fix(models/auth): sanitize replacement config patches

This commit is contained in:
Peter Steinberger
2026-04-23 06:57:45 +01:00
parent a849283f80
commit a7871d8212
2 changed files with 40 additions and 5 deletions

View File

@@ -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<string, unknown>).polluted).toBeUndefined();
});
it("keeps normal recursive merges for unrelated provider auth patch fields", () => {
const base = {
agents: {

View File

@@ -46,13 +46,31 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown> = {};
for (const [key, nestedValue] of Object.entries(value)) {
if (BLOCKED_MERGE_KEYS.has(key)) {
continue;
}
next[key] = sanitizeConfigPatchValue(nestedValue);
}
return next;
}
export function mergeConfigPatch<T>(base: T, patch: unknown): T {
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
return patch as T;
return sanitizeConfigPatchValue(patch) as T;
}
const next: Record<string, unknown> = { ...base };
@@ -64,7 +82,7 @@ export function mergeConfigPatch<T>(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<OpenClawConfig["agents"]>["defaults"]
>["models"],
},