diff --git a/src/plugins/provider-auth-choice-helpers.test.ts b/src/plugins/provider-auth-choice-helpers.test.ts index 9ac05235baa..8393a79bffb 100644 --- a/src/plugins/provider-auth-choice-helpers.test.ts +++ b/src/plugins/provider-auth-choice-helpers.test.ts @@ -50,6 +50,23 @@ describe("applyProviderAuthConfigPatch", () => { expect(Object.getPrototypeOf(next).polluted).toBeUndefined(); }); + it("drops prototype-pollution keys from opt-in model replacement", () => { + const patch = JSON.parse( + '{"agents":{"defaults":{"models":{"__proto__":{"polluted":true},"claude-cli/claude-sonnet-4-6":{"alias":"Sonnet","params":{"constructor":{"polluted":true},"maxTokens":12000}}}}}}', + ); + const next = applyProviderAuthConfigPatch(base, patch, { replaceDefaultModels: true }); + const models = next.agents?.defaults?.models; + expect(models).toEqual({ + "claude-cli/claude-sonnet-4-6": { + alias: "Sonnet", + params: { maxTokens: 12000 }, + }, + }); + expect(Object.prototype.hasOwnProperty.call(models, "__proto__")).toBe(false); + expect(Object.getPrototypeOf(Object.assign({}, models)).polluted).toBeUndefined(); + expect(({} as Record).polluted).toBeUndefined(); + }); + it("keeps normal recursive merges for unrelated provider auth patch fields", () => { const base = { agents: { diff --git a/src/plugins/provider-auth-choice-helpers.ts b/src/plugins/provider-auth-choice-helpers.ts index 0f5f53d0c48..4e1623b4f96 100644 --- a/src/plugins/provider-auth-choice-helpers.ts +++ b/src/plugins/provider-auth-choice-helpers.ts @@ -46,13 +46,31 @@ function isPlainRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } -// Guard the recursive merge against prototype-pollution payloads if a -// patch ever arrives from a JSON-parsed source that preserves these keys. +// Guard config patches against prototype-pollution payloads if a patch ever +// arrives from a JSON-parsed source that preserves these keys. const BLOCKED_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]); +function sanitizeConfigPatchValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => sanitizeConfigPatchValue(entry)); + } + if (!isPlainRecord(value)) { + return value; + } + + const next: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + if (BLOCKED_MERGE_KEYS.has(key)) { + continue; + } + next[key] = sanitizeConfigPatchValue(nestedValue); + } + return next; +} + export function mergeConfigPatch(base: T, patch: unknown): T { if (!isPlainRecord(base) || !isPlainRecord(patch)) { - return patch as T; + return sanitizeConfigPatchValue(patch) as T; } const next: Record = { ...base }; @@ -64,7 +82,7 @@ export function mergeConfigPatch(base: T, patch: unknown): T { if (isPlainRecord(existing) && isPlainRecord(value)) { next[key] = mergeConfigPatch(existing, value); } else { - next[key] = value; + next[key] = sanitizeConfigPatchValue(value); } } return next as T; @@ -93,7 +111,7 @@ export function applyProviderAuthConfigPatch( defaults: { ...merged.agents?.defaults, // Opt-in replacement for migrations that rename/remove model keys. - models: patchModels as NonNullable< + models: sanitizeConfigPatchValue(patchModels) as NonNullable< NonNullable["defaults"] >["models"], },