fix: fix XSS bypass via split </script payload across function bodies - #226
Open
okuryu wants to merge 6 commits into
Open
fix: fix XSS bypass via split </script payload across function bodies#226okuryu wants to merge 6 commits into
</script payload across function bodies#226okuryu wants to merge 6 commits into
Conversation
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>
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>
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>
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>
</script payload across function bodies (#220)
…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>
There was a problem hiding this comment.
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_REGEXPtreats an entire template (backtick-to-backtick) as a single opaque span. As the comment notes, this can misclassify</scriptsequences that occur inside${ ... }substitutions (which are code) as “string content”, causing unicode-escaping of<//in plain code and potentially producing aSyntaxErrorfor 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</scriptmatches 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>
</script payload across function bodies (#220)</script payload across function bodies
redonkulus
approved these changes
Aug 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:Per the WHATWG HTML tokenizer's script data end tag name state, an HTML parser only needs
</scriptfollowed by TAB/LF/FF/CR/SPACE///>to start parsing an end tag — it doesn't need the>in the same value.Fix
SCRIPT_CLOSE_REGEXPnow also matches a bare</scriptprefix followed by one of those delimiter characters, so it's escaped even without a closing>in the same value.<//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.${...}inside it, so a</script-like sequence in a substitution (real code) can still be misclassified. Accepted trade-off to keep the scan simple.