Skip to content
Merged
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
28 changes: 26 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ 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;

var RESERVED_SYMBOLS = ['*', 'async'];

Expand All @@ -32,6 +35,21 @@ function escapeUnsafeChars(unsafeChar) {
return ESCAPED_CHARS[unsafeChar];
}

// Escape function body for XSS protection while preserving arrow function syntax
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');
});
// Escape line terminators (these are always unsafe)
str = str.replace(/\u2028/g, '\\u2028');
str = str.replace(/\u2029/g, '\\u2029');
return str;
}

function generateUID() {
var bytes = crypto.getRandomValues(new Uint8Array(UID_LENGTH));
var result = '';
Expand Down Expand Up @@ -138,12 +156,18 @@ module.exports = function serialize(obj, options) {
return value;
}

function serializeFunc(fn) {
function serializeFunc(fn, options) {
var serializedFn = fn.toString();
if (IS_NATIVE_CODE_REGEXP.test(serializedFn)) {
throw new TypeError('Serializing native function: ' + fn.name);
}

// 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);
}

// pure functions, example: {key: function() {}}
if(IS_PURE_FUNCTION.test(serializedFn)) {
return serializedFn;
Expand Down Expand Up @@ -261,6 +285,6 @@ module.exports = function serialize(obj, options) {

var fn = functions[valueIndex];

return serializeFunc(fn);
return serializeFunc(fn, options);
});
}
74 changes: 74 additions & 0 deletions test/unit/serialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,80 @@ describe('serialize( obj )', function () {
strictEqual(serialize(new URL('x:</script>')), 'new URL("x:\\u003C\\u002Fscript\\u003E")');
strictEqual(eval(serialize(new URL('x:</script>'))).href, 'x:</script>');
});

it('should encode unsafe HTML chars in function bodies', function () {
function fn() { return '</script>'; }
var serialized = serialize(fn);
strictEqual(serialized.includes('\\u003C\\u002Fscript\\u003E'), true);
strictEqual(serialized.includes('</script>'), false);
// Verify the function still works after deserialization
var deserialized; eval('deserialized = ' + serialized);
strictEqual(typeof deserialized, 'function');
strictEqual(deserialized(), '</script>');
});

it('should encode unsafe HTML chars in arrow function bodies', function () {
var fn = () => { return '</script>'; };
var serialized = serialize(fn);
strictEqual(serialized.includes('\\u003C\\u002Fscript\\u003E'), true);
strictEqual(serialized.includes('</script>'), false);
// Verify the function still works after deserialization
var deserialized; eval('deserialized = ' + serialized);
strictEqual(typeof deserialized, 'function');
strictEqual(deserialized(), '</script>');
});

it('should encode unsafe HTML chars in enhanced literal object methods', function () {
var obj = {
fn() { return '</script>'; }
};
var serialized = serialize(obj);
strictEqual(serialized.includes('\\u003C\\u002Fscript\\u003E'), true);
strictEqual(serialized.includes('</script>'), false);
// Verify the function still works after deserialization
var deserialized; eval('deserialized = ' + serialized);
strictEqual(deserialized.fn(), '</script>');
});

it('should not escape function bodies when unsafe option is true', function () {
function fn() { return '</script>'; }
var serialized = serialize(fn, {unsafe: true});
strictEqual(serialized.includes('</script>'), true);
strictEqual(serialized.includes('\\u003C\\u002Fscript\\u003E'), false);
});

it('should encode </script > with space before >', function () {
function fn() { return '</script >'; }
var serialized = serialize(fn);
strictEqual(serialized.includes('\\u003C\\u002Fscript'), true);
strictEqual(serialized.includes('</script '), false);
// Verify the function still works after deserialization
var deserialized; eval('deserialized = ' + serialized);
strictEqual(typeof deserialized, 'function');
strictEqual(deserialized(), '</script >');
});

it('should encode </script foo> with attributes', function () {
function fn() { return '</script foo>'; }
var serialized = serialize(fn);
strictEqual(serialized.includes('\\u003C\\u002Fscript'), true);
strictEqual(serialized.includes('</script '), false);
// Verify the function still works after deserialization
var deserialized; eval('deserialized = ' + serialized);
strictEqual(typeof deserialized, 'function');
strictEqual(deserialized(), '</script foo>');
});

it('should encode </script with tab before >', function () {
function fn() { return '</script\t>'; }
var serialized = serialize(fn);
strictEqual(serialized.includes('\\u003C\\u002Fscript'), true);
strictEqual(serialized.includes('</script'), false);
// Verify the function still works after deserialization
var deserialized; eval('deserialized = ' + serialized);
strictEqual(typeof deserialized, 'function');
strictEqual(deserialized(), '</script\t>');
});
});

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