Skip to content

Commit c0bf0ee

Browse files
amannncursoragent
andauthored
fix: Prototype safety guards for precompile: true (#2307)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent b4aa538 commit c0bf0ee

10 files changed

Lines changed: 2583 additions & 11 deletions

File tree

packages/icu-minify/.size-limit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const config: SizeLimitConfig = [
1111
name: "import compile from 'icu-minify/compile' (production)",
1212
import: 'compile',
1313
path: 'dist/esm/production/compile.js',
14-
limit: '7.055 kB'
14+
limit: '7.1 kB'
1515
}
1616
];
1717

packages/icu-minify/src/compile.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ function compileDateTimeStyle(
189189
}
190190

191191
function compileSelect(node: SelectElement): CompiledNode {
192-
const options: SelectOptions = {};
192+
const options = Object.create(null) as SelectOptions;
193193

194194
for (const [key, option] of Object.entries(node.options)) {
195195
options[key] = compileNodesToNode(option.value);
@@ -203,7 +203,7 @@ function compilePlural(node: PluralElementBase): CompiledNode {
203203
if (process.env.NODE_ENV !== 'production' && node.offset) {
204204
throw new Error('Plural offsets are not supported');
205205
}
206-
const options: PluralOptions = {};
206+
const options = Object.create(null) as PluralOptions;
207207

208208
for (const [key, option] of Object.entries(node.options)) {
209209
options[key] = compileNodesToNode(option.value);

packages/icu-minify/test/roundtrip.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,21 @@ describe('select', () => {
553553
).toMatchInlineSnapshot(`"They"`);
554554
});
555555

556+
it('falls back to other when the value matches an Object.prototype key', () => {
557+
const compiled = compile(
558+
'{role, select, admin {Admin} user {User} other {Guest}}'
559+
);
560+
for (const key of [
561+
'constructor',
562+
'hasOwnProperty',
563+
'toString',
564+
'valueOf',
565+
'__proto__'
566+
]) {
567+
expect(formatMessage(compiled, 'en', {role: key})).toBe('Guest');
568+
}
569+
});
570+
556571
it('formats arguments in branches', () => {
557572
const compiled = compile(
558573
'{gender, select, female {{name} is a woman} other {{name} is a person}}'

packages/next-intl/src/extractor/format/codecs/JSONCodec.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import {getSortedMessages, setNestedProperty} from '../../utils.js';
1+
import {
2+
getSortedMessages,
3+
isForbiddenObjectKey,
4+
setNestedProperty
5+
} from '../../utils.js';
26
import {defineCodec} from '../ExtractorCodec.js';
37

48
interface StoredFormat {
@@ -38,6 +42,9 @@ function traverseMessages(
3842
const NAMESPACE_SEPARATOR = '.';
3943

4044
for (const key of Object.keys(obj)) {
45+
if (isForbiddenObjectKey(key)) {
46+
throw new Error(`Invalid message catalog key: \`${key}\`.`);
47+
}
4148
const newPath = path ? path + NAMESPACE_SEPARATOR + key : key;
4249
const value = obj[key];
4350
if (typeof value === 'string') {

packages/next-intl/src/extractor/utils.test.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {describe, expect, it} from 'vitest';
2-
import {getSortedMessages} from './utils.js';
2+
import {getSortedMessages, setNestedProperty} from './utils.js';
33

44
describe('getSortedMessages', () => {
55
it('sorts by reference path', () => {
@@ -63,3 +63,21 @@ describe('getSortedMessages', () => {
6363
).toEqual(['c', 'a', 'b']);
6464
});
6565
});
66+
67+
describe('setNestedProperty', () => {
68+
it('rejects __proto__ segments (prototype pollution)', () => {
69+
expect(() => setNestedProperty({}, '__proto__.polluted', 'x')).toThrow(
70+
'Invalid message id segment: __proto__'
71+
);
72+
expect(
73+
(Object.prototype as unknown as {polluted?: string}).polluted
74+
).toBeUndefined();
75+
});
76+
77+
it('creates plain data properties for nested paths', () => {
78+
const root = Object.create(null) as Record<string, unknown>;
79+
setNestedProperty(root, 'a.b', 1);
80+
expect(Object.hasOwn(root, 'a')).toBe(true);
81+
expect(({} as Record<string, unknown>).b).toBeUndefined();
82+
});
83+
});

packages/next-intl/src/extractor/utils.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,38 @@ export function normalizePathToPosix(filePath: string): string {
99
);
1010
}
1111

12+
const FORBIDDEN_OBJECT_KEYS = new Set([
13+
'__proto__',
14+
'constructor',
15+
'prototype'
16+
]);
17+
18+
export function isForbiddenObjectKey(key: string): boolean {
19+
return FORBIDDEN_OBJECT_KEYS.has(key);
20+
}
21+
1222
// Essentialls lodash/set, but we avoid this dependency
1323
export function setNestedProperty(
1424
obj: Record<string, any>,
1525
keyPath: string,
1626
value: any
1727
): void {
1828
const keys = keyPath.split('.');
19-
let current = obj;
29+
for (const key of keys) {
30+
if (isForbiddenObjectKey(key)) {
31+
throw new Error(`Invalid message id segment: ${key}`);
32+
}
33+
}
2034

35+
let current = obj;
2136
for (let i = 0; i < keys.length - 1; i++) {
2237
const key = keys[i];
2338
if (
24-
!(key in current) ||
39+
!Object.prototype.hasOwnProperty.call(current, key) ||
2540
typeof current[key] !== 'object' ||
2641
current[key] === null
2742
) {
28-
current[key] = {};
43+
current[key] = Object.create(null);
2944
}
3045
current = current[key];
3146
}

packages/next-intl/src/plugin/catalog/catalogLoader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ function precompileMessages(
9090
messages: Array<ExtractorMessage>,
9191
cache: Map<string, CompiledMessageCacheEntry>
9292
): Record<string, unknown> {
93-
const result: Record<string, unknown> = {};
93+
const result = Object.create(null) as Record<string, unknown>;
9494
const cacheKeysToEvict = new Set(cache.keys());
9595

9696
for (const message of messages) {
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
/target/*
2-
!/target/wasm32-wasip1/release/swc_plugin_extractor.wasm
3-
Cargo.lock
2+
!/target/wasm32-wasip1/release/swc_plugin_extractor.wasm

0 commit comments

Comments
 (0)