diff --git a/README.md b/README.md index e6523ba..42aa4b2 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,13 @@ Serialize JavaScript Serialize JavaScript to a _superset_ of JSON that includes regular expressions, dates and functions. [![npm Version][npm-badge]][npm] -[![Dependency Status][david-badge]][david] ![Test](https://github.com/yahoo/serialize-javascript/workflows/Test/badge.svg) ## Overview The code in this package began its life as an internal module to [express-state][]. To expand its usefulness, it now lives as `serialize-javascript` — an independent package on npm. -You're probably wondering: **What about `JSON.stringify()`!?** We've found that sometimes we need to serialize JavaScript **functions**, **regexps**, **dates**, **sets** or **maps**. A great example is a web app that uses client-side URL routing where the route definitions are regexps that need to be shared from the server to the client. But this module is also great for communicating between node processes. +You're probably wondering: **What about `JSON.stringify()`!?** We've found that sometimes we need to serialize JavaScript **functions**, **regexps**, **dates**, **sets** or **maps**. A great example is a web app that uses client-side URL routing where the route definitions are regexps that need to be shared from the server to the client. The string returned from this package's single export function is literal JavaScript which can be saved to a `.js` file, or be embedded into an HTML document by making the content of a ` and variations (case-insensitive) for XSS protection +// Matches +var SCRIPT_CLOSE_REGEXP = /<\/script[^>]*>/gi; var RESERVED_SYMBOLS = ['*', 'async']; @@ -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 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(/\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 +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 + if (options && options.unsafe !== true) { + serializedFn = escapeFunctionBody(serializedFn); + } + // pure functions, example: {key: function() {}} if(IS_PURE_FUNCTION.test(serializedFn)) { return serializedFn; @@ -261,6 +285,6 @@ module.exports = function serialize(obj, options) { var fn = functions[valueIndex]; - return serializeFunc(fn); + return serializeFunc(fn, options); }); } diff --git a/package-lock.json b/package-lock.json index 9027c5f..bda5896 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "serialize-javascript", - "version": "7.0.0", + "version": "7.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "serialize-javascript", - "version": "7.0.0", + "version": "7.0.1", "license": "BSD-3-Clause", "devDependencies": { "benchmark": "^2.1.4" diff --git a/package.json b/package.json index fa8a9c7..2a2437c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "serialize-javascript", - "version": "7.0.0", + "version": "7.0.1", "description": "Serialize JavaScript to a superset of JSON that includes regular expressions and functions.", "main": "index.js", "scripts": { diff --git a/test/unit/serialize.js b/test/unit/serialize.js index 62c0eee..f850d6e 100644 --- a/test/unit/serialize.js +++ b/test/unit/serialize.js @@ -495,6 +495,80 @@ 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); + }); + + it('should encode with space before >', function () { + function fn() { return ''; } + var serialized = serialize(fn); + strictEqual(serialized.includes('\\u003C\\u002Fscript'), true); + strictEqual(serialized.includes(''); + }); + + it('should encode with attributes', function () { + function fn() { return ''; } + var serialized = serialize(fn); + strictEqual(serialized.includes('\\u003C\\u002Fscript'), true); + strictEqual(serialized.includes(''); + }); + + it('should encode ', function () { + function fn() { return ''; } + var serialized = serialize(fn); + strictEqual(serialized.includes('\\u003C\\u002Fscript'), true); + strictEqual(serialized.includes(''); + }); }); describe('options', function () {