Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ 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; a final letter
from `script` (either case) is still escaped. 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.

## 1.5.1 — 2026-10-15 UTC

Documentation and build-maintenance patch; publication is verified in the
Expand Down
41 changes: 28 additions & 13 deletions core/src/main/java/org/owasp/encoder/Encode.java
Original file line number Diff line number Diff line change
Expand Up @@ -1127,9 +1127,11 @@ public static void forJava(Writer out, String input)
* before the insertion, with the closing <code>&gt;</code> supplied later.</p>
*
* <p>In HTML script blocks, a nonempty encoded fragment cannot complete the
* raw sequences <code>&lt;/script</code> or <code>&lt;script</code>
* (case-insensitive),
* HTML tokens <code>&lt;/script</code> or <code>&lt;script</code>
* (case-insensitive, followed by an HTML name delimiter),
* <code>&lt;!--</code>, or <code>--&gt;</code> with adjacent trusted text.
* A longer name such as <code>&lt;/scripture</code> is not a script end tag.
* HTML does not remove whitespace between the letters of a tag name.
* For example, trusted <code>&lt;/scr</code>, an encoded <code>i</code>, and
* trusted <code>pt&gt;</code> cannot form an HTML closing script tag.
* Preventing a split <code>&lt;script</code> also avoids entering the HTML
Expand All @@ -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.</p>
*
* <p>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 <code>i</code> becomes <code>\x69</code>, while
* <code>script</code> becomes <code>scrip\x74</code>.
* 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.</p>
*
* <p>For example, <code>forJavaScript("http://google.com")</code> returns
* <code>h\x74\x74\x70:\/\/google.\x63om</code>, which evaluates to the
* <code>http:\/\/google.com</code>, which evaluates to the
* original URL inside a supported JavaScript string literal.
* {@link #forJavaScriptSource(String)} and
* {@link #forJavaScriptAttribute(String)} leave this URL unchanged, but
Expand Down Expand Up @@ -1243,11 +1258,11 @@ public static void forJava(Writer out, String input)
* </tr>
* <tr class="altColor">
* <td class="colFirst" colspan="2">C, I, P, R, S, T and lowercase</td>
* <td class="colLast"><code>\x##</code></td>
* <td class="colLast">Prevents a nonempty encoded fragment from completing
* the case-insensitive sequences <code>&lt;/script</code> or
* <code>&lt;script</code> with
* adjacent trusted text; see the insertion-boundary requirements above.</td>
* <td class="colLast"><code>\x##</code> at the end of input only</td>
* <td class="colLast">Together with the punctuation escapes, prevents
* adjacent trusted text from completing a recognized script token.
* Interior letters pass through unchanged; see the insertion-boundary
* requirements above.</td>
* </tr>
* <tr class="rowColor">
* <td class="colFirst">U+005C</td><td><code>\</code></td>
Expand Down Expand Up @@ -1342,8 +1357,8 @@ public static void forJavaScript(Writer out, String input)
* 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 >}, and both cases of C, I, P, R, S,
* and T are not escaped. Those additional escapes protect an outer HTML
* space, {@code !}, {@code <}, {@code >}, terminal C, I, P, R, S,
* and T (either case) are not escaped. Those additional escapes protect an outer HTML
* script context and do not apply to an event attribute.</p>
* <p>DEL and C1 controls (U+007F to U+009F) are hex-escaped. Unpaired UTF-16
* surrogates use Unicode escapes; valid surrogate pairs remain unescaped.</p>
Expand Down Expand Up @@ -1405,7 +1420,7 @@ public static void forJavaScriptAttribute(Writer out, String input)
*
* <p>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.</p>
*
* <b>Example JSP Usage:</b>
Expand Down Expand Up @@ -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.</p>
* <p>DEL and C1 controls (U+007F to U+009F) are hex-escaped. Unpaired UTF-16
* surrogates use Unicode escapes; valid surrogate pairs remain unescaped.</p>
Expand Down
17 changes: 16 additions & 1 deletion core/src/main/java/org/owasp/encoder/EncodedWriter.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,15 @@
* 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.</p>
* 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 other contexts, incomplete sequences can remain pending; do not write a trusted suffix
* directly to the wrapped writer 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.</p>
*
* @author Jeff Ichnowski
*/
Expand Down Expand Up @@ -234,6 +242,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();
}
Expand Down
24 changes: 21 additions & 3 deletions core/src/main/java/org/owasp/encoder/Encoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@
* OWASP Encoder's project is not currently supported.</p>
*
* <p>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.</p>
*
* <p>To use an Encoder instance directly, repeatedly call {@link
Expand Down Expand Up @@ -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.</p>
* 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.</p>
*
* <p>The output buffer must have enough remaining space for an entire
* encoded sequence. Allow at least 13 characters of output space to support
Expand Down Expand Up @@ -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.
Expand Down
71 changes: 58 additions & 13 deletions core/src/main/java/org/owasp/encoder/JavaScriptEncoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ enum Mode {
* prematurely terminating a script block with a closing
* "&lt;/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.
*/
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -133,23 +139,22 @@ enum Mode {
-1 & ~((1 << '\\')),
~((1 << Unicode.DEL) | (1 << '`') | (1 << '{')),};

if (mode == Mode.BLOCK || mode == Mode.HTML) {
if (_htmlScript) {
// In <script> blocks, prevent the browser from seeing a
// "</script" end tag, "<script" double-escape start, "<!--", or
// "-->". 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 "</script",
// "<script", "<!--", or "-->" 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 "<!--" unless it changes the raw HTML token.
_validMasks[1] &= ~((1 << ' ') | (1 << '!') | (1 << '-')
| (1 << '/') | (1 << '<') | (1 << '>'));
_validMasks[2] &= ~((1 << 'C') | (1 << 'I') | (1 << 'P')
| (1 << 'R') | (1 << 'S') | (1 << 'T'));
_validMasks[3] &= ~((1 << 'c') | (1 << 'i') | (1 << 'p')
| (1 << 'r') | (1 << 's') | (1 << 't'));
}
if (mode != Mode.SOURCE) {
_validMasks[1] &= ~(1 << '&');
Expand All @@ -171,7 +176,8 @@ int firstEncodedOffset(String input, int off, int len) {
for (int i = off; i < n; ++i) {
char ch = input.charAt(i);
if (ch < 128) {
if ((validMasks[ch >>> 5] & (1 << ch)) == 0) {
if ((validMasks[ch >>> 5] & (1 << ch)) == 0
|| (_htmlScript && i + 1 == n && isScriptNameLetter(ch))) {
return i;
}
} else if (_asciiOnly || ch <= Unicode.MAX_C1_CTRL_CHAR
Expand All @@ -189,6 +195,37 @@ int firstEncodedOffset(String input, int off, int len) {
return n;
}

/**
* Whether the ASCII character can continue a case-insensitive script name.
*/
private static boolean isScriptNameLetter(char ch) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it looks chars in isolation, this probably cannot distinguish "script" from "csript".

switch (ch) {
case 'C':
case 'I':
case 'P':
case 'R':
case 'S':
case 'T':
case 'c':
case 'i':
case 'p':
case 'r':
case 's':
case 't':
return true;
default:
return false;
}
}

@Override
boolean canFlushPending(char ch) {
// Hex and Unicode escapes preserve UTF-16 values even if more input
// follows. A later low surrogate is escaped separately, preserving the
// same JavaScript string value as a raw pair.
return _htmlScript && (isScriptNameLetter(ch) || Character.isHighSurrogate(ch));
}

@Override
protected CoderResult encodeArrays(CharBuffer input, CharBuffer output, boolean endOfInput) {
final char[] in = input.array();
Expand All @@ -211,6 +248,14 @@ protected CoderResult encodeArrays(CharBuffer input, CharBuffer output, boolean
if ((validMasks[ch >>> 5] & (1 << ch)) == 0) {
break encoded;
}
if (_htmlScript && i + 1 == n && isScriptNameLetter(ch)) {
if (!endOfInput) {
// The caller retains this letter until it is
// known whether the logical input ends here.
return underflow(input, i, output, j);
}
break encoded;
}
} else {
if (ch <= Unicode.MAX_C1_CTRL_CHAR || (_asciiOnly && ch <= 0xff)) {
break hexEncoded;
Expand Down
Loading
Loading