Skip to content

Commit d05392f

Browse files
authored
fix(cli): block prototype-pollution payloads in --env parsing (#14165)
1 parent 6471d07 commit d05392f

2 files changed

Lines changed: 69 additions & 1 deletion

File tree

packages/rspack-cli/src/utils/options.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,16 +125,24 @@ function setBuiltinEnvArg(
125125
value: unknown,
126126
) {
127127
const envName = `RSPACK_${envNameSuffix}`;
128-
if (!(envName in env)) {
128+
// `hasOwnProperty.call` so a polluted prototype can't mask the write.
129+
if (!Object.prototype.hasOwnProperty.call(env, envName)) {
129130
env[envName] = value;
130131
}
131132
}
132133

134+
// Reject these segments in dotted `--env` paths to avoid prototype pollution.
135+
const DANGEROUS_ENV_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
136+
133137
function normalizeEnvToObject(options: CommonOptions) {
134138
function parseValue(previous: Record<string, unknown>, value: string) {
135139
const [allKeys, val] = value.split(/=(.+)/, 2);
136140
const splitKeys = allKeys.split(/\.(?!$)/);
137141

142+
if (splitKeys.some((k) => DANGEROUS_ENV_KEYS.has(k.replace(/=$/, '')))) {
143+
return previous;
144+
}
145+
138146
let prevRef = previous;
139147

140148
splitKeys.forEach((key, index) => {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from '@rstest/core';
2+
import { normalizeCommonOptions } from '../../src/utils/options';
3+
4+
describe('normalizeCommonOptions --env parsing', () => {
5+
it('builds a nested object from dotted keys', () => {
6+
const opts: any = { env: ['app.name=demo', 'app.debug=true'] };
7+
normalizeCommonOptions(opts, 'build');
8+
expect(opts.env.app.name).toBe('demo');
9+
expect(opts.env.app.debug).toBe('true');
10+
});
11+
12+
it('sets RSPACK_BUILD and RSPACK_BUNDLE on build action', () => {
13+
const opts: any = { env: [] };
14+
normalizeCommonOptions(opts, 'build');
15+
expect(opts.env.RSPACK_BUILD).toBe(true);
16+
expect(opts.env.RSPACK_BUNDLE).toBe(true);
17+
});
18+
19+
it('keeps env as a plain object so config functions can call Object methods', () => {
20+
const opts: any = { env: ['app.name=demo', 'flag=true'] };
21+
normalizeCommonOptions(opts, 'build');
22+
// User configs commonly do `env.hasOwnProperty(...)` or `env instanceof Object`.
23+
expect(opts.env instanceof Object).toBe(true);
24+
expect(Object.prototype.hasOwnProperty.call(opts.env, 'flag')).toBe(true);
25+
expect(opts.env.hasOwnProperty('app')).toBe(true);
26+
});
27+
28+
describe('prototype-pollution hardening', () => {
29+
afterEach(() => {
30+
// Defensive cleanup so a regression in this file cannot leak pollution
31+
// into sibling test files in the same worker.
32+
delete (Object.prototype as any).RSPACK_BUILD;
33+
delete (Object.prototype as any).polluted;
34+
delete (Object.prototype as any).injected;
35+
});
36+
37+
it('rejects __proto__ in dotted env path without polluting Object.prototype', () => {
38+
const opts: any = {
39+
env: ['__proto__.polluted=yes', '__proto__.RSPACK_BUILD=owned'],
40+
};
41+
normalizeCommonOptions(opts, 'build');
42+
43+
expect(({} as any).polluted).toBeUndefined();
44+
expect(({} as any).RSPACK_BUILD).toBeUndefined();
45+
});
46+
47+
it('rejects constructor.prototype.x payloads', () => {
48+
const opts: any = { env: ['constructor.prototype.injected=1'] };
49+
normalizeCommonOptions(opts, 'build');
50+
expect(({} as any).injected).toBeUndefined();
51+
});
52+
53+
it('does not allow attacker to spoof reserved RSPACK_BUILD via prototype', () => {
54+
const opts: any = { env: ['__proto__.RSPACK_BUILD=owned'] };
55+
normalizeCommonOptions(opts, 'build');
56+
// Legitimate write must win — not the attacker-controlled "owned" string.
57+
expect(opts.env.RSPACK_BUILD).toBe(true);
58+
});
59+
});
60+
});

0 commit comments

Comments
 (0)