diff --git a/.changeset/eager-mice-cheer.md b/.changeset/eager-mice-cheer.md new file mode 100644 index 000000000..70b7afde0 --- /dev/null +++ b/.changeset/eager-mice-cheer.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1534 +--- +Add prototype-pollution guard to the workstream/root config merge (_deepMergeConfig) so a config.json with a __proto__/constructor/prototype key can no longer spoof unset config flags. diff --git a/src/config-loader.cts b/src/config-loader.cts index cf6e9dbf6..5f5e79ec1 100644 --- a/src/config-loader.cts +++ b/src/config-loader.cts @@ -138,6 +138,11 @@ function _deepMergeConfig(base: Record, overlay: Record = { ...base }; for (const key of Object.keys(overlay)) { + // Prototype-pollution guard — mirrors the four sibling guards in this file + // (lines ~315/319/331/341/549). Without it a workstream/root config.json with + // {"__proto__": {...}} pollutes this merged object's prototype chain and can + // spoof unset config flags. (Per-object pollution, not global Object.prototype.) + if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue; if (overlay[key] !== null && typeof overlay[key] === 'object' && !Array.isArray(overlay[key])) { result[key] = _deepMergeConfig((base[key] ?? {}) as Record, overlay[key] as Record); } else { diff --git a/tests/config-loader.test.cjs b/tests/config-loader.test.cjs index f338726ee..a7d006f10 100644 --- a/tests/config-loader.test.cjs +++ b/tests/config-loader.test.cjs @@ -28,7 +28,7 @@ const { cleanup } = require('./helpers.cjs'); const configLoader = require('../gsd-core/bin/lib/config-loader.cjs'); -const { loadConfig, loadConfigResolved, _resetRuntimeWarningCacheForTests } = configLoader; +const { loadConfig, loadConfigResolved, _resetRuntimeWarningCacheForTests, _deepMergeConfig } = configLoader; // ─── helpers ────────────────────────────────────────────────────────────────── @@ -492,3 +492,29 @@ describe('loadConfigResolved — provenance', () => { assert.equal(result.config.model_profile, 'root-val-c'); }); }); + +// ─── _deepMergeConfig prototype-pollution guard (audit M4) ─────────────────── +// The root↔workstream merge once iterated Object.keys(overlay) with no +// __proto__/constructor/prototype guard — while four sibling paths in the same +// file guard them. A config.json with {"__proto__": {...}} could pollute the +// merged object's prototype chain and spoof unset config flags. +describe('_deepMergeConfig — prototype-pollution guard (M4)', () => { + test('ignores a __proto__ overlay key (no proto pollution, no flag spoofing)', () => { + // JSON.parse (not an object literal) creates an OWN enumerable "__proto__" + // key — exactly what a malicious config.json on disk yields. + const malicious = JSON.parse('{"__proto__": {"injectedFlag": true}}'); + const merged = _deepMergeConfig({ model_profile: 'base' }, malicious); + assert.equal({}.injectedFlag, undefined, 'global Object.prototype must not be polluted'); + assert.equal(merged.injectedFlag, undefined, 'merged object must not expose the injected flag'); + assert.equal(Object.getPrototypeOf(merged) === Object.prototype, true, 'merged prototype unchanged'); + assert.equal(merged.model_profile, 'base', 'legitimate keys still merge'); + }); + + test('ignores constructor/prototype overlay keys too', () => { + const malicious = JSON.parse('{"constructor": {"x": 1}, "prototype": {"y": 2}}'); + const merged = _deepMergeConfig({ a: 1 }, malicious); + assert.equal(merged.a, 1); + // constructor must remain the native Object constructor, not the injected object + assert.equal(typeof merged.constructor, 'function'); + }); +});