Skip to content

fix: fix XSS bypass via split </script payload across function bodies - #226

Open
okuryu wants to merge 6 commits into
mainfrom
okuryu-investigate-issue-220-xss
Open

fix: fix XSS bypass via split </script payload across function bodies#226
okuryu wants to merge 6 commits into
mainfrom
okuryu-investigate-issue-220-xss

Conversation

@okuryu

@okuryu okuryu commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes a variant of the XSS vulnerability reported in #220 that remained after the earlier fix (738a8e9).

Problem

escapeFunctionBody() only escaped a complete </script...> tag when it appeared within a single serialized value. So the tag could be split across two values to bypass it:

serialize({
  a: function(){ /* </script */ },                          // no closing '>'
  b: function(){ /* > <img src=x onerror=alert(1)> */ }     // supplies '>' and markup
});

Per the WHATWG HTML tokenizer's script data end tag name state, an HTML parser only needs </script followed by TAB/LF/FF/CR/SPACE///> to start parsing an end tag — it doesn't need the > in the same value.

Fix

  • SCRIPT_CLOSE_REGEXP now also matches a bare </script prefix followed by one of those delimiter characters, so it's escaped even without a closing > in the same value.
  • Escaping is lexically aware: the function source is first scanned for string/template/regex literals and comments. Inside those, characters are unicode-escaped as before. In plain code, <// may be real tokens (comparison, regex delimiter, division), so unicode-escaping them would break syntax — a space is inserted instead, which is a no-op for JS but still breaks up </script.
  • Known limitation: a template literal is treated as one opaque span including any ${...} inside it, so a </script-like sequence in a substitution (real code) can still be misclassified. Accepted trade-off to keep the scan simple.
  • Span lookup uses a single forward cursor (O(n)) rather than a per-match scan, since matches and spans are both in increasing offset order.

The existing escapeFunctionBody() only escaped a complete `</script...>`
tag within a single serialized function body. An attacker could split
the payload across two separately-serialized function values so that
one body supplies `</script` without a trailing `>` and another body
supplies the `>` plus injected markup, bypassing the escaping entirely
when concatenated into the final output.

Per the WHATWG HTML tokenizer's "script data end tag name state", a
bare `</script` followed by TAB, LF, FF, CR, SPACE, `/`, or `>` is
enough to commit to end-tag parsing, so escaping only needs to trigger
on that prefix rather than requiring a matching closing `>` in the same
value. Add SCRIPT_CLOSE_PREFIX_REGEXP to detect and escape this prefix
independently of the existing full-tag regex, preserving arrow function
syntax and existing unsafe: true behavior.

Add a regression test for the split-payload case. Verified against a
real HTML5-spec-compliant parser (parse5) that the fix blocks the
split payload and all real WHATWG delimiter variants.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@okuryu
okuryu marked this pull request as ready for review August 26, 2026 12:00
Copilot AI balanced review requested due to automatic review settings August 26, 2026 12:00

This comment was marked as outdated.

Per Copilot review feedback: HTML tokenization happens before JS escape
sequence processing, so a literal backslash is not itself a WHATWG
end-tag delimiter. Including it caused an observable behavior change
for tagged template literals (e.g. String.raw), altering their raw
string content unnecessarily. Only the real WHATWG delimiters (TAB,
LF, FF, CR, SPACE, /, >) are needed to close the split-payload gap.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

This comment was marked as outdated.

The previous fix for the split </script> XSS bypass unconditionally
unicode-escaped `<` and `/` wherever a </script-like sequence was found
in a serialized function body. That broke valid JavaScript where those
characters are real tokens rather than HTML-adjacent text, e.g.:

  function fn(x) { return x</script/.test('script'); }

which is `x < /script/.test('script')` (a comparison against a regex
literal match). Unicode-escaping `<` and `/` there produced a
SyntaxError on eval, since \uXXXX escapes are only valid inside string/
template literals, not as bare operator substitutes.

Fix: before escaping, scan the function source with a lightweight regex
that locates string literals, template literals, and comments. A
</script-like match found inside one of those spans is still
character-preserving unicode-escaped (required there, since the exact
characters matter for round-tripping, or for comments, don't matter but
must remain syntactically inert). A match found outside those spans
(plain code: operators, regex literals, identifiers) is instead
separated by inserting a single space between `<` and `/`, which is a
no-op for JavaScript semantics but still breaks up the literal
`</script` run that the HTML tokenizer looks for.

Also merges the two script-close regexes (complete-tag and bare-prefix)
into one so escaping happens in a single replace() pass, keeping match
offsets valid against the span list computed up front.

Known limitation: a template literal is treated as one opaque span
including any ${...} substitutions, so a </script-like sequence
appearing directly inside a substitution (actual code) is still
misclassified as string content. This is called out in a code comment
as an accepted trade-off for keeping the scan simple.

Adds a regression test that evaluates the serialized function for the
reported case to confirm it no longer throws and behaves identically
to the original function.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

This comment was marked as outdated.

Address two Copilot review comments on PR #226:

- Replace the O(n*m) `.some()` linear scan over string/comment spans
  (run once per script-close match) with a forward-moving cursor.
  Both matches and spans are processed in increasing source-offset
  order, so a single cursor is enough to classify every match in O(n)
  total instead of re-scanning all spans for every match.

- Add a regex-literal alternative to STRING_OR_COMMENT_REGEXP. Without
  it, a quote character inside a genuine regex literal (e.g. `/'/`)
  could be mistaken for the start of a string, misaligning the span
  for a real subsequent string literal and causing its content to be
  incorrectly space-inserted instead of unicode-escaped -- silently
  changing the serialized value (e.g. `</script ` became `< /script `
  for `/'/.test(x) ? '</script ' : 'ok'`).

Add regression test for the regex-literal quote case; 92/92 tests
pass.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@okuryu
okuryu requested a lite review from Copilot August 26, 2026 12:47
@okuryu okuryu changed the title Fix XSS bypass via </script split across function bodies (#220) Fix XSS bypass via split </script payload across function bodies (#220) Aug 26, 2026

This comment was marked as outdated.

…REGEXP

An unescaped `/` inside a regex character class (e.g. `/[/']/`) is valid
JS and doesn't terminate the literal, but the previous regex-literal
alternative didn't know that and would end its match early at that `/`.
This could misalign the span of the string literal that follows,
leaving its `</script` payload unescaped as plain code (e.g.
`/[/']/.test(x) ? '</script ' : 'ok'` silently returned `< /script `
instead of `</script `).

Add a `\[(?:\\.|[^\]\\\n])*\]` alternative inside the regex-literal
pattern so bracket classes (including escaped characters/brackets) are
consumed as a unit, and any `/` inside one no longer closes the match
early.

Add regression test; 93/93 tests pass.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

index.js:64

  • The template-literal branch in STRING_OR_COMMENT_REGEXP treats an entire template (backtick-to-backtick) as a single opaque span. As the comment notes, this can misclassify </script sequences that occur inside ${ ... } substitutions (which are code) as “string content”, causing unicode-escaping of <// in plain code and potentially producing a SyntaxError for otherwise-valid functions. If this is an accepted limitation, it may still be worth narrowing the template handling so ${...} regions are scanned as code (e.g., split a template literal into raw-text spans and substitution spans with a small brace-depth scanner) to avoid introducing new serialization breakages.

This issue also appears on line 81 of the same file.

// - A whole template literal (backtick to backtick) is treated as one
//   opaque span, including any `${...}` substitutions inside it. Known
//   limitation: if a `</script` sequence appears *inside* such a
//   substitution (which is actual code, e.g. `` `${ x</script/.test(x) }` ``),
//   it will be misidentified as string/template content and

index.js:81

  • STRING_OR_COMMENT_REGEXP's line-comment branch (\/\/[^ ]*) doesn't stop at all JavaScript line terminators. In JS, // comments terminate at \r, \n, \u2028, or \u2029; if a function source contains // ... \u2028 (or \u2029), this pattern will incorrectly treat the following code as part of the comment span, which can misclassify later </script matches and lead to incorrect escaping (including potential SyntaxErrors). Consider updating the branch to something like \/\/[^ \n\u2028\u2029]*.
var STRING_OR_COMMENT_REGEXP = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`|\/(?:\\.|\[(?:\\.|[^\]\\\n])*\]|[^\/\\\n])+\//g;

No behavior change; shortens the comments around SCRIPT_CLOSE_REGEXP,
STRING_OR_COMMENT_REGEXP, and escapeFunctionBody for readability.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@okuryu okuryu changed the title Fix XSS bypass via split </script payload across function bodies (#220) fix: fix XSS bypass via split </script payload across function bodies Aug 26, 2026
@okuryu
okuryu requested a review from redonkulus August 26, 2026 13:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants