diff --git a/index.js b/index.js index e8022a3..b82d679 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,8 @@ See the accompanying LICENSE file for terms. 'use strict'; +var crypto = require('crypto'); + // Generate an internal UID to make the regexp pattern harder to guess. var UID_LENGTH = 16; var UID = generateUID(); @@ -15,6 +17,8 @@ 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 (case-insensitive) for XSS protection +var SCRIPT_CLOSE_REGEXP = /<\/script>/gi; var RESERVED_SYMBOLS = ['*', 'async']; @@ -32,6 +36,21 @@ function escapeUnsafeChars(unsafeChar) { return ESCAPED_CHARS[unsafeChar]; } +// Escape function body for XSS protection while preserving arrow function syntax +function escapeFunctionBody(str) { + // Escape sequences (case-insensitive) - the main XSS risk + // This must be done first before other replacements + str = str.replace(SCRIPT_CLOSE_REGEXP, function(match) { + return '\\u003C\\u002Fscript\\u003E'; + }); + // Also escape and other case variations + str = str.replace(/<\/SCRIPT>/g, '\\u003C\\u002FSCRIPT\\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 = ''; @@ -138,12 +157,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 + if (options && options.unsafe !== true) { + serializedFn = escapeFunctionBody(serializedFn); + } + // pure functions, example: {key: function() {}} if(IS_PURE_FUNCTION.test(serializedFn)) { return serializedFn; @@ -261,6 +286,6 @@ module.exports = function serialize(obj, options) { var fn = functions[valueIndex]; - return serializeFunc(fn); + return serializeFunc(fn, options); }); } diff --git a/test/unit/serialize.js b/test/unit/serialize.js index 62c0eee..0e7c2dd 100644 --- a/test/unit/serialize.js +++ b/test/unit/serialize.js @@ -495,6 +495,47 @@ describe('serialize( obj )', function () { strictEqual(serialize(new URL('x:')), 'new URL("x:\\u003C\\u002Fscript\\u003E")'); strictEqual(eval(serialize(new URL('x:'))).href, 'x:'); }); + + it('should encode unsafe HTML chars in function bodies', function () { + function fn() { return ''; } + var serialized = serialize(fn); + strictEqual(serialized.includes('\\u003C\\u002Fscript\\u003E'), true); + strictEqual(serialized.includes(''), false); + // Verify the function still works after deserialization + var deserialized; eval('deserialized = ' + serialized); + strictEqual(typeof deserialized, 'function'); + strictEqual(deserialized(), ''); + }); + + it('should encode unsafe HTML chars in arrow function bodies', function () { + var fn = () => { return ''; }; + var serialized = serialize(fn); + strictEqual(serialized.includes('\\u003C\\u002Fscript\\u003E'), true); + strictEqual(serialized.includes(''), false); + // Verify the function still works after deserialization + var deserialized; eval('deserialized = ' + serialized); + strictEqual(typeof deserialized, 'function'); + strictEqual(deserialized(), ''); + }); + + it('should encode unsafe HTML chars in enhanced literal object methods', function () { + var obj = { + fn() { return ''; } + }; + var serialized = serialize(obj); + strictEqual(serialized.includes('\\u003C\\u002Fscript\\u003E'), true); + strictEqual(serialized.includes(''), false); + // Verify the function still works after deserialization + var deserialized; eval('deserialized = ' + serialized); + strictEqual(deserialized.fn(), ''); + }); + + it('should not escape function bodies when unsafe option is true', function () { + function fn() { return ''; } + var serialized = serialize(fn, {unsafe: true}); + strictEqual(serialized.includes(''), true); + strictEqual(serialized.includes('\\u003C\\u002Fscript\\u003E'), false); + }); }); describe('options', function () {