Skip to content

Commit bd249f5

Browse files
authored
Merge pull request #167 from OWASP/test-126-public-surface
Guard facade mappings and JSP/Jakarta parity
2 parents b718a9f + 6f4a329 commit bd249f5

52 files changed

Lines changed: 745 additions & 2747 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/build.yaml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ jobs:
1414
runs-on: ubuntu-latest
1515
steps:
1616
- uses: actions/checkout@v7
17+
- name: Check JSP and Jakarta source parity
18+
run: python3 scripts/check-taglib-parity.py
1719
- name: Set up JDK 17
1820
uses: actions/setup-java@v6
1921
with:

‎core/src/main/java/org/owasp/encoder/ASCIIBits.java‎

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -102,23 +102,4 @@ ASCIIBits set(int min, int max) {
102102
return this;
103103
}
104104

105-
/**
106-
* Sets a range of characters to 0s in the masks.
107-
*
108-
* @param min the minimum (inclusive).
109-
* @param max the maximum (inclusive).
110-
* @return {@code this}
111-
*/
112-
ASCIIBits clear(char min, char max) {
113-
// There are faster bit-twiddling tricks to accomplish the same
114-
// thing as below. Minor optimization for later.
115-
int i=min;
116-
for (int n = Math.min(max,63) ; i<=n ; ++i) {
117-
_lowerMask &= ~(1L << i);
118-
}
119-
for ( ; i<=max ; ++i) {
120-
_upperMask &= ~(1L << i);
121-
}
122-
return this;
123-
}
124105
}

‎core/src/test/java/org/owasp/encoder/CDATAEncoderTest.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public static Test suite() {
5353
.encode("]]]]><![CDATA[>>", "]]>>")
5454
.encode("]]]]]", "]]]]]")
5555
.encode("<\"&\'>", "<\"&\'>") // valid in CDATA, not in XML
56+
.encode("missing-low-surrogate", " x", "\ud800x")
5657

5758
.invalid(0, 0x1f)
5859
.valid("\t\r\n")

‎core/src/test/java/org/owasp/encoder/CSSEncoderTest.java‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
*/
4646
public class CSSEncoderTest extends TestCase {
4747
public static Test suite() {
48-
TestSuite suite = new TestSuite();
48+
TestSuite suite = new TestSuite(CSSEncoderTest.class);
4949

5050
for (CSSEncoder.Mode mode : CSSEncoder.Mode.values()) {
5151
EncoderTestSuiteBuilder builder = new EncoderTestSuiteBuilder(new CSSEncoder(mode), "safe", "'")
@@ -55,6 +55,7 @@ public static Test suite() {
5555
.encode("no-space-required-after-encode", "\\27x", "'x")
5656
.encode("NUL", "\\0", "\0")
5757
.encode("DEL", "\\7f", "\u007f")
58+
.encode("missing-low-surrogate", "_x", "\ud800x")
5859

5960
.encoded(0, '\237')
6061
.valid("!#$%")
@@ -66,6 +67,21 @@ public static Test suite() {
6667
.encode("Paragraph Separator", "\\2029", "\u2029")
6768
.invalid(Character.MIN_SURROGATE, Character.MAX_SURROGATE);
6869

70+
for (char next : "aAfF \t\n\r\f".toCharArray()) {
71+
String suffix;
72+
if (next == ' ' && mode == CSSEncoder.Mode.STRING) {
73+
suffix = " ";
74+
} else if (next <= ' ') {
75+
suffix = "\\" + Integer.toHexString(next);
76+
} else {
77+
suffix = String.valueOf(next);
78+
}
79+
// A separator protects the escape from a hex digit, or from
80+
// consuming whitespace that belongs to the original input.
81+
builder.encode("escape-lookahead-U+" + Integer.toHexString(next),
82+
"\\27 " + suffix, "'" + next);
83+
}
84+
6985
switch (mode) {
7086
case STRING:
7187
builder.valid(' ', '~');
@@ -87,4 +103,21 @@ public static Test suite() {
87103

88104
return suite;
89105
}
106+
107+
public void testEscapeLookaheadAcrossWrites() throws Exception {
108+
for (CSSEncoder.Mode mode : CSSEncoder.Mode.values()) {
109+
for (char next : "aAfF \t\n\r\f".toCharArray()) {
110+
java.io.StringWriter out = new java.io.StringWriter();
111+
EncodedWriter writer = new EncodedWriter(out, new CSSEncoder(mode));
112+
writer.write("'");
113+
writer.flush();
114+
// flush must retain the pending quote until lookahead arrives.
115+
assertEquals("", out.toString());
116+
writer.write(next);
117+
writer.close();
118+
assertEquals(mode + " lookahead U+" + Integer.toHexString(next),
119+
Encode.encode(new CSSEncoder(mode), "'" + next), out.toString());
120+
}
121+
}
122+
}
90123
}

core/src/main/java/org/owasp/encoder/ChainedEncoder.java renamed to core/src/test/java/org/owasp/encoder/ChainedEncoder.java

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,8 @@
3838
import java.nio.charset.CoderResult;
3939

4040
/**
41-
* ChainedEncoder -- An encoder that chains together two encoders in
42-
* order. This is included as an example, but not actually exposed or
43-
* used as it requires an internal buffer making it not thread-safe.
41+
* ChainedEncoder -- A test fixture that chains together two encoders in
42+
* order. Its internal buffer makes it unsuitable for shared production use.
4443
* Sequences of 3 or more encodings require chaining together chained
4544
* encoders.
4645
*
@@ -56,9 +55,7 @@ class ChainedEncoder extends Encoder {
5655
* The buffer used to store the output of the first encoder before
5756
* sending as input to the second encoder.
5857
*/
59-
// TODO: because of this buffer, the ChainedEncoder is the only stateful
60-
// encoder. This needs to be removed somehow, or state control APIs need
61-
// to be added (E.g. reset/flush)
58+
// Each test must own its instance; this fixture is not thread-safe.
6259
final CharBuffer _buffer = CharBuffer.allocate(1024);
6360

6461
/**
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) 2026 OWASP
2+
// All rights reserved.
3+
//
4+
// Redistribution and use in source and binary forms, with or without
5+
// modification, are permitted provided that the following conditions
6+
// are met:
7+
//
8+
// * Redistributions of source code must retain the above
9+
// copyright notice, this list of conditions and the following
10+
// disclaimer.
11+
//
12+
// * Redistributions in binary form must reproduce the above
13+
// copyright notice, this list of conditions and the following
14+
// disclaimer in the documentation and/or other materials
15+
// provided with the distribution.
16+
//
17+
// * Neither the name of the OWASP nor the names of its
18+
// contributors may be used to endorse or promote products
19+
// derived from this software without specific prior written
20+
// permission.
21+
//
22+
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
23+
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
24+
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
25+
// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
26+
// COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
27+
// INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
28+
// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
29+
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
30+
// HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
31+
// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
32+
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
33+
// OF THE POSSIBILITY OF SUCH DAMAGE.
34+
35+
package org.owasp.encoder;
36+
37+
import java.io.StringWriter;
38+
import java.io.Writer;
39+
import java.lang.reflect.Field;
40+
import java.lang.reflect.Method;
41+
import java.lang.reflect.Modifier;
42+
import java.util.Arrays;
43+
import java.util.Set;
44+
import java.util.TreeSet;
45+
import junit.framework.TestCase;
46+
47+
/** Guards the complete public facade, including legacy URI and XML 1.1. */
48+
public class EncodeFacadeTest extends TestCase {
49+
public void testEveryContextAndOverloadHasAnExpectation() throws Exception {
50+
Set<String> names = new TreeSet<String>();
51+
Set<String> methods = new TreeSet<String>();
52+
for (EncoderContexts context : EncoderContexts.ALL) {
53+
assertTrue("duplicate context " + context.name, names.add(context.name));
54+
assertTrue("duplicate method " + context.method, methods.add(context.method));
55+
}
56+
Set<String> constants = new TreeSet<String>();
57+
for (Field field : Encoders.class.getFields()) {
58+
if (Modifier.isStatic(field.getModifiers()) && Modifier.isFinal(field.getModifiers())
59+
&& field.getType() == String.class) {
60+
assertTrue("duplicate constant " + field, constants.add((String) field.get(null)));
61+
}
62+
}
63+
assertEquals("registry constants", constants, names);
64+
Set<String> stringMethods = new TreeSet<String>();
65+
Set<String> writerMethods = new TreeSet<String>();
66+
for (Method method : Encode.class.getMethods()) {
67+
if (!Modifier.isStatic(method.getModifiers()) || !method.getName().startsWith("for")) {
68+
continue;
69+
}
70+
if (Arrays.equals(new Class<?>[] {String.class}, method.getParameterTypes())) {
71+
assertEquals(String.class, method.getReturnType());
72+
stringMethods.add(method.getName());
73+
} else {
74+
assertTrue("unexpected overload " + method, Arrays.equals(
75+
new Class<?>[] {Writer.class, String.class}, method.getParameterTypes()));
76+
assertEquals(Void.TYPE, method.getReturnType());
77+
writerMethods.add(method.getName());
78+
}
79+
}
80+
assertEquals("String facade", methods, stringMethods);
81+
assertEquals("Writer facade", methods, writerMethods);
82+
}
83+
84+
public void testFacadeDelegation() throws Exception {
85+
for (EncoderContexts context : EncoderContexts.ALL) {
86+
for (String input : new String[] {null, "", "plain text", EncoderContexts.probe()}) {
87+
assertFacade(context, input);
88+
}
89+
// Place unsafe text and surrogate pairs on both sides of buffer boundaries.
90+
for (int size : new int[] {1023, 1024, 1025, 2047, 2048, 2049}) {
91+
char[] padding = new char[size - 1];
92+
Arrays.fill(padding, 'a');
93+
// NUL requires encoding in every context, bypassing the safe
94+
// prefix fast path; the high surrogate starts at index size.
95+
assertFacade(context, "\0" + new String(padding) + "\ud83d\ude00" + EncoderContexts.probe());
96+
}
97+
}
98+
}
99+
100+
private void assertFacade(EncoderContexts context, String input) throws Exception {
101+
Encoder encoder = Encoders.forName(context.name);
102+
String expected = input == null ? "null" : Encode.encode(encoder, input);
103+
Method stringMethod = Encode.class.getMethod(context.method, String.class);
104+
Method writerMethod = Encode.class.getMethod(context.method, Writer.class, String.class);
105+
assertEquals(context.method + " String", expected, stringMethod.invoke(null, (Object) input));
106+
StringWriter out = new StringWriter();
107+
writerMethod.invoke(null, out, input);
108+
assertEquals(context.method + " Writer", expected, out.toString());
109+
}
110+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2026 OWASP
2+
// All rights reserved.
3+
//
4+
// Redistribution and use in source and binary forms, with or without
5+
// modification, are permitted provided that the following conditions
6+
// are met:
7+
//
8+
// * Redistributions of source code must retain the above
9+
// copyright notice, this list of conditions and the following
10+
// disclaimer.
11+
//
12+
// * Redistributions in binary form must reproduce the above
13+
// copyright notice, this list of conditions and the following
14+
// disclaimer in the documentation and/or other materials
15+
// provided with the distribution.
16+
//
17+
// * Neither the name of the OWASP nor the names of its
18+
// contributors may be used to endorse or promote products
19+
// derived from this software without specific prior written
20+
// permission.
21+
//
22+
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
23+
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
24+
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
25+
// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
26+
// COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
27+
// INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
28+
// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
29+
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
30+
// HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
31+
// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
32+
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
33+
// OF THE POSSIBILITY OF SUCH DAMAGE.
34+
35+
package org.owasp.encoder;
36+
37+
import java.util.Arrays;
38+
import java.util.List;
39+
40+
/** Independent context wiring expectations shared by facade and registry tests. */
41+
final class EncoderContexts {
42+
final String name;
43+
final String method;
44+
final Encoder encoder;
45+
46+
private EncoderContexts(String name, String method, Encoder encoder) {
47+
this.name = name;
48+
this.method = method;
49+
this.encoder = encoder;
50+
}
51+
52+
static final List<EncoderContexts> ALL = Arrays.asList(
53+
new EncoderContexts("html", "forHtml", new XMLEncoder(XMLEncoder.Mode.ALL)),
54+
new EncoderContexts("html-content", "forHtmlContent", new XMLEncoder(XMLEncoder.Mode.CONTENT)),
55+
new EncoderContexts("html-attribute", "forHtmlAttribute", new XMLEncoder(XMLEncoder.Mode.ATTRIBUTE)),
56+
new EncoderContexts("html-attribute-unquoted", "forHtmlUnquotedAttribute", new HTMLEncoder()),
57+
new EncoderContexts("xml", "forXml", new XMLEncoder(XMLEncoder.Mode.ALL)),
58+
new EncoderContexts("xml-content", "forXmlContent", new XMLEncoder(XMLEncoder.Mode.CONTENT)),
59+
new EncoderContexts("xml-attribute", "forXmlAttribute", new XMLEncoder(XMLEncoder.Mode.ATTRIBUTE)),
60+
new EncoderContexts("xml-comment", "forXmlComment", new XMLCommentEncoder()),
61+
new EncoderContexts("xml-1.1", "forXml11", new XMLEncoder(XMLEncoder.Mode.ALL, XMLEncoder.Version.XML_1_1)),
62+
new EncoderContexts("xml-1.1-content", "forXml11Content", new XMLEncoder(XMLEncoder.Mode.CONTENT, XMLEncoder.Version.XML_1_1)),
63+
new EncoderContexts("xml-1.1-attribute", "forXml11Attribute", new XMLEncoder(XMLEncoder.Mode.ATTRIBUTE, XMLEncoder.Version.XML_1_1)),
64+
new EncoderContexts("cdata", "forCDATA", new CDATAEncoder()),
65+
new EncoderContexts("css-string", "forCssString", new CSSEncoder(CSSEncoder.Mode.STRING)),
66+
new EncoderContexts("css-url", "forCssUrl", new CSSEncoder(CSSEncoder.Mode.URL)),
67+
new EncoderContexts("java", "forJava", new JavaEncoder()),
68+
new EncoderContexts("javascript", "forJavaScript", new JavaScriptEncoder(JavaScriptEncoder.Mode.HTML, false)),
69+
new EncoderContexts("javascript-attribute", "forJavaScriptAttribute", new JavaScriptEncoder(JavaScriptEncoder.Mode.ATTRIBUTE, false)),
70+
new EncoderContexts("javascript-block", "forJavaScriptBlock", new JavaScriptEncoder(JavaScriptEncoder.Mode.BLOCK, false)),
71+
new EncoderContexts("javascript-source", "forJavaScriptSource", new JavaScriptEncoder(JavaScriptEncoder.Mode.SOURCE, false)),
72+
new EncoderContexts("json", "forJson", new JSONEncoder()),
73+
// Deprecated but retained as a supported compatibility entry point.
74+
new EncoderContexts("uri", "forUri", new URIEncoder(URIEncoder.Mode.FULL_URI)),
75+
new EncoderContexts("uri-component", "forUriComponent", new URIEncoder(URIEncoder.Mode.COMPONENT))
76+
);
77+
78+
static String probe() {
79+
StringBuilder value = new StringBuilder();
80+
// Every ASCII metacharacter plus DEL and C1 controls distinguishes modes.
81+
for (char ch = 0; ch <= 0x9f; ch++) {
82+
value.append(ch);
83+
}
84+
return value.append("--]]> ${x} \u00e9\u2028\u2029\ud83d\ude00\ud800X\udc00\ufdd0\ufffe\uffff\ud800").toString();
85+
}
86+
}

‎core/src/test/java/org/owasp/encoder/EncodersTest.java‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
import java.lang.reflect.Field;
4242
import java.lang.reflect.Modifier;
43+
import java.io.StringWriter;
4344

4445
/**
4546
* EncodersTest -- Tests for the Encoders class.
@@ -48,6 +49,50 @@
4849
*/
4950
public class EncodersTest extends TestCase {
5051

52+
public void testConcreteContextMappings() {
53+
for (EncoderContexts context : EncoderContexts.ALL) {
54+
Encoder actual = Encoders.forName(context.name);
55+
assertEquals(context.name, context.encoder.getClass(), actual.getClass());
56+
assertSame(context.name + " singleton", actual, Encoders.forName(context.name));
57+
// Compare against an independently constructed encoder so a facade and
58+
// registry wired to the same wrong singleton cannot agree accidentally.
59+
assertEquals(context.name, Encode.encode(context.encoder, EncoderContexts.probe()),
60+
Encode.encode(actual, EncoderContexts.probe()));
61+
if (actual instanceof XMLEncoder || actual instanceof JavaScriptEncoder
62+
|| actual instanceof CSSEncoder || actual instanceof URIEncoder) {
63+
assertEquals(context.name + " mode/version", context.encoder.toString(), actual.toString());
64+
}
65+
}
66+
}
67+
68+
public void testNullContext() {
69+
try {
70+
Encoders.forName(null);
71+
fail("null context accepted");
72+
} catch (NullPointerException expected) {
73+
// Required by the factory contract.
74+
}
75+
}
76+
77+
public void testUnknownAndCaseSensitiveContexts() {
78+
for (String name : new String[] {"nope", "", "HTML"}) {
79+
try {
80+
Encoders.forName(name);
81+
fail("unknown context accepted: " + name);
82+
} catch (UnsupportedContextException expected) {
83+
assertEquals(name, expected.getMessage());
84+
}
85+
}
86+
}
87+
88+
public void testWriterByContextName() throws Exception {
89+
StringWriter out = new StringWriter();
90+
EncodedWriter writer = new EncodedWriter(out, "html");
91+
writer.write("<&\"'>");
92+
writer.close();
93+
assertEquals("&lt;&amp;&#34;&#39;&gt;", out.toString());
94+
}
95+
5196
public static Test suite() throws Exception {
5297
return new TestSuite(EncodersTest.class);
5398
}

0 commit comments

Comments
 (0)