fix(config): strip prototype-hijacking keys from parsed config - #304
Conversation
loadConfig merged JSON.parse(output) directly via spread, so a crafted ~/.opencli/config.json with __proto__ / constructor keys could pollute Object.prototype. Realistic risk is low (the file is user-owned), but a future write path from untrusted input would turn this into unexpected property reads. Adds stripPoisonKeys() to drop __proto__/constructor/prototype before merge. Closes #301
Review — the fix is harmless, but the test is vacuousStripping The pre-fix code does not pollute
Both assertions in the new test ( The fixture also does not contain the payloadJSON.stringify({ __proto__: { polluted: true }, constructor: {...}, model: "gemini-2.5-flash" })
// => {"constructor":{"prototype":{"polluted2":true}},"model":"gemini-2.5-flash"}
SuggestionTwo honest options:
I would take option 1 — the guard is cheap and the deep-merge scenario is plausible — but the issue and test should not claim a live prototype-pollution vector that does not exist. Worth also noting this in #301 so the record is accurate if anyone audits the security work later. Process noteThis is the third of the current security batch where the test does not exercise the real call path (see also the |
…as hardening (#301) Review: the test wrote JSON.stringify({__proto__: ...}), but __proto__: in an object literal is the prototype setter and never serialises — so the fixture contained no __proto__ at all. Separately, JSON.parse + object spread does not pollute Object.prototype via this path, so the prototype assertions passed vacuously (on unpatched main too). Rewrite the fixture as a RAW JSON string containing __proto__/constructor, and assert what is actually true: the poison keys do not survive onto the returned object's own properties (fails on unpatched main — verified) while legitimate fields load. stripPoisonKeys is now positioned as hardening against a future deep merge, which is where a live vector would appear. References #301
| const cfgDir = join(tmpHome, ".opencli"); | ||
| await mkdir(cfgDir, { recursive: true }); | ||
| await writeFile( | ||
| join(cfgDir, "config.json"), |
|
Addressed in 099ca01. Rewrote the fixture as a raw JSON string containing proto/constructor, and the assertions now check what is actually true: the poison keys do not survive onto the returned object's own properties (verified to FAIL on unpatched main) while legitimate fields load. Reframed stripPoisonKeys as hardening against a future deep merge, since JSON.parse + spread does not pollute Object.prototype via this path. Also left a note on #301 to keep the record accurate. |
Round-2 re-review — resolvedVerified the rewritten test genuinely carries signal, which was the whole point: The raw-JSON-string fixture is right (an object literal's One leftover inconsistencyThe comment above // Object keys that can hijack the prototype chain via JSON.parse + spread. A crafted
// ~/.opencli/config.json (or a future path that writes config from untrusted input)
// could otherwise pollute Object.prototype. See #301.
|
… live vuln) (#301) Review round 2: the code comment claimed the pre-fix path 'could otherwise pollute Object.prototype', contradicting the test comment (and the round-2 verification), which shows JSON.parse + spread does not pollute via this path. Correct the code comment to match: this is hardening against a future deep merge, not a fix for a current prototype-pollution vector. References #301
Summary
loadConfigmergedJSON.parse(file)directly via spread, so a crafted~/.opencli/config.jsonwith__proto__/constructorkeys could polluteObject.prototype. Realistic risk is low (the file is user-owned,0o600), but a future write path from untrusted input would turn this into unexpected property reads.Adds
stripPoisonKeys()to drop__proto__/constructor/prototypebefore merging parsed config over defaults.Test plan
npm run typecheck && npm run lint && npm run format:check && npm test— all pass (843 tests)__proto__/constructorloads its legitimate fields but does NOT polluteObject.prototype.Closes #301