diff --git a/CHANGELOG.md b/CHANGELOG.md
index 54229f4..45c7fbc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,17 @@ unchanged. [1.5.1 is available from Central](releases/1.5.1-central-publication.
## Unreleased
+- Reduce unnecessary script-letter escaping in `forJavaScript` and
+ `forJavaScriptBlock` (#243). Interior letters remain readable; the input's last
+ character is escaped if it is C, I, P, R, S, or T (either case). Existing punctuation escapes and
+ protection against recognized HTML tokens across trusted-text boundaries are
+ preserved. Encoded bytes change, while JavaScript string values do not.
+ Streaming encoders may retain one trailing letter; `EncodedWriter.flush()`
+ safely emits it as a hex escape and any pending high surrogate as a Unicode
+ escape. Explicit flushes can add safe escapes while preserving UTF-16 values.
+ Direct `Encoder.encode` callers must retain unread input after underflow and
+ supply more input or mark end-of-input, including for a pending ASCII letter.
+
## 1.5.1 — 2026-10-15 UTC
Documentation and build-maintenance patch; publication is verified in the
diff --git a/core/src/main/java/org/owasp/encoder/Encode.java b/core/src/main/java/org/owasp/encoder/Encode.java
index 7adcd7e..ae46eef 100644
--- a/core/src/main/java/org/owasp/encoder/Encode.java
+++ b/core/src/main/java/org/owasp/encoder/Encode.java
@@ -1127,9 +1127,11 @@ public static void forJava(Writer out, String input)
* before the insertion, with the closing > supplied later.
In HTML script blocks, a nonempty encoded fragment cannot complete the
- * raw sequences </script or <script
- * (case-insensitive),
+ * HTML tokens </script or <script
+ * (case-insensitive, followed by an HTML name delimiter),
* <!--, or --> with adjacent trusted text.
+ * A longer name such as </scripture is not a script end tag.
+ * HTML does not remove whitespace between the letters of a tag name.
* For example, trusted </scr, an encoded i, and
* trusted pt> cannot form an HTML closing script tag.
* Preventing a split <script also avoids entering the HTML
@@ -1138,8 +1140,21 @@ public static void forJava(Writer out, String input)
* Empty input produces empty output and cannot separate dangerous trusted
* fragments. These protections do not sanitize the surrounding template.
HTML punctuation and whitespace escapes break tokens within the input.
+ * A final C, I, P, R, S, or T (in either case) is also hex-escaped to prevent
+ * adjacent trusted text from completing a split script name. Interior letters
+ * are unchanged. Thus i becomes \x69, while
+ * script becomes scrip\x74.
+ * Low-level streaming encoders retain a trailing script letter until more
+ * input arrives or the stream ends. An explicit {@link EncodedWriter#flush()}
+ * emits a pending script letter as a hex escape or a pending high surrogate
+ * as a Unicode escape. A later low surrogate is escaped separately, preserving
+ * the UTF-16 string value. Flushing between writes can therefore add escapes
+ * compared with encoding the whole value;
+ * it does not change the JavaScript string value.
For example, forJavaScript("http://google.com") returns
- * h\x74\x74\x70:\/\/google.\x63om, which evaluates to the
+ * http:\/\/google.com, which evaluates to the
* original URL inside a supported JavaScript string literal.
* {@link #forJavaScriptSource(String)} and
* {@link #forJavaScriptAttribute(String)} leave this URL unchanged, but
@@ -1243,11 +1258,11 @@ public static void forJava(Writer out, String input)
*
*
\x##</script or
- * <script with
- * adjacent trusted text; see the insertion-boundary requirements above.\x## at the end of input only\DEL and C1 controls (U+007F to U+009F) are hex-escaped. Unpaired UTF-16 * surrogates use Unicode escapes; valid surrogate pairs remain unescaped.
@@ -1405,7 +1420,7 @@ public static void forJavaScriptAttribute(Writer out, String input) * *The insertion-boundary requirements and nonempty-fragment HTML * protection of {@link #forJavaScript(String)} apply. This method retains - * the script-letter escapes; its shorter quote escapes do not make it safe + * the terminal script-letter escape; its shorter quote escapes do not make it safe * for HTML event attributes.
* * Example JSP Usage: @@ -1454,8 +1469,8 @@ public static void forJavaScriptBlock(Writer out, String input) * provide surrounding single (') or double (") quotation marks, or backticks * (`) for an ordinary (untagged) template literal. This method * performs the same encode as {@link #forJavaScript(String)} except that - * slash, hyphen, space, {@code !}, {@code <}, {@code >}, both cases of C, - * I, P, R, S, and T, and ampersand are not escaped; double and single + * slash, hyphen, space, {@code !}, {@code <}, {@code >}, terminal C, + * I, P, R, S, and T (either case), and ampersand are not escaped; double and single * quotes use backslash escapes. *DEL and C1 controls (U+007F to U+009F) are hex-escaped. Unpaired UTF-16 * surrogates use Unicode escapes; valid surrogate pairs remain unescaped.
diff --git a/core/src/main/java/org/owasp/encoder/EncodedWriter.java b/core/src/main/java/org/owasp/encoder/EncodedWriter.java index 7661133..978e3ad 100644 --- a/core/src/main/java/org/owasp/encoder/EncodedWriter.java +++ b/core/src/main/java/org/owasp/encoder/EncodedWriter.java @@ -45,7 +45,17 @@ * and close operations synchronize on the wrapped writer. Keep each logical * encoding stream in its own EncodedWriter; synchronization does not prevent * separate callers' content from being interleaved. Close the writer to finish - * pending input; flush alone does not mark the end of input. + * pending input; flush alone does not mark the end of input. In HTML-script + * JavaScript modes, flush safely emits a pending trailing letter from + * {@code script} as a hex escape, or a pending high surrogate as a Unicode escape. + * A later low surrogate is escaped separately, preserving the same JavaScript + * UTF-16 string value. Explicit flushes can therefore add escapes compared with + * encoding the whole value. In these modes, call {@link #flush()} before writing + * directly to the wrapped writer so buffered output and pending input appear + * in order. In other contexts, incomplete sequences can remain pending even + * after flush; do not write a trusted suffix while input remains pending. For a complete + * string followed by trusted output, use the Writer overloads of {@link Encode} + * instead. Closing this writer also closes its wrapped writer. * * @author Jeff Ichnowski */ @@ -234,6 +244,13 @@ private void ensureOpen() throws IOException { public void flush() throws IOException { synchronized (lock) { ensureOpen(); + if (_hasLeftOver && _leftOverBuffer.position() == 1 + && _encoder.canFlushPending(_leftOverBuffer.get(0))) { + // Make a safely representable code unit visible before callers + // write directly to the wrapped writer. Other encoders retain + // their existing incomplete-sequence lookahead behavior. + flushLeftOver(null); + } flushBufferToWriter(); _out.flush(); } diff --git a/core/src/main/java/org/owasp/encoder/Encoder.java b/core/src/main/java/org/owasp/encoder/Encoder.java index 9aaee5c..db8bb1a 100644 --- a/core/src/main/java/org/owasp/encoder/Encoder.java +++ b/core/src/main/java/org/owasp/encoder/Encoder.java @@ -44,8 +44,9 @@ * OWASP Encoder's project is not currently supported. * *All encoder singletons returned by {@link Encoders} are stateless and - * thread-safe. Incomplete sequences remain in the caller's input buffer; - * callers must preserve those characters between calls and must not share + * thread-safe. Characters needing lookahead remain in the caller's input buffer; + * these include incomplete Unicode sequences and trailing HTML-script letters. + * Callers must preserve those characters between calls and must not share * mutable input or output buffers across concurrent calls.
* *To use an Encoder instance directly, repeatedly call {@link @@ -110,7 +111,12 @@ public abstract class Encoder { * true}). This will happen when the encoder needs to see more * input before determining what to do--for example when an encoder sees * a high surrogate at the end of a non-final input buffer and needs the - * next code unit to determine whether it forms a pair.
+ * next code unit to determine whether it forms a pair. HTML-script JavaScript + * encoders also retain a trailing letter from {@code script} until another + * code unit or end-of-input determines whether it needs escaping. After + * {@code UNDERFLOW}, retain unread input and supply more input, or set + * {@code endOfInput} to {@code true} when the logical input is complete; + * retrying the same incomplete input with {@code false} cannot progress. * *The output buffer must have enough remaining space for an entire * encoded sequence. Allow at least 13 characters of output space to support @@ -239,6 +245,18 @@ CoderResult encodeBuffers(CharBuffer input, CharBuffer output, boolean endOfInpu */ abstract int firstEncodedOffset(String input, int off, int len); + /** + * Whether one retained UTF-16 code unit can be represented at an explicit + * writer flush without changing its value when more input follows. Encoders + * that would replace an incomplete sequence must leave it pending instead. + * + * @param ch the sole retained code unit + * @return true if encoding this code unit with endOfInput is safe on flush + */ + boolean canFlushPending(char ch) { + return false; + } + /** * Internal helper method to properly position buffers after encoding up * until an overflow. diff --git a/core/src/main/java/org/owasp/encoder/JavaScriptEncoder.java b/core/src/main/java/org/owasp/encoder/JavaScriptEncoder.java index 0cfb82a..1ef118b 100644 --- a/core/src/main/java/org/owasp/encoder/JavaScriptEncoder.java +++ b/core/src/main/java/org/owasp/encoder/JavaScriptEncoder.java @@ -67,7 +67,7 @@ enum Mode { * prematurely terminating a script block with a closing * "</script" token inside the string. Characters that can contribute * to an HTML script delimiter are escaped so a nonempty encoded fragment - * cannot complete a raw token supplied partly by trusted literal text. + * cannot complete a token supplied partly by trusted literal text. * The surrounding template must satisfy the insertion-boundary * requirements documented by Encode.forJavaScript. */ @@ -101,6 +101,11 @@ enum Mode { * characters that would normally not be encoded, will be encoded. */ private final boolean _asciiOnly; + /** + * True if HTML script tokens split across insertion boundaries must be + * prevented in addition to JavaScript string syntax. + */ + private final boolean _htmlScript; /** * Constructs a new JavaScriptEncoder for the specified contextual mode. @@ -116,6 +121,7 @@ enum Mode { // (to be clear, it's much faster on 32-bit VMS, and just slightly // faster on 64-bit VMS) _mode = mode; + _htmlScript = mode == Mode.BLOCK || mode == Mode.HTML; // Note: this probably needs to be repeated everywhere this trick is // used, but here seems like as good a place as any. According to @@ -133,23 +139,22 @@ enum Mode { -1 & ~((1 << '\\')), ~((1 << Unicode.DEL) | (1 << '`') | (1 << '{')),}; - if (mode == Mode.BLOCK || mode == Mode.HTML) { + if (_htmlScript) { // In ". To do so we escape "/" as - // "\/" and hex-escape every other ASCII character that could form - // part of an HTML script delimiter. This prevents - // the encoding of any nonempty value from completing "" with adjacent trusted literal text. Other ASCII - // whitespace already uses JavaScript escapes in every mode. + // "-->". Escaping their punctuation prevents tokens from starting + // inside encoded input or across its right boundary. A final + // script-name letter is also escaped below, breaking a token name + // that began in trusted text before the input. Earlier letters + // need not be escaped: the name will encounter either encoded + // punctuation, a mismatching character, or the final letter escape. + // Non-final buffers leave a possible final letter unread until + // more input arrives. Other ASCII whitespace already uses + // JavaScript escapes in every mode. // A JavaScript backslash escape would not prevent the exploits on // "" and "b")); } - public void testEncodedWriterFlushDoesNotFinalizeSurrogate() throws Exception { + public void testEncodedWriterFlushPreservesSurrogatesWithContextSpecificSpelling() throws Exception { String pair = "\ud83d\ude00"; for (String context : new String[] {Encoders.JAVASCRIPT, Encoders.JAVASCRIPT_BLOCK, Encoders.CDATA, Encoders.XML_COMMENT}) { @@ -228,8 +263,10 @@ public void testEncodedWriterFlushDoesNotFinalizeSurrogate() throws Exception { writer.flush(); writer.write(pair.charAt(1)); writer.close(); - assertEquals(context, Encode.encode(Encoders.forName(context), pair), - output.toString()); + String expected = Encoders.JAVASCRIPT.equals(context) + || Encoders.JAVASCRIPT_BLOCK.equals(context) + ? "\\ud83d\\ude00" : Encode.encode(Encoders.forName(context), pair); + assertEquals(context, expected, output.toString()); } } @@ -456,7 +493,16 @@ private static void assertEncodedWriterSplits(String context, String input, writer.flush(); writer.write(input, split, input.length() - split); writer.close(); - assertEquals(context + " split " + split, expected, output.toString()); + // Explicit flush can finalize a script letter conservatively. These + // fixtures contain no surrogate pair split, so each flushed part is + // independently finalized. Other contexts keep their prior result. + boolean script = Encoders.JAVASCRIPT.equals(context) + || Encoders.JAVASCRIPT_BLOCK.equals(context); + String flushedExpected = script + ? Encode.encode(Encoders.forName(context), input.substring(0, split)) + + Encode.encode(Encoders.forName(context), input.substring(split)) + : expected; + assertEquals(context + " split " + split, flushedExpected, output.toString()); } StringWriter output = new StringWriter(); @@ -466,6 +512,13 @@ private static void assertEncodedWriterSplits(String context, String input, writer.flush(); } writer.close(); + if (Encoders.JAVASCRIPT.equals(context) || Encoders.JAVASCRIPT_BLOCK.equals(context)) { + StringBuilder flushedExpected = new StringBuilder(); + for (int i = 0; i < input.length(); i++) { + flushedExpected.append(Encode.encode(Encoders.forName(context), input.substring(i, i + 1))); + } + expected = flushedExpected.toString(); + } assertEquals(context + " one-character chunks", expected, output.toString()); } diff --git a/core/src/test/java/org/owasp/encoder/EncoderTestSuiteBuilder.java b/core/src/test/java/org/owasp/encoder/EncoderTestSuiteBuilder.java index b365dc9..c9e218c 100644 --- a/core/src/test/java/org/owasp/encoder/EncoderTestSuiteBuilder.java +++ b/core/src/test/java/org/owasp/encoder/EncoderTestSuiteBuilder.java @@ -269,6 +269,12 @@ public EncoderTestSuiteBuilder encode(final String expected, final String input) * @return this. */ public EncoderTestSuiteBuilder encode(String name, final String expected, final String input) { + return encode(name, expected, expected, input); + } + + /** Adds a fixture whose terminal escape changes when more input follows. */ + public EncoderTestSuiteBuilder encode(String name, final String expected, + final String expectedBeforeSuffix, final String input) { return add(new TestCase(name) { @Override protected void runTest() throws Throwable { @@ -277,15 +283,15 @@ protected void runTest() throws Throwable { // test input surrounded by safe characters checkEncode(_safeAffix + expected, _safeAffix + input); - checkEncode(expected + _safeAffix, input + _safeAffix); - checkEncode(_safeAffix + expected + _safeAffix, + checkEncode(expectedBeforeSuffix + _safeAffix, input + _safeAffix); + checkEncode(_safeAffix + expectedBeforeSuffix + _safeAffix, _safeAffix + input + _safeAffix); // test input surrounded by characters needing escape String escapedAffix = Encode.encode(_encoder, _unsafeAffix); checkEncode(escapedAffix + expected, _unsafeAffix + input); - checkEncode(expected + escapedAffix, input + _unsafeAffix); - checkEncode(escapedAffix + expected + escapedAffix, + checkEncode(expectedBeforeSuffix + escapedAffix, input + _unsafeAffix); + checkEncode(escapedAffix + expectedBeforeSuffix + escapedAffix, _unsafeAffix + input + _unsafeAffix); } }); diff --git a/core/src/test/java/org/owasp/encoder/JavaScriptBoundaryOptimizationTest.java b/core/src/test/java/org/owasp/encoder/JavaScriptBoundaryOptimizationTest.java new file mode 100644 index 0000000..3254d0e --- /dev/null +++ b/core/src/test/java/org/owasp/encoder/JavaScriptBoundaryOptimizationTest.java @@ -0,0 +1,574 @@ +// Copyright (c) 2026 OWASP. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// +// * Redistributions of source code must retain the above +// copyright notice, this list of conditions and the following +// disclaimer. +// +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following +// disclaimer in the documentation and/or other materials +// provided with the distribution. +// +// * Neither the name of the OWASP nor the names of its +// contributors may be used to endorse or promote products +// derived from this software without specific prior written +// permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +// COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +// INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +// HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +// OF THE POSSIBILITY OF SUCH DAMAGE. + +package org.owasp.encoder; + +import java.io.StringWriter; +import java.nio.CharBuffer; +import java.nio.charset.CoderResult; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import junit.framework.TestCase; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; + +/** Output-size and streaming regressions for terminal script-name escapes. */ +public class JavaScriptBoundaryOptimizationTest extends TestCase { + private static final String[] CONTEXTS = { + Encoders.JAVASCRIPT, Encoders.JAVASCRIPT_BLOCK + }; + private static final Encoder[] ENCODERS = { + Encoders.JAVASCRIPT_ENCODER, Encoders.JAVASCRIPT_BLOCK_ENCODER + }; + + public void testCommonTextAvoidsNonterminalLetterEscapes() throws Exception { + String[][] samples = { + {"http://google.com", "http:\\/\\/google.com"}, + {"scripture", "scripture"}, + {"CIPRSTciprstx", "CIPRSTciprstx"}, + {"script", "scrip\\x74"}, + {"cat", "ca\\x74"}, + {"", "\\x3c\\/script\\x3e"}, + {"", "\\x3c\\x21\\x2d\\x2d\\x20\\x2d\\x2d\\x3e"}, + {"", ""}, + {null, "null"} + }; + for (int mode = 0; mode < ENCODERS.length; mode++) { + for (String[] sample : samples) { + assertEquals(CONTEXTS[mode], sample[1], facade(mode, sample[0])); + StringWriter output = new StringWriter(); + if (mode == 0) { + Encode.forJavaScript(output, sample[0]); + } else { + Encode.forJavaScriptBlock(output, sample[0]); + } + assertEquals(CONTEXTS[mode], sample[1], output.toString()); + assertEquals(CONTEXTS[mode], sample[1], + Encode.encode(Encoders.forName(CONTEXTS[mode]), sample[0])); + } + } + assertEquals("http://google.com", Encode.forJavaScriptSource("http://google.com")); + assertEquals("http://google.com", Encode.forJavaScriptAttribute("http://google.com")); + } + + public void testEveryTerminalScriptLetterIsEscapedOnlyInScriptModes() { + for (char ch : "CIPRSTciprst".toCharArray()) { + String input = "a" + ch; + String expected = "a\\x" + Integer.toHexString(ch); + assertEquals(expected, Encode.forJavaScript(input)); + assertEquals(expected, Encode.forJavaScriptBlock(input)); + assertEquals(input, Encode.forJavaScriptSource(input)); + assertEquals(input, Encode.forJavaScriptAttribute(input)); + } + } + + public void testFastPathRespectsSubstringEndAndOffsets() { + for (Encoder encoder : ENCODERS) { + assertEquals(9, encoder.firstEncodedOffset("scripture", 0, 9)); + assertEquals(5, encoder.firstEncodedOffset("script", 0, 6)); + assertEquals(6, encoder.firstEncodedOffset("xScriptz", 1, 6)); + assertEquals(10, encoder.firstEncodedOffset("xscriptureY", 1, 9)); + assertEquals(1, encoder.firstEncodedOffset("xt!x", 1, 1)); + assertEquals(2, encoder.firstEncodedOffset("xs!x", 1, 2)); + assertEquals(2, encoder.firstEncodedOffset("xx", 2, 0)); + } + String unchanged = new String("scripture"); + assertSame(unchanged, Encode.forJavaScript(unchanged)); + assertSame(unchanged, Encode.forJavaScriptBlock(unchanged)); + } + + public void testFacadeBuffersDoNotCreateAdditionalTerminalEscapes() throws Exception { + for (int mode = 0; mode < ENCODERS.length; mode++) { + for (int size : new int[]{1, 1023, 1024, 1025, 2047, 2048, 2049, 8193}) { + String prefix = repeat('a', size - 1); + assertEquals(prefix + "\\x74", facade(mode, prefix + "t")); + String input = "!" + prefix + "ta"; + String expected = "\\x21" + prefix + "ta"; + assertEquals(expected, facade(mode, input)); + StringWriter output = new StringWriter(); + if (mode == 0) { + Encode.forJavaScript(output, input); + } else { + Encode.forJavaScriptBlock(output, input); + } + assertEquals(expected, output.toString()); + } + String outputOverflow = repeat('!', 2048) + "scripture"; + assertEquals(repeatEncoded("\\x21", 2048) + "scripture", + facade(mode, outputOverflow)); + } + } + + public void testLowLevelRetainsOneLetterUntilItKnowsTheInputEnds() { + for (Encoder encoder : ENCODERS) { + for (char ch : "CIPRSTciprst".toCharArray()) { + CharBuffer input = CharBuffer.allocate(2); + input.put(ch).flip(); + CharBuffer output = CharBuffer.allocate(16); + assertTrue(encoder.encode(input, output, false).isUnderflow()); + assertEquals(0, input.position()); + assertEquals(0, output.position()); + input.compact(); + input.put('a').flip(); + assertTrue(encoder.encode(input, output, false).isUnderflow()); + assertFalse(input.hasRemaining()); + output.flip(); + assertEquals(ch + "a", output.toString()); + + input.clear(); + input.put(ch).flip(); + output.clear(); + assertTrue(encoder.encode(input, output, false).isUnderflow()); + assertTrue(encoder.encode(input, output, true).isUnderflow()); + assertFalse(input.hasRemaining()); + output.flip(); + assertEquals("\\x" + Integer.toHexString(ch), output.toString()); + } + } + } + + public void testTerminalEscapeOverflowIsAtomic() { + for (Encoder encoder : ENCODERS) { + for (int capacity = 0; capacity < 4; capacity++) { + CharBuffer input = CharBuffer.wrap(new char[]{'^', '^', 's', '^'}); + input.position(2).limit(3); + char[] guarded = new char[capacity + 4]; + Arrays.fill(guarded, '^'); + CharBuffer output = CharBuffer.wrap(guarded); + output.position(2).limit(capacity + 2); + assertTrue(encoder.encode(input, output, true).isOverflow()); + assertEquals(2, input.position()); + assertEquals(2, output.position()); + for (char ch : guarded) { + assertEquals('^', ch); + } + output = CharBuffer.allocate(4); + assertTrue(encoder.encode(input, output, true).isUnderflow()); + assertFalse(input.hasRemaining()); + output.flip(); + assertEquals("\\x73", output.toString()); + } + } + } + + public void testChunkedSlicesAndOutputOverflowsMatchFacade() { + for (int mode = 0; mode < ENCODERS.length; mode++) { + String input = "scripture script>/cCatP"; + for (int chunk = 1; chunk <= input.length(); chunk++) { + for (int capacity = 4; capacity <= 7; capacity++) { + assertEquals("chunk=" + chunk + ", capacity=" + capacity, + facade(mode, input), lowLevel(ENCODERS[mode], input, chunk, capacity)); + } + } + input = "s\u2028c\ud83d\ude00i"; + for (int chunk = 1; chunk <= input.length(); chunk++) { + assertEquals(facade(mode, input), lowLevel(ENCODERS[mode], input, chunk, 6)); + } + } + } + + public void testEncodedWriterEverySplitMatchesFacade() throws Exception { + for (int mode = 0; mode < ENCODERS.length; mode++) { + for (String input : new String[]{"", "s", "script", "scripture", + "http://google.com", "", "s\u2028c\ud83d\ude00i"}) { + for (int split = 0; split <= input.length(); split++) { + assertWriterSplit(mode, input, split); + } + } + String large = "!" + repeat('a', 1022) + "scripture" + repeat('!', 1024) + "t"; + for (int split : new int[]{0, 1, 1023, 1024, 1025, 1032, + large.length() - 1, large.length()}) { + assertWriterSplit(mode, large, split); + } + StringWriter output = new StringWriter(); + EncodedWriter writer = new EncodedWriter(output, CONTEXTS[mode]); + for (char ch : large.toCharArray()) { + writer.write(ch); + } + writer.close(); + assertEquals(facade(mode, large), output.toString()); + } + } + + public void testFlushMakesTerminalLetterVisibleWithoutEndingTheStream() throws Exception { + for (String context : CONTEXTS) { + StringWriter output = new StringWriter(); + EncodedWriter writer = new EncodedWriter(output, context); + writer.write("cat"); + writer.flush(); + writer.flush(); + writer.write(""); + writer.flush(); + assertEquals("ca\\x74", output.toString()); + writer.write("a"); + writer.flush(); + assertEquals("ca\\x74a", output.toString()); + writer.close(); + assertEquals("ca\\x74a", output.toString()); + + output = new StringWriter(); + writer = new EncodedWriter(output, context); + writer.write("cat"); + writer.flush(); + assertEquals("ca\\x74", output.toString()); + writer.close(); + assertEquals("ca\\x74", output.toString()); + } + } + + public void testFlushEscapesAllScriptLettersAndLeavesOtherModesUnchanged() throws Exception { + for (String context : CONTEXTS) { + for (char ch : "CIPRSTciprst".toCharArray()) { + StringWriter output = new StringWriter(); + EncodedWriter writer = new EncodedWriter(output, context); + writer.write(ch); + writer.flush(); + assertEquals("\\x" + Integer.toHexString(ch), output.toString()); + writer.write('a'); + writer.close(); + assertEquals("\\x" + Integer.toHexString(ch) + "a", output.toString()); + } + } + for (String context : new String[]{Encoders.JAVASCRIPT_ATTRIBUTE, Encoders.JAVASCRIPT_SOURCE}) { + StringWriter output = new StringWriter(); + EncodedWriter writer = new EncodedWriter(output, context); + writer.write("script"); + writer.flush(); + assertEquals("script", output.toString()); + writer.close(); + assertEquals("script", output.toString()); + } + } + + public void testHtmlScriptFlushRepresentsSurrogatesWithoutChangingTheirValue() throws Exception { + for (String context : CONTEXTS) { + for (String pair : new String[]{"\ud800\udc00", "\ud83d\ude00", "\udbff\udfff"}) { + String highEscape = "\\u" + Integer.toHexString(pair.charAt(0)); + String pairEscape = highEscape + "\\u" + Integer.toHexString(pair.charAt(1)); + StringWriter output = new StringWriter(); + EncodedWriter writer = new EncodedWriter(output, context); + writer.write(pair.charAt(0)); + writer.flush(); + writer.flush(); + assertEquals(context, highEscape, output.toString()); + writer.write(pair.charAt(1)); + writer.close(); + assertEquals(context, pairEscape, output.toString()); + + output = new StringWriter(); + writer = new EncodedWriter(output, context); + writer.write(pair.charAt(0)); + writer.write(pair.charAt(1)); + writer.close(); + assertEquals("no explicit flush keeps raw pair: " + context, pair, output.toString()); + } + } + } + + public void testFlushRetainsSurrogateLookaheadInOtherContexts() throws Exception { + for (String context : new String[]{Encoders.JAVASCRIPT_ATTRIBUTE, Encoders.JAVASCRIPT_SOURCE, + Encoders.CDATA, Encoders.XML_COMMENT}) { + StringWriter output = new StringWriter(); + EncodedWriter writer = new EncodedWriter(output, context); + writer.write('\ud83d'); + writer.flush(); + writer.flush(); + assertEquals(context, "", output.toString()); + writer.write('\ude00'); + writer.close(); + assertEquals(context, "\ud83d\ude00", output.toString()); + } + } + + public void testFlushHandlesInsufficientSpaceForTheCompletePendingEscape() throws Exception { + for (String context : CONTEXTS) { + for (String[] unit : new String[][]{{"s", "\\x73"}, {"\ud83d", "\\ud83d"}}) { + for (int available = 0; available < unit[1].length(); available++) { + String prefix = repeat('a', EncodedWriter.BUFFER_SIZE - available); + StringWriter output = new StringWriter(); + EncodedWriter writer = new EncodedWriter(output, context); + writer.write(prefix + unit[0]); + writer.flush(); + assertEquals(prefix + unit[1], output.toString()); + writer.write('a'); + writer.close(); + assertEquals(prefix + unit[1] + "a", output.toString()); + } + } + } + } + + public void testFlushBeforeDirectTrustedSuffixCannotReorderALetterIntoAnEndTag() throws Exception { + String prefix = "
after
"; + Document intended = Jsoup.parse(prefix + "i" + suffix); + assertNull("unencoded intended value has a different tag name", intended.getElementById("pwn")); + Document reordered = Jsoup.parse(prefix + suffix); + assertNotNull("negative control: omitting the pending letter closes script", + reordered.getElementById("pwn")); + for (String context : CONTEXTS) { + StringWriter output = new StringWriter(); + output.write(prefix); + EncodedWriter writer = new EncodedWriter(output, context); + writer.write('i'); + writer.flush(); + output.write(suffix); + writer.close(); + Document parsed = Jsoup.parse(output.toString()); + assertNull(context, parsed.getElementById("pwn")); + assertEquals(context, 1, parsed.select("script").size()); + assertEquals(context, "var value=\"after
"; + Document intended = Jsoup.parse(prefix + "i\ud800" + suffix); + assertNull("surrogate prevents the intended value from matching script", + intended.getElementById("pwn")); + Document reordered = Jsoup.parse(prefix + "i" + suffix); + assertNotNull("negative control: withholding the surrogate closes script", + reordered.getElementById("pwn")); + for (String context : CONTEXTS) { + StringWriter output = new StringWriter(); + output.write(prefix); + EncodedWriter writer = new EncodedWriter(output, context); + writer.write("i\ud800"); + writer.flush(); + output.write(suffix); + writer.close(); + Document parsed = Jsoup.parse(output.toString()); + assertNull(context, parsed.getElementById("pwn")); + assertEquals(context, 1, parsed.select("script").size()); + assertEquals(context, "var value=\"after
'+ + '