diff --git a/src/plugins/provider-auth-choice-helpers.test.ts b/src/plugins/provider-auth-choice-helpers.test.ts index e87eb937914..9ac05235baa 100644 --- a/src/plugins/provider-auth-choice-helpers.test.ts +++ b/src/plugins/provider-auth-choice-helpers.test.ts @@ -42,6 +42,14 @@ describe("applyProviderAuthConfigPatch", () => { expect(next.agents?.defaults?.model).toEqual(base.agents.defaults.model); }); + it("drops prototype-pollution keys from the merge", () => { + const patch = JSON.parse('{"__proto__":{"polluted":true},"agents":{"defaults":{}}}'); + const next = applyProviderAuthConfigPatch(base, patch); + expect(next.agents?.defaults?.models).toEqual(base.agents.defaults.models); + expect(({} as Record).polluted).toBeUndefined(); + expect(Object.getPrototypeOf(next).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 b7dd3bfc063..0f5f53d0c48 100644 --- a/src/plugins/provider-auth-choice-helpers.ts +++ b/src/plugins/provider-auth-choice-helpers.ts @@ -46,6 +46,10 @@ 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. +const BLOCKED_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]); + export function mergeConfigPatch(base: T, patch: unknown): T { if (!isPlainRecord(base) || !isPlainRecord(patch)) { return patch as T; @@ -53,6 +57,9 @@ export function mergeConfigPatch(base: T, patch: unknown): T { const next: Record = { ...base }; for (const [key, value] of Object.entries(patch)) { + if (BLOCKED_MERGE_KEYS.has(key)) { + continue; + } const existing = next[key]; if (isPlainRecord(existing) && isPlainRecord(value)) { next[key] = mergeConfigPatch(existing, value);