Skip to content

Commit ae54731

Browse files
committed
security: escape function bodies
1 parent bb0048c commit ae54731

File tree

3 files changed

+71
-3
lines changed

3 files changed

+71
-3
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1+
.nyc_output/
2+
coverage/
13
node_modules

index.js

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ See the accompanying LICENSE file for terms.
66

77
'use strict';
88

9+
var crypto = require('crypto');
10+
911
// Generate an internal UID to make the regexp pattern harder to guess.
1012
var UID_LENGTH = 16;
1113
var UID = generateUID();
@@ -15,6 +17,8 @@ var IS_NATIVE_CODE_REGEXP = /\{\s*\[native code\]\s*\}/g;
1517
var IS_PURE_FUNCTION = /function.*?\(/;
1618
var IS_ARROW_FUNCTION = /.*?=>.*?/;
1719
var UNSAFE_CHARS_REGEXP = /[<>\/\u2028\u2029]/g;
20+
// Regex to match </script> and </SCRIPT> (case-insensitive) for XSS protection
21+
var SCRIPT_CLOSE_REGEXP = /<\/script>/gi;
1822

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

@@ -32,8 +36,23 @@ function escapeUnsafeChars(unsafeChar) {
3236
return ESCAPED_CHARS[unsafeChar];
3337
}
3438

39+
// Escape function body for XSS protection while preserving arrow function syntax
40+
function escapeFunctionBody(str) {
41+
// Escape </script> sequences (case-insensitive) - the main XSS risk
42+
// This must be done first before other replacements
43+
str = str.replace(SCRIPT_CLOSE_REGEXP, function(match) {
44+
return '\\u003C\\u002Fscript\\u003E';
45+
});
46+
// Also escape </SCRIPT> and other case variations
47+
str = str.replace(/<\/SCRIPT>/g, '\\u003C\\u002FSCRIPT\\u003E');
48+
// Escape line terminators (these are always unsafe)
49+
str = str.replace(/\u2028/g, '\\u2028');
50+
str = str.replace(/\u2029/g, '\\u2029');
51+
return str;
52+
}
53+
3554
function generateUID() {
36-
var bytes = crypto.getRandomValues(new Uint8Array(UID_LENGTH));
55+
var bytes = crypto.randomBytes(UID_LENGTH);
3756
var result = '';
3857
for(var i=0; i<UID_LENGTH; ++i) {
3958
result += bytes[i].toString(16);
@@ -138,12 +157,18 @@ module.exports = function serialize(obj, options) {
138157
return value;
139158
}
140159

141-
function serializeFunc(fn) {
160+
function serializeFunc(fn, options) {
142161
var serializedFn = fn.toString();
143162
if (IS_NATIVE_CODE_REGEXP.test(serializedFn)) {
144163
throw new TypeError('Serializing native function: ' + fn.name);
145164
}
146165

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

262287
var fn = functions[valueIndex];
263288

264-
return serializeFunc(fn);
289+
return serializeFunc(fn, options);
265290
});
266291
}

test/unit/serialize.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,47 @@ 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+
});
498539
});
499540

500541
describe('options', function () {

0 commit comments

Comments
 (0)