Skip to content

Commit 8be0f90

Browse files
authored
Merge pull request #3118 from modernweb-dev/fix/rce
fix: rce
2 parents d991cd3 + 85a9409 commit 8be0f90

3 files changed

Lines changed: 278 additions & 24 deletions

File tree

.changeset/late-toys-march.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@web/browser-logs': patch
3+
---
4+
5+
Fix RCE issue

packages/browser-logs/src/deserialize.ts

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ const ASYNC_DESERIALIZE_WRAPPER = Symbol('ASYNC_DESERIALIZE_WRAPPER');
77

88
const BOUND_NAME_FUNCTION_REGEX = /^bound\s+/;
99

10+
/**
11+
* Validates that a string is a safe JavaScript identifier before it is
12+
* interpolated into a new Function(...) call. Accepts only strings composed
13+
* of ASCII letters, digits, $ and _, with a non-digit first character.
14+
*
15+
* This prevents browser-controlled constructor/function names from breaking
16+
* out of the generated function declaration and executing arbitrary code in
17+
* the Node.js host process.
18+
*/
19+
const SAFE_IDENTIFIER_RE = /^[$A-Za-z_][$\w]*$/;
20+
21+
function isValidIdentifier(name: string): boolean {
22+
return SAFE_IDENTIFIER_RE.test(name);
23+
}
24+
1025
function createReviver(promises: Promise<unknown>[], options?: DeserializeOptions) {
1126
const undefinedPropsPerObject = new Map();
1227

@@ -41,20 +56,16 @@ function createReviver(promises: Promise<unknown>[], options?: DeserializeOption
4156
keys.push(key);
4257
}
4358
return;
44-
case 'Function':
45-
if (value.name.includes('-')) {
46-
const { name } = value;
47-
// eslint-disable-next-line
48-
const placeholder = { [name]: () => {} };
49-
return placeholder[name];
50-
}
59+
case 'Function': {
60+
const rawName = value.name.replace(BOUND_NAME_FUNCTION_REGEX, '');
61+
// Only allow names that are valid JS identifiers. Any payload that
62+
// tries to inject syntax (parentheses, spaces, commas, etc.) will
63+
// fail the check and be replaced with a safe literal fallback.
64+
const safeName = isValidIdentifier(rawName) ? rawName : 'anonymous';
65+
5166
// Create a fake function with the same name. We don't log the function implementation.
52-
return new Function(
53-
`return function ${value.name.replace(
54-
BOUND_NAME_FUNCTION_REGEX,
55-
'',
56-
)}() { /* implementation hidden */ }`,
57-
)();
67+
return new Function(`return function ${safeName}() { /* implementation hidden */ }`)();
68+
}
5869
case 'RegExp':
5970
// Create a new RegExp using the same parameters
6071
return new RegExp(value.source, value.flags);
@@ -95,12 +106,14 @@ function createReviver(promises: Promise<unknown>[], options?: DeserializeOption
95106

96107
/**
97108
* Objects in the browser are serialized to a simple object. We preserve the
98-
* constructor name and assign a fake prototpe to it here so that the name
109+
* constructor name and assign a fake prototype to it here so that the name
99110
* appears in the logs.
100111
*/
101112
if (hasOwnProperty.call(value, KEY_CONSTRUCTOR_NAME)) {
102-
const constructorName = value[KEY_CONSTRUCTOR_NAME];
103-
const ConstructorFunction = new Function(`return function ${constructorName}(){}`)();
113+
const rawName = value[KEY_CONSTRUCTOR_NAME];
114+
const safeName = isValidIdentifier(rawName) ? rawName : '__unknown__';
115+
116+
const ConstructorFunction = new Function(`return function ${safeName}(){}`)();
104117
Object.setPrototypeOf(value, new ConstructorFunction());
105118
delete value[KEY_CONSTRUCTOR_NAME];
106119
return value;

packages/browser-logs/test/serialize-deserialize.test.ts

Lines changed: 244 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ describe('serialize deserialize', { timeout: 10000 }, function () {
101101
);
102102
const deserialized = await deserialize(serialized);
103103
assert.equal(typeof deserialized, 'function');
104-
assert.equal(deserialized.name, '');
104+
// Empty function names are sanitized to 'anonymous' for security
105+
assert.equal(deserialized.name, 'anonymous');
105106
});
106107

107108
it('handles Text nodes', async () => {
@@ -200,12 +201,6 @@ describe('serialize deserialize', { timeout: 10000 }, function () {
200201
assert.deepEqual(deserialized, [1, 2, 3]);
201202
});
202203

203-
it('handles objects', async () => {
204-
const serialized = await page.evaluate(() => window['_serialize']({ a: 1, b: 2 }));
205-
const deserialized = await deserialize(serialized);
206-
assert.deepEqual(deserialized, { a: 1, b: 2 });
207-
});
208-
209204
it('handles objects with methods', async () => {
210205
const serialized = await page.evaluate(() =>
211206
window['_serialize']({
@@ -229,7 +224,8 @@ describe('serialize deserialize', { timeout: 10000 }, function () {
229224
assert.equal(typeof deserialized.baz, 'function');
230225
assert.equal(deserialized.baz.name, 'baz');
231226
assert.equal(typeof deserialized['my-element'], 'function');
232-
assert.equal(deserialized['my-element'].name, 'my-element');
227+
// Names with hyphens are not valid JS identifiers, sanitized to 'anonymous' for security
228+
assert.equal(deserialized['my-element'].name, 'anonymous');
233229
});
234230

235231
it('handles deep objects', async () => {
@@ -537,3 +533,243 @@ describe('serialize deserialize', { timeout: 10000 }, function () {
537533
assert.deepEqual(deserialized, null);
538534
});
539535
});
536+
537+
describe('deserialize security', function () {
538+
describe('constructor name injection prevention', function () {
539+
it('sanitizes constructor names with code injection payloads', async () => {
540+
// This payload would execute arbitrary code if interpolated directly into new Function(...)
541+
const maliciousPayload = JSON.stringify({
542+
__WTR_CONSTRUCTOR_NAME__: 'x(){},(globalThis.PWNED=true),function y',
543+
data: 'test',
544+
});
545+
546+
const deserialized = await deserialize(maliciousPayload);
547+
548+
// The malicious code should NOT have executed
549+
assert.equal((globalThis as any).PWNED, undefined);
550+
// The object should still be deserialized with a safe fallback name
551+
assert.equal(deserialized.constructor.name, '__unknown__');
552+
assert.equal(deserialized.data, 'test');
553+
});
554+
555+
it('sanitizes constructor names with require() injection', async () => {
556+
const maliciousPayload = JSON.stringify({
557+
__WTR_CONSTRUCTOR_NAME__:
558+
'x(){},require("child_process").execSync("echo PWNED"),function y',
559+
value: 123,
560+
});
561+
562+
const deserialized = await deserialize(maliciousPayload);
563+
assert.equal(deserialized.constructor.name, '__unknown__');
564+
assert.equal(deserialized.value, 123);
565+
});
566+
567+
it('sanitizes constructor names with parentheses', async () => {
568+
const maliciousPayload = JSON.stringify({
569+
__WTR_CONSTRUCTOR_NAME__: 'Foo()',
570+
});
571+
572+
const deserialized = await deserialize(maliciousPayload);
573+
assert.equal(deserialized.constructor.name, '__unknown__');
574+
});
575+
576+
it('sanitizes constructor names with spaces', async () => {
577+
const maliciousPayload = JSON.stringify({
578+
__WTR_CONSTRUCTOR_NAME__: 'Foo Bar',
579+
});
580+
581+
const deserialized = await deserialize(maliciousPayload);
582+
assert.equal(deserialized.constructor.name, '__unknown__');
583+
});
584+
585+
it('sanitizes constructor names with semicolons', async () => {
586+
const maliciousPayload = JSON.stringify({
587+
__WTR_CONSTRUCTOR_NAME__: 'Foo;console.log("pwned")',
588+
});
589+
590+
const deserialized = await deserialize(maliciousPayload);
591+
assert.equal(deserialized.constructor.name, '__unknown__');
592+
});
593+
594+
it('sanitizes constructor names with curly braces', async () => {
595+
const maliciousPayload = JSON.stringify({
596+
__WTR_CONSTRUCTOR_NAME__: 'Foo{}',
597+
});
598+
599+
const deserialized = await deserialize(maliciousPayload);
600+
assert.equal(deserialized.constructor.name, '__unknown__');
601+
});
602+
603+
it('sanitizes constructor names with commas', async () => {
604+
const maliciousPayload = JSON.stringify({
605+
__WTR_CONSTRUCTOR_NAME__: 'x,y',
606+
});
607+
608+
const deserialized = await deserialize(maliciousPayload);
609+
assert.equal(deserialized.constructor.name, '__unknown__');
610+
});
611+
612+
it('sanitizes empty constructor names', async () => {
613+
const maliciousPayload = JSON.stringify({
614+
__WTR_CONSTRUCTOR_NAME__: '',
615+
});
616+
617+
const deserialized = await deserialize(maliciousPayload);
618+
assert.equal(deserialized.constructor.name, '__unknown__');
619+
});
620+
621+
it('sanitizes constructor names starting with digits', async () => {
622+
const maliciousPayload = JSON.stringify({
623+
__WTR_CONSTRUCTOR_NAME__: '123Foo',
624+
});
625+
626+
const deserialized = await deserialize(maliciousPayload);
627+
assert.equal(deserialized.constructor.name, '__unknown__');
628+
});
629+
630+
it('allows valid constructor names', async () => {
631+
const validNames = ['Foo', 'FooBar', '_Foo', '$Foo', 'Foo123', '_$foo_bar_123'];
632+
633+
for (const name of validNames) {
634+
const payload = JSON.stringify({
635+
__WTR_CONSTRUCTOR_NAME__: name,
636+
});
637+
638+
const deserialized = await deserialize(payload);
639+
assert.equal(deserialized.constructor.name, name, `Expected ${name} to be allowed`);
640+
}
641+
});
642+
});
643+
644+
describe('function name injection prevention', function () {
645+
it('sanitizes function names with code injection payloads', async () => {
646+
const maliciousPayload = JSON.stringify({
647+
__WTR_TYPE__: 'Function',
648+
name: 'x(){},(globalThis.PWNED_FN=true),function y',
649+
});
650+
651+
const deserialized = await deserialize(maliciousPayload);
652+
653+
// The malicious code should NOT have executed
654+
assert.equal((globalThis as any).PWNED_FN, undefined);
655+
// The function should still be deserialized with a safe fallback name
656+
assert.equal(typeof deserialized, 'function');
657+
assert.equal(deserialized.name, 'anonymous');
658+
});
659+
660+
it('sanitizes function names with require() injection', async () => {
661+
const maliciousPayload = JSON.stringify({
662+
__WTR_TYPE__: 'Function',
663+
name: 'x(){},require("child_process"),function y',
664+
});
665+
666+
const deserialized = await deserialize(maliciousPayload);
667+
assert.equal(typeof deserialized, 'function');
668+
assert.equal(deserialized.name, 'anonymous');
669+
});
670+
671+
it('sanitizes function names with parentheses', async () => {
672+
const maliciousPayload = JSON.stringify({
673+
__WTR_TYPE__: 'Function',
674+
name: 'foo()',
675+
});
676+
677+
const deserialized = await deserialize(maliciousPayload);
678+
assert.equal(typeof deserialized, 'function');
679+
assert.equal(deserialized.name, 'anonymous');
680+
});
681+
682+
it('sanitizes function names with spaces', async () => {
683+
const maliciousPayload = JSON.stringify({
684+
__WTR_TYPE__: 'Function',
685+
name: 'foo bar',
686+
});
687+
688+
const deserialized = await deserialize(maliciousPayload);
689+
assert.equal(typeof deserialized, 'function');
690+
assert.equal(deserialized.name, 'anonymous');
691+
});
692+
693+
it('sanitizes empty function names', async () => {
694+
const maliciousPayload = JSON.stringify({
695+
__WTR_TYPE__: 'Function',
696+
name: '',
697+
});
698+
699+
const deserialized = await deserialize(maliciousPayload);
700+
assert.equal(typeof deserialized, 'function');
701+
assert.equal(deserialized.name, 'anonymous');
702+
});
703+
704+
it('handles bound function prefix with malicious payload', async () => {
705+
const maliciousPayload = JSON.stringify({
706+
__WTR_TYPE__: 'Function',
707+
name: 'bound x(){},(globalThis.PWNED_BOUND=true),function y',
708+
});
709+
710+
const deserialized = await deserialize(maliciousPayload);
711+
712+
assert.equal((globalThis as any).PWNED_BOUND, undefined);
713+
assert.equal(typeof deserialized, 'function');
714+
assert.equal(deserialized.name, 'anonymous');
715+
});
716+
717+
it('allows valid function names', async () => {
718+
const validNames = ['foo', 'fooBar', '_foo', '$foo', 'foo123', '_$foo_bar_123'];
719+
720+
for (const name of validNames) {
721+
const payload = JSON.stringify({
722+
__WTR_TYPE__: 'Function',
723+
name: name,
724+
});
725+
726+
const deserialized = await deserialize(payload);
727+
assert.equal(typeof deserialized, 'function');
728+
assert.equal(deserialized.name, name, `Expected ${name} to be allowed`);
729+
}
730+
});
731+
732+
it('allows valid bound function names', async () => {
733+
const payload = JSON.stringify({
734+
__WTR_TYPE__: 'Function',
735+
name: 'bound myFunction',
736+
});
737+
738+
const deserialized = await deserialize(payload);
739+
assert.equal(typeof deserialized, 'function');
740+
assert.equal(deserialized.name, 'myFunction');
741+
});
742+
});
743+
744+
describe('nested injection prevention', function () {
745+
it('sanitizes malicious names in nested objects', async () => {
746+
const maliciousPayload = JSON.stringify({
747+
nested: {
748+
__WTR_CONSTRUCTOR_NAME__: 'x(){},(globalThis.NESTED_PWNED=true),function y',
749+
data: 'nested data',
750+
},
751+
});
752+
753+
const deserialized = await deserialize(maliciousPayload);
754+
755+
assert.equal((globalThis as any).NESTED_PWNED, undefined);
756+
assert.equal(deserialized.nested.constructor.name, '__unknown__');
757+
assert.equal(deserialized.nested.data, 'nested data');
758+
});
759+
760+
it('sanitizes malicious function names in arrays', async () => {
761+
const maliciousPayload = JSON.stringify([
762+
{
763+
__WTR_TYPE__: 'Function',
764+
name: 'x(){},(globalThis.ARRAY_PWNED=true),function y',
765+
},
766+
]);
767+
768+
const deserialized = await deserialize(maliciousPayload);
769+
770+
assert.equal((globalThis as any).ARRAY_PWNED, undefined);
771+
assert.equal(typeof deserialized[0], 'function');
772+
assert.equal(deserialized[0].name, 'anonymous');
773+
});
774+
});
775+
});

0 commit comments

Comments
 (0)