mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:50:44 +00:00
fix(models/auth): sanitize replacement config patches
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user