* 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:
5
.changeset/eager-mice-cheer.md
Normal file
5
.changeset/eager-mice-cheer.md
Normal 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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user