From c731e6ea9f40b6e49b39ef5c790993898f831578 Mon Sep 17 00:00:00 2001 From: Ryuichi Okumura Date: Wed, 26 Aug 2026 20:57:13 +0900 Subject: [PATCH 1/6] Fix XSS bypass via ` tag within a single serialized function body. An attacker could split the payload across two separately-serialized function values so that one body supplies `` 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 `` 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> --- index.js | 31 ++++++++++++++++++++++--------- test/unit/serialize.js | 14 ++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/index.js b/index.js index f3db681..2fa89bb 100644 --- a/index.js +++ b/index.js @@ -15,9 +15,23 @@ var IS_NATIVE_CODE_REGEXP = /\{\s*\[native code\]\s*\}/g; var IS_PURE_FUNCTION = /function.*?\(/; var IS_ARROW_FUNCTION = /.*?=>.*?/; var UNSAFE_CHARS_REGEXP = /[<>\/\u2028\u2029]/g; -// Regex to match and variations (case-insensitive) for XSS protection -// Matches +// Regexes to match script end tags (case-insensitive) for XSS protection. +// The first matches a complete `` tag within a single value. +// The second matches a bare ``) that the WHATWG HTML +// tokenizer's "script data end tag name state" treats as ending the tag +// name and starting tag recognition (see +// https://html.spec.whatwg.org/multipage/parsing.html#script-data-end-tag-name-state). +// Because that state only needs to see `` can be supplied by a +// *different* serialized value later in the output; escaping the prefix on +// its own closes that gap. A trailing backslash is also treated as a +// boundary so that a literal `\t`/`\n`/etc. escape sequence emitted by +// `Function.prototype.toString()` (backslash followed by a letter, not an +// actual control character) is escaped too, even though a lone backslash is +// not itself a WHATWG delimiter. var SCRIPT_CLOSE_REGEXP = /<\/script[^>]*>/gi; +var SCRIPT_CLOSE_PREFIX_REGEXP = /<\/script(?=[\t\n\f\r \/>\\])/gi; var RESERVED_SYMBOLS = ['*', 'async']; @@ -35,16 +49,16 @@ function escapeUnsafeChars(unsafeChar) { return ESCAPED_CHARS[unsafeChar]; } -// Escape function body for XSS protection while preserving arrow function syntax +// Escape function body for XSS protection while preserving arrow function +// syntax (=>) and comparison operators: only script end tags and line +// terminators are escaped. function escapeFunctionBody(str) { - // Escape sequences and variations (case-insensitive) - the main XSS risk - // Matches - // This must be done first before other replacements str = str.replace(SCRIPT_CLOSE_REGEXP, function(match) { - // Escape all <, /, and > characters in the closing script tag return match.replace(//g, '\\u003E'); }); - // Escape line terminators (these are always unsafe) + str = str.replace(SCRIPT_CLOSE_PREFIX_REGEXP, function(match) { + return match.replace(/) while escaping if (options && options.unsafe !== true) { serializedFn = escapeFunctionBody(serializedFn); } diff --git a/test/unit/serialize.js b/test/unit/serialize.js index 2fc5709..cc9ddbd 100644 --- a/test/unit/serialize.js +++ b/test/unit/serialize.js @@ -666,6 +666,20 @@ describe('serialize( obj )', function () { strictEqual(typeof deserialized, 'function'); strictEqual(deserialized(), ''); }); + + it('should encode split script-closing payload across function bodies', function () { + var serialized = serialize({ + a: function () { /* */ } + }); + + strictEqual(serialized.includes(' Date: Wed, 26 Aug 2026 21:05:18 +0900 Subject: [PATCH 2/6] Remove backslash from script-close delimiter check 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> --- index.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/index.js b/index.js index 2fa89bb..5741369 100644 --- a/index.js +++ b/index.js @@ -25,13 +25,13 @@ var UNSAFE_CHARS_REGEXP = /[<>\/\u2028\u2029]/g; // Because that state only needs to see `` can be supplied by a // *different* serialized value later in the output; escaping the prefix on -// its own closes that gap. A trailing backslash is also treated as a -// boundary so that a literal `\t`/`\n`/etc. escape sequence emitted by -// `Function.prototype.toString()` (backslash followed by a letter, not an -// actual control character) is escaped too, even though a lone backslash is -// not itself a WHATWG delimiter. +// its own closes that gap. A trailing backslash is intentionally NOT +// included: HTML tokenization happens before any JavaScript escape-sequence +// processing, so a literal backslash character is not itself a delimiter +// recognized by the tokenizer, and treating it as one would incorrectly +// alter the raw text of tagged template literals (e.g. `String.raw`). var SCRIPT_CLOSE_REGEXP = /<\/script[^>]*>/gi; -var SCRIPT_CLOSE_PREFIX_REGEXP = /<\/script(?=[\t\n\f\r \/>\\])/gi; +var SCRIPT_CLOSE_PREFIX_REGEXP = /<\/script(?=[\t\n\f\r \/>])/gi; var RESERVED_SYMBOLS = ['*', 'async']; From 0122bafe526b15ae592d585b15432eff6899ece3 Mon Sep 17 00:00:00 2001 From: Ryuichi Okumura Date: Wed, 26 Aug 2026 21:34:55 +0900 Subject: [PATCH 3/6] Make script-close escaping lexically aware to avoid breaking valid JS The previous fix for the split XSS bypass unconditionally unicode-escaped `<` and `/` wherever a --- index.js | 89 ++++++++++++++++++++++++++++++------------ test/unit/serialize.js | 16 ++++++++ 2 files changed, 81 insertions(+), 24 deletions(-) diff --git a/index.js b/index.js index 5741369..45bcc04 100644 --- a/index.js +++ b/index.js @@ -15,23 +15,28 @@ var IS_NATIVE_CODE_REGEXP = /\{\s*\[native code\]\s*\}/g; var IS_PURE_FUNCTION = /function.*?\(/; var IS_ARROW_FUNCTION = /.*?=>.*?/; var UNSAFE_CHARS_REGEXP = /[<>\/\u2028\u2029]/g; -// Regexes to match script end tags (case-insensitive) for XSS protection. -// The first matches a complete `` tag within a single value. -// The second matches a bare ``) that the WHATWG HTML -// tokenizer's "script data end tag name state" treats as ending the tag -// name and starting tag recognition (see -// https://html.spec.whatwg.org/multipage/parsing.html#script-data-end-tag-name-state). -// Because that state only needs to see `` can be supplied by a -// *different* serialized value later in the output; escaping the prefix on -// its own closes that gap. A trailing backslash is intentionally NOT -// included: HTML tokenization happens before any JavaScript escape-sequence -// processing, so a literal backslash character is not itself a delimiter -// recognized by the tokenizer, and treating it as one would incorrectly -// alter the raw text of tagged template literals (e.g. `String.raw`). -var SCRIPT_CLOSE_REGEXP = /<\/script[^>]*>/gi; -var SCRIPT_CLOSE_PREFIX_REGEXP = /<\/script(?=[\t\n\f\r \/>])/gi; +// Matches a script end tag (case-insensitive) for XSS protection, in either +// of two forms: +// 1. `<\/script[^>]*>` - a complete `` tag within a single +// value, escaped in full (including the closing `>`). +// 2. `<\/script(?=[\t\n\f\r \/>])` - a bare ``) that the +// WHATWG HTML tokenizer's "script data end tag name state" treats as +// ending the tag name and starting end-tag recognition (see +// https://html.spec.whatwg.org/multipage/parsing.html#script-data-end-tag-name-state). +// Because that state only needs to see `` can +// be supplied by a *different* serialized value later in the output, so +// the closing tag itself doesn't need to be present in the same match; +// escaping the prefix on its own closes that gap. A trailing backslash +// is intentionally NOT included: HTML tokenization happens before any +// JavaScript escape-sequence processing, so a literal backslash +// character is not itself a delimiter recognized by the tokenizer, and +// treating it as one would incorrectly alter the raw text of tagged +// template literals (e.g. `String.raw`). +// The first alternative is tried first so a fully-formed tag (the common +// case) is escaped as one unit, including its closing `>`. +var SCRIPT_CLOSE_REGEXP = /<\/script[^>]*>|<\/script(?=[\t\n\f\r \/>])/gi; var RESERVED_SYMBOLS = ['*', 'async']; @@ -49,15 +54,51 @@ function escapeUnsafeChars(unsafeChar) { return ESCAPED_CHARS[unsafeChar]; } +// Matches string literals, template literals, and comments so that +// `escapeFunctionBody` can tell them apart from plain code (see below). +// This is a lightweight heuristic, not a full parser: a whole template +// literal (backtick to backtick) is treated as one opaque span, including +// any `${...}` substitutions inside it. Known limitation: if a `) and comparison operators: only script end tags and line -// terminators are escaped. +// syntax (=>), comparison operators, and regex literals: only script end +// tags and line terminators are escaped. function escapeFunctionBody(str) { - str = str.replace(SCRIPT_CLOSE_REGEXP, function(match) { - return match.replace(//g, '\\u003E'); - }); - str = str.replace(SCRIPT_CLOSE_PREFIX_REGEXP, function(match) { - return match.replace(/= span[0] && offset < span[1]; + }); + if (!inStringOrComment) { + // Outside of strings/templates/comments, `<` and `/` are real + // JavaScript tokens (a comparison operator, a regex literal + // delimiter, division, etc.) and can't be rewritten as unicode + // escapes without producing invalid syntax. Inserting + // whitespace between them is a no-op for JavaScript semantics + // (tokens are whitespace-insensitive here) while still breaking + // up the literal `/g, '\\u003E'); }); str = str.replace(/\u2028/g, '\\u2028'); str = str.replace(/\u2029/g, '\\u2029'); diff --git a/test/unit/serialize.js b/test/unit/serialize.js index cc9ddbd..59e60d6 100644 --- a/test/unit/serialize.js +++ b/test/unit/serialize.js @@ -680,6 +680,22 @@ describe('serialize( obj )', function () { strictEqual(typeof deserialized.a, 'function'); strictEqual(typeof deserialized.b, 'function'); }); + + it('should not corrupt `<` used as a comparison operator followed by a regex literal', function () { + // ` Date: Wed, 26 Aug 2026 21:46:38 +0900 Subject: [PATCH 4/6] Fix quadratic scan and regex-literal quote misclassification 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. ` --- index.js | 50 ++++++++++++++++++++++++++++-------------- test/unit/serialize.js | 14 ++++++++++++ 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/index.js b/index.js index 45bcc04..09ac2ba 100644 --- a/index.js +++ b/index.js @@ -54,25 +54,35 @@ function escapeUnsafeChars(unsafeChar) { return ESCAPED_CHARS[unsafeChar]; } -// Matches string literals, template literals, and comments so that -// `escapeFunctionBody` can tell them apart from plain code (see below). -// This is a lightweight heuristic, not a full parser: a whole template -// literal (backtick to backtick) is treated as one opaque span, including -// any `${...}` substitutions inside it. Known limitation: if a `), comparison operators, and regex literals: only script end // tags and line terminators are escaped. function escapeFunctionBody(str) { // Record the [start, end) span of every string literal, template - // literal, and comment so matches inside them can be treated - // differently from matches in plain code (see below). + // literal, regex literal, and comment so matches inside them can be + // treated differently from matches in plain code (see below). var stringAndCommentSpans = []; var match; STRING_OR_COMMENT_REGEXP.lastIndex = 0; @@ -80,10 +90,18 @@ function escapeFunctionBody(str) { stringAndCommentSpans.push([match.index, match.index + match[0].length]); } + // Both the script-close matches (found below, in source order via + // `replace`) and `stringAndCommentSpans` are ordered by offset, so a + // single forward-moving cursor is enough to classify every match in + // O(n) total instead of re-scanning every span for every match. + var spanCursor = 0; + str = str.replace(SCRIPT_CLOSE_REGEXP, function(scriptCloseMatch, offset) { - var inStringOrComment = stringAndCommentSpans.some(function(span) { - return offset >= span[0] && offset < span[1]; - }); + while (spanCursor < stringAndCommentSpans.length && stringAndCommentSpans[spanCursor][1] <= offset) { + spanCursor++; + } + var span = stringAndCommentSpans[spanCursor]; + var inStringOrComment = !!span && offset >= span[0] && offset < span[1]; if (!inStringOrComment) { // Outside of strings/templates/comments, `<` and `/` are real // JavaScript tokens (a comparison operator, a regex literal diff --git a/test/unit/serialize.js b/test/unit/serialize.js index 59e60d6..22442bf 100644 --- a/test/unit/serialize.js +++ b/test/unit/serialize.js @@ -696,6 +696,20 @@ describe('serialize( obj )', function () { strictEqual(deserialized('script'), fn('script')); strictEqual(deserialized('other'), fn('other')); }); + + it('should not let a quote inside a regex literal misalign a later string literal', function () { + // The quote in `/'/` must not be mistaken for the start of a + // string; otherwise the real string below is misidentified and + // its `]/i.test(serialized), false); + + var deserialized; eval('deserialized = ' + serialized); + strictEqual(deserialized("'"), fn("'")); + strictEqual(deserialized('x'), fn('x')); + }); }); describe('options', function () { From 72d42f1483b12cbf607290363f73dcf4a8cc154b Mon Sep 17 00:00:00 2001 From: Ryuichi Okumura Date: Wed, 26 Aug 2026 21:56:23 +0900 Subject: [PATCH 5/6] Treat regex bracket character classes as opaque in STRING_OR_COMMENT_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 ` --- index.js | 8 ++++++-- test/unit/serialize.js | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 09ac2ba..8ca0208 100644 --- a/index.js +++ b/index.js @@ -73,8 +73,12 @@ function escapeUnsafeChars(unsafeChar) { // subsequent string match; a division expression that happens to match // this pattern is merely treated as opaque, which only risks an // unnecessary (but still valid) unicode-escape rather than a -// miscalculated string boundary. -var STRING_OR_COMMENT_REGEXP = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`|\/(?:\\.|[^\/\\\n])+\//g; +// miscalculated string boundary. Bracket character classes (`[...]`) are +// matched as a unit so that an unescaped `/` inside one (valid and +// unremarkable in a regex literal, e.g. `/[/']/`) isn't mistaken for the +// literal's closing delimiter, which would otherwise end the match early +// and misalign whatever string/regex follows. +var STRING_OR_COMMENT_REGEXP = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`|\/(?:\\.|\[(?:\\.|[^\]\\\n])*\]|[^\/\\\n])+\//g; // Escape function body for XSS protection while preserving arrow function // syntax (=>), comparison operators, and regex literals: only script end diff --git a/test/unit/serialize.js b/test/unit/serialize.js index 22442bf..978328f 100644 --- a/test/unit/serialize.js +++ b/test/unit/serialize.js @@ -710,6 +710,22 @@ describe('serialize( obj )', function () { strictEqual(deserialized("'"), fn("'")); strictEqual(deserialized('x'), fn('x')); }); + + it('should not let a `/` inside a regex character class end the regex literal early', function () { + // `/` inside `[...]` doesn't need to be escaped and doesn't + // terminate the regex literal. If it were mistaken for the + // closing delimiter, the real string literal that follows would + // be misidentified and its `]/i.test(serialized), false); + + var deserialized; eval('deserialized = ' + serialized); + strictEqual(deserialized("'"), fn("'")); + strictEqual(deserialized('/'), fn('/')); + strictEqual(deserialized('x'), fn('x')); + }); }); describe('options', function () { From 1f2ee03839d0e5bf5f623dd14c4b8b0df1eb0f90 Mon Sep 17 00:00:00 2001 From: Ryuichi Okumura Date: Wed, 26 Aug 2026 22:09:22 +0900 Subject: [PATCH 6/6] Simplify explanatory comments in index.js 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> --- index.js | 97 ++++++++++++++++++++------------------------------------ 1 file changed, 34 insertions(+), 63 deletions(-) diff --git a/index.js b/index.js index 8ca0208..4d3e212 100644 --- a/index.js +++ b/index.js @@ -15,27 +15,15 @@ var IS_NATIVE_CODE_REGEXP = /\{\s*\[native code\]\s*\}/g; var IS_PURE_FUNCTION = /function.*?\(/; var IS_ARROW_FUNCTION = /.*?=>.*?/; var UNSAFE_CHARS_REGEXP = /[<>\/\u2028\u2029]/g; -// Matches a script end tag (case-insensitive) for XSS protection, in either -// of two forms: -// 1. `<\/script[^>]*>` - a complete `` tag within a single -// value, escaped in full (including the closing `>`). -// 2. `<\/script(?=[\t\n\f\r \/>])` - a bare ``) that the -// WHATWG HTML tokenizer's "script data end tag name state" treats as -// ending the tag name and starting end-tag recognition (see -// https://html.spec.whatwg.org/multipage/parsing.html#script-data-end-tag-name-state). -// Because that state only needs to see `` can -// be supplied by a *different* serialized value later in the output, so -// the closing tag itself doesn't need to be present in the same match; -// escaping the prefix on its own closes that gap. A trailing backslash -// is intentionally NOT included: HTML tokenization happens before any -// JavaScript escape-sequence processing, so a literal backslash -// character is not itself a delimiter recognized by the tokenizer, and -// treating it as one would incorrectly alter the raw text of tagged -// template literals (e.g. `String.raw`). -// The first alternative is tried first so a fully-formed tag (the common -// case) is escaped as one unit, including its closing `>`. +// Matches a script end tag (case-insensitive) for XSS protection: either a +// full `` tag, or a bare ``) that the HTML tokenizer +// treats as ending the tag name (see the WHATWG "script data end tag name +// state"). The bare-prefix form matters because the matching `>` could be +// supplied by a different serialized value later in the output, so escaping +// stops there without waiting for a closing `>`. A trailing backslash is not +// a delimiter here (that's a JS-level concern, not an HTML one), so this +// doesn't affect tagged template literals like `String.raw`. var SCRIPT_CLOSE_REGEXP = /<\/script[^>]*>|<\/script(?=[\t\n\f\r \/>])/gi; var RESERVED_SYMBOLS = ['*', 'async']; @@ -54,39 +42,28 @@ function escapeUnsafeChars(unsafeChar) { return ESCAPED_CHARS[unsafeChar]; } -// Matches string literals, template literals, regex literals, and comments -// so that `escapeFunctionBody` can tell them apart from plain code (see -// below). This is a lightweight heuristic, not a full parser: -// - A whole template literal (backtick to backtick) is treated as one -// opaque span, including any `${...}` substitutions inside it. Known -// limitation: if a `), comparison operators, and regex literals: only script end // tags and line terminators are escaped. function escapeFunctionBody(str) { - // Record the [start, end) span of every string literal, template - // literal, regex literal, and comment so matches inside them can be - // treated differently from matches in plain code (see below). + // Record the [start, end) span of every string/template/regex literal + // and comment, so a script-close match inside one can be escaped + // differently from a match in plain code (see below). var stringAndCommentSpans = []; var match; STRING_OR_COMMENT_REGEXP.lastIndex = 0; @@ -94,10 +71,9 @@ function escapeFunctionBody(str) { stringAndCommentSpans.push([match.index, match.index + match[0].length]); } - // Both the script-close matches (found below, in source order via - // `replace`) and `stringAndCommentSpans` are ordered by offset, so a - // single forward-moving cursor is enough to classify every match in - // O(n) total instead of re-scanning every span for every match. + // Matches and spans are both in increasing offset order, so a single + // forward-moving cursor classifies every match in O(n) total instead of + // rescanning all spans for each match. var spanCursor = 0; str = str.replace(SCRIPT_CLOSE_REGEXP, function(scriptCloseMatch, offset) { @@ -107,19 +83,14 @@ function escapeFunctionBody(str) { var span = stringAndCommentSpans[spanCursor]; var inStringOrComment = !!span && offset >= span[0] && offset < span[1]; if (!inStringOrComment) { - // Outside of strings/templates/comments, `<` and `/` are real - // JavaScript tokens (a comparison operator, a regex literal - // delimiter, division, etc.) and can't be rewritten as unicode - // escapes without producing invalid syntax. Inserting - // whitespace between them is a no-op for JavaScript semantics - // (tokens are whitespace-insensitive here) while still breaking - // up the literal `/g, '\\u003E'); }); str = str.replace(/\u2028/g, '\\u2028');