Skip to content

Commit 738a8e9

Browse files
authored
security: sanitize function bodies (#199)
1 parent af58299 commit 738a8e9

File tree

2 files changed

+100
-2
lines changed

2 files changed

+100
-2
lines changed

index.js

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ var IS_NATIVE_CODE_REGEXP = /\{\s*\[native code\]\s*\}/g;
1515
var IS_PURE_FUNCTION = /function.*?\(/;
1616
var IS_ARROW_FUNCTION = /.*?=>.*?/;
1717
var UNSAFE_CHARS_REGEXP = /[<>\/\u2028\u2029]/g;
18+
// Regex to match </script> and variations (case-insensitive) for XSS protection
19+
// Matches </script followed by optional whitespace/attributes and >
20+
var SCRIPT_CLOSE_REGEXP = /<\/script[^>]*>/gi;
1821

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

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

38+
// Escape function body for XSS protection while preserving arrow function syntax
39+
function escapeFunctionBody(str) {
40+
// Escape </script> sequences and variations (case-insensitive) - the main XSS risk
41+
// Matches </script followed by optional whitespace/attributes and >
42+
// This must be done first before other replacements
43+
str = str.replace(SCRIPT_CLOSE_REGEXP, function(match) {
44+
// Escape all <, /, and > characters in the closing script tag
45+
return match.replace(/</g, '\\u003C').replace(/\//g, '\\u002F').replace(/>/g, '\\u003E');
46+
});
47+
// Escape line terminators (these are always unsafe)
48+
str = str.replace(/\u2028/g, '\\u2028');
49+
str = str.replace(/\u2029/g, '\\u2029');
50+
return str;
51+
}
52+
3553
function generateUID() {
3654
var bytes = crypto.getRandomValues(new Uint8Array(UID_LENGTH));
3755
var result = '';
@@ -138,12 +156,18 @@ module.exports = function serialize(obj, options) {
138156
return value;
139157
}
140158

141-
function serializeFunc(fn) {
159+
function serializeFunc(fn, options) {
142160
var serializedFn = fn.toString();
143161
if (IS_NATIVE_CODE_REGEXP.test(serializedFn)) {
144162
throw new TypeError('Serializing native function: ' + fn.name);
145163
}
146164

165+
// Escape unsafe HTML characters in function body for XSS protection
166+
// This must preserve arrow function syntax (=>) while escaping </script>
167+
if (options && options.unsafe !== true) {
168+
serializedFn = escapeFunctionBody(serializedFn);
169+
}
170+
147171
// pure functions, example: {key: function() {}}
148172
if(IS_PURE_FUNCTION.test(serializedFn)) {
149173
return serializedFn;
@@ -261,6 +285,6 @@ module.exports = function serialize(obj, options) {
261285

262286
var fn = functions[valueIndex];
263287

264-
return serializeFunc(fn);
288+
return serializeFunc(fn, options);
265289
});
266290
}

test/unit/serialize.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,80 @@ describe('serialize( obj )', function () {
495495
strictEqual(serialize(new URL('x:</script>')), 'new URL("x:\\u003C\\u002Fscript\\u003E")');
496496
strictEqual(eval(serialize(new URL('x:</script>'))).href, 'x:</script>');
497497
});
498+
499+
it('should encode unsafe HTML chars in function bodies', function () {
500+
function fn() { return '</script>'; }
501+
var serialized = serialize(fn);
502+
strictEqual(serialized.includes('\\u003C\\u002Fscript\\u003E'), true);
503+
strictEqual(serialized.includes('</script>'), false);
504+
// Verify the function still works after deserialization
505+
var deserialized; eval('deserialized = ' + serialized);
506+
strictEqual(typeof deserialized, 'function');
507+
strictEqual(deserialized(), '</script>');
508+
});
509+
510+
it('should encode unsafe HTML chars in arrow function bodies', function () {
511+
var fn = () => { return '</script>'; };
512+
var serialized = serialize(fn);
513+
strictEqual(serialized.includes('\\u003C\\u002Fscript\\u003E'), true);
514+
strictEqual(serialized.includes('</script>'), false);
515+
// Verify the function still works after deserialization
516+
var deserialized; eval('deserialized = ' + serialized);
517+
strictEqual(typeof deserialized, 'function');
518+
strictEqual(deserialized(), '</script>');
519+
});
520+
521+
it('should encode unsafe HTML chars in enhanced literal object methods', function () {
522+
var obj = {
523+
fn() { return '</script>'; }
524+
};
525+
var serialized = serialize(obj);
526+
strictEqual(serialized.includes('\\u003C\\u002Fscript\\u003E'), true);
527+
strictEqual(serialized.includes('</script>'), false);
528+
// Verify the function still works after deserialization
529+
var deserialized; eval('deserialized = ' + serialized);
530+
strictEqual(deserialized.fn(), '</script>');
531+
});
532+
533+
it('should not escape function bodies when unsafe option is true', function () {
534+
function fn() { return '</script>'; }
535+
var serialized = serialize(fn, {unsafe: true});
536+
strictEqual(serialized.includes('</script>'), true);
537+
strictEqual(serialized.includes('\\u003C\\u002Fscript\\u003E'), false);
538+
});
539+
540+
it('should encode </script > with space before >', function () {
541+
function fn() { return '</script >'; }
542+
var serialized = serialize(fn);
543+
strictEqual(serialized.includes('\\u003C\\u002Fscript'), true);
544+
strictEqual(serialized.includes('</script '), false);
545+
// Verify the function still works after deserialization
546+
var deserialized; eval('deserialized = ' + serialized);
547+
strictEqual(typeof deserialized, 'function');
548+
strictEqual(deserialized(), '</script >');
549+
});
550+
551+
it('should encode </script foo> with attributes', function () {
552+
function fn() { return '</script foo>'; }
553+
var serialized = serialize(fn);
554+
strictEqual(serialized.includes('\\u003C\\u002Fscript'), true);
555+
strictEqual(serialized.includes('</script '), false);
556+
// Verify the function still works after deserialization
557+
var deserialized; eval('deserialized = ' + serialized);
558+
strictEqual(typeof deserialized, 'function');
559+
strictEqual(deserialized(), '</script foo>');
560+
});
561+
562+
it('should encode </script with tab before >', function () {
563+
function fn() { return '</script\t>'; }
564+
var serialized = serialize(fn);
565+
strictEqual(serialized.includes('\\u003C\\u002Fscript'), true);
566+
strictEqual(serialized.includes('</script'), false);
567+
// Verify the function still works after deserialization
568+
var deserialized; eval('deserialized = ' + serialized);
569+
strictEqual(typeof deserialized, 'function');
570+
strictEqual(deserialized(), '</script\t>');
571+
});
498572
});
499573

500574
describe('options', function () {

0 commit comments

Comments
 (0)