fix(#1533): prototype-pollution guard in _deepMergeConfig (#1534)

* fix: prototype-pollution guard in _deepMergeConfig (audit M4)

The root↔workstream config merge iterated Object.keys(overlay) with no
__proto__/constructor/prototype guard, while four sibling paths in the same
file (lines ~315/319/331/341/549) guard them. A workstream/root config.json
with {"__proto__": {...}} could pollute the merged object's prototype chain
and spoof unset config flags (per-object, not global Object.prototype).

Adds the same three-key continue guard at the top of the overlay loop plus a
regression test for __proto__/constructor/prototype overlay keys.

Closes a gap missed by the closed config proto-pollution hardening
(#751/#1406/#663).

Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz

* chore(changeset): Fixed fragment for #1534 (config proto-pollution guard)

Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Rezolv
2026-06-21 22:46:34 -04:00
committed by GitHub
parent 8748e95ed1
commit 75552f7ea0
3 changed files with 37 additions and 1 deletions

View File

@@ -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.

View File

@@ -138,6 +138,11 @@ function _deepMergeConfig(base: Record<string, unknown>, overlay: Record<string,
if (typeof base !== 'object' || typeof overlay !== 'object') return overlay;
const result: Record<string, unknown> = { ...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<string, unknown>, overlay[key] as Record<string, unknown>);
} else {

View File

@@ -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');
});
});