* 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;
|
if (typeof base !== 'object' || typeof overlay !== 'object') return overlay;
|
||||||
const result: Record<string, unknown> = { ...base };
|
const result: Record<string, unknown> = { ...base };
|
||||||
for (const key of Object.keys(overlay)) {
|
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])) {
|
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>);
|
result[key] = _deepMergeConfig((base[key] ?? {}) as Record<string, unknown>, overlay[key] as Record<string, unknown>);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const { cleanup } = require('./helpers.cjs');
|
|||||||
|
|
||||||
const configLoader = require('../gsd-core/bin/lib/config-loader.cjs');
|
const configLoader = require('../gsd-core/bin/lib/config-loader.cjs');
|
||||||
|
|
||||||
const { loadConfig, loadConfigResolved, _resetRuntimeWarningCacheForTests } = configLoader;
|
const { loadConfig, loadConfigResolved, _resetRuntimeWarningCacheForTests, _deepMergeConfig } = configLoader;
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -492,3 +492,29 @@ describe('loadConfigResolved — provenance', () => {
|
|||||||
assert.equal(result.config.model_profile, 'root-val-c');
|
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