Skip to content
Open
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
71 changes: 59 additions & 12 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,16 @@ 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 </script> and variations (case-insensitive) for XSS protection
// Matches </script followed by optional whitespace/attributes and >
var SCRIPT_CLOSE_REGEXP = /<\/script[^>]*>/gi;
// Matches a script end tag (case-insensitive) for XSS protection: either a
// full `</script...>` tag, or a bare `</script` followed by one of the
// characters (TAB, LF, FF, CR, SPACE, `/`, `>`) 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'];

Expand All @@ -35,16 +42,57 @@ function escapeUnsafeChars(unsafeChar) {
return ESCAPED_CHARS[unsafeChar];
}

// Escape function body for XSS protection while preserving arrow function syntax
// Roughly matches string literals, template literals, regex literals, and
// comments, so `escapeFunctionBody` can treat their contents differently
// from plain code. This is a heuristic, not a full parser, with two known
// limitations:
// - A template literal is treated as one opaque span, including any
// `${...}` inside it. A `</script`-like sequence inside such a
// substitution (real code) can be misclassified as string content.
// - The regex-literal alternative can't tell a regex from division (e.g.
// `a / b / c`); a division expression that matches it is simply treated
// as opaque, which only risks an unnecessary (but valid) unicode-escape.
// It exists mainly so quotes and slashes inside a real regex (including
// inside a `[...]` character class) aren't mistaken for string/regex
// delimiters, which would misalign whatever 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
// tags and line terminators are escaped.
function escapeFunctionBody(str) {
// Escape </script> sequences and variations (case-insensitive) - the main XSS risk
// Matches </script followed by optional whitespace/attributes and >
// 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, '\\u003C').replace(/\//g, '\\u002F').replace(/>/g, '\\u003E');
// 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;
while ((match = STRING_OR_COMMENT_REGEXP.exec(str))) {
stringAndCommentSpans.push([match.index, match.index + match[0].length]);
}

// 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) {
while (spanCursor < stringAndCommentSpans.length && stringAndCommentSpans[spanCursor][1] <= offset) {
spanCursor++;
}
var span = stringAndCommentSpans[spanCursor];
var inStringOrComment = !!span && offset >= span[0] && offset < span[1];
if (!inStringOrComment) {
// In plain code, `<` and `/` may be real tokens (comparison,
// regex delimiter, division, ...), so they can't be rewritten
// as unicode escapes without breaking syntax. A space is a
// no-op here but still breaks up the `</script` sequence.
return '< ' + scriptCloseMatch.slice(1);
}
// Inside a string/template/regex/comment, characters must be
// preserved exactly, so unicode-escape instead.
return scriptCloseMatch.replace(/</g, '\\u003C').replace(/\//g, '\\u002F').replace(/>/g, '\\u003E');
});
// Escape line terminators (these are always unsafe)
str = str.replace(/\u2028/g, '\\u2028');
str = str.replace(/\u2029/g, '\\u2029');
return str;
Expand Down Expand Up @@ -163,7 +211,6 @@ module.exports = function serialize(obj, options) {
}

// Escape unsafe HTML characters in function body for XSS protection
// This must preserve arrow function syntax (=>) while escaping </script>
if (options && options.unsafe !== true) {
serializedFn = escapeFunctionBody(serializedFn);
}
Expand Down
60 changes: 60 additions & 0 deletions test/unit/serialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,66 @@ describe('serialize( obj )', function () {
strictEqual(typeof deserialized, 'function');
strictEqual(deserialized(), '</script\t>');
});

it('should encode split script-closing payload across function bodies', function () {
var serialized = serialize({
a: function () { /* </script */ },
b: function () { /* > <img src=x onerror=alert(1)> */ }
});

strictEqual(serialized.includes('</script'), false);
strictEqual(serialized.includes('\\u003C\\u002Fscript'), true);

var deserialized; eval('deserialized = ' + serialized);
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 () {
// `</script` here is not an HTML closing tag: it's the token
// sequence `<` (less-than) followed by the regex literal
// `/script/`. Naively unicode-escaping `<` and `/` in this
// context produces invalid JavaScript syntax.
function fn(x) { return x</script/.test(x); }
var serialized = serialize(fn);

var deserialized;
eval('deserialized = ' + serialized); // must not throw a SyntaxError
strictEqual(typeof deserialized, 'function');
// Behavior must be identical to the original 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 `</script ` payload is left unescaped as plain code.
function fn(x) { return /'/.test(x) ? '</script ' : 'ok'; }
var serialized = serialize(fn);

strictEqual(/<\/script[\t\n\f\r \/>]/i.test(serialized), false);

var deserialized; eval('deserialized = ' + serialized);
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 `</script ` payload left unescaped.
function fn(x) { return /[/']/.test(x) ? '</script ' : 'ok'; }
var serialized = serialize(fn);

strictEqual(/<\/script[\t\n\f\r \/>]/i.test(serialized), false);

var deserialized; eval('deserialized = ' + serialized);
strictEqual(deserialized("'"), fn("'"));
strictEqual(deserialized('/'), fn('/'));
strictEqual(deserialized('x'), fn('x'));
});
});

describe('options', function () {
Expand Down