fix(config): stop common config snippet walkers touching Object.prototype

`JSON.parse('{"__proto__":{…}}')` produces `__proto__` as an *own
enumerable* property, so `Object.entries` yields it; and
`isPlainObject(Object.prototype)` is true, so `deepMerge` skipped its
"replace with empty object" branch and merged straight into the global
prototype. Reproduced, not inferred.

`deepRemove` had the same shape and was destructive: `"__proto__" in
target` is always true because `in` walks the prototype chain, so it
recursed into `Object.prototype` and deleted from it.

Reachable without XSS: `settings` is not in the sync skip/preserve lists,
so `common_config_*` is overwritten by whatever the WebDAV/S3 remote
sends, and opening a provider form merges it.

Guard all three walkers that share the traversal shape. The third,
`isSubset`, only reads and cannot pollute, but following
`target["__proto__"]` made `{"__proto__":{}}` a subset of *every* config,
so the "common config applied" toggle read wrong. It also now requires
own properties, since an inherited key is not "present in the config".

`isSubset` rejects on a forbidden key rather than skipping: if a future
caller bypasses sanitization, reporting "not applied" is the safe
direction because re-applying is idempotent.

That rejection alone left an inconsistency: merge skips forbidden keys
and keeps going, so `{"env":{"A":"1"},"__proto__":{}}` really did write
`env.A` while `hasCommonConfigSnippet` reported it as never applied.
Fixed by sanitizing on the *reading* side only, so the comparison runs
against exactly what the write side produces. Deliberately not applied to
the write path: `deepMerge`/`deepRemove` already skip these keys, so
sanitizing first is byte-for-byte identical there -- an unfalsifiable
call that would wrongly imply the walkers cannot handle their own input.

`deepCloneFallback` gets the same skip. Its impact differs and the
comment says so: it does not reach the global prototype, it swaps the
clone's own prototype, giving the copy ghost properties. It is dead while
`structuredClone` exists, but the two paths disagreed on `__proto__`.
This commit is contained in:
Jason
2026-07-28 22:47:41 +08:00
parent 134bdc0e65
commit cd17912f04
3 changed files with 155 additions and 7 deletions
+6
View File
@@ -15,6 +15,12 @@ function deepCloneFallback<T>(value: T): T {
const cloned = {} as T;
Object.keys(value).forEach((key) => {
// `cloned["__proto__"] = …` 走的是 setter,会替换 cloned 自己的原型而不是
// 新增一个自有属性。这不会污染全局 `Object.prototype`(已实测),但会让克隆体
// 凭空读得到源对象里那些键,是个难查的幽灵属性。`structuredClone` 把
// `__proto__` 当普通数据键原样保留,两条路径的行为因此不一致——跳过它,
// 让 fallback 与主路径对齐。
if (key === "__proto__") return;
cloned[key as keyof T] = deepCloneFallback(value[key as keyof T]);
});
return cloned;
+77 -1
View File
@@ -1,11 +1,13 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import {
codexApiFormatFromWireApi,
isCodexAnthropicWireApi,
extractCodexModelName,
hasCommonConfigSnippet,
isCodexRemoteCompactionEnabled,
setCodexModelName,
setCodexRemoteCompaction,
updateCommonConfigSnippet,
} from "./providerConfigUtils";
describe("Codex wire API helpers", () => {
@@ -173,3 +175,77 @@ name = "Example"
expect(extractCodexModelName(singleQuoted)).toBe("kimi-k2.7");
});
});
describe("common config snippet prototype-pollution guards", () => {
// 污染是全局的:一旦漏进 Object.prototype,同文件后续用例会读到幽灵属性,
// 失败点会飘到无关的断言上。每条用例后强制清干净。
afterEach(() => {
delete (Object.prototype as Record<string, unknown>).polluted;
});
it("does not let a merged snippet reach Object.prototype", () => {
// `JSON.parse` 会把 `__proto__` 造成**自有可枚举属性**,所以它进得了
// `Object.entries`;而 `isPlainObject(Object.prototype)` 为 true,旧代码
// 因此不走"替换成空对象"的分支,直接把 value 合并进了全局原型。
const snippet = JSON.stringify({
env: { SHARED_TIMEOUT_MS: "1000" },
["__proto__"]: { polluted: "YES" },
});
const result = updateCommonConfigSnippet("{}", snippet, true);
expect(result.error).toBeUndefined();
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
// 正常键必须照旧合并进去——守卫不能顺手把可共享配置也吃掉。
expect(JSON.parse(result.updatedConfig).env.SHARED_TIMEOUT_MS).toBe("1000");
});
it("does not report a __proto__-only snippet as already applied", () => {
// isSubset 是这组遍历里的第三个函数,只读不写,所以不会污染原型——但不跳过
// 就会拿 `Object.prototype` 去比对:`{"__proto__":{}}` 的每个键在任何对象上
// 都"存在",于是被判成**任何**配置的子集,「通用配置已启用」开关随之读错。
expect(hasCommonConfigSnippet("{}", '{"__proto__":{}}')).toBe(false);
expect(
hasCommonConfigSnippet('{"env":{"A":"1"}}', '{"__proto__":{"x":1}}'),
).toBe(false);
});
it("keeps merge and applied-state consistent for a mixed snippet", () => {
// 混合片段是三个遍历函数语义分歧的照妖镜:deepMerge 跳过禁键继续写 env.A
// 而 isSubset 一旦见到禁键就整体否决 —— 结果是片段真的生效了,开关却永远
// 显示"未启用"。净化统一在入口做之后,这个偏差在结构上不再可能。
const snippet = JSON.stringify({
env: { A: "1" },
["__proto__"]: { polluted: "YES" },
});
const merged = updateCommonConfigSnippet("{}", snippet, true).updatedConfig;
expect(JSON.parse(merged).env.A).toBe("1");
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
// 写进去了,就必须报"已启用"
expect(hasCommonConfigSnippet(merged, snippet)).toBe(true);
});
it("still reports a genuinely applied snippet as applied", () => {
// 守卫不能把正常判定也一起改坏
expect(
hasCommonConfigSnippet('{"env":{"A":"1","B":"2"}}', '{"env":{"A":"1"}}'),
).toBe(true);
expect(
hasCommonConfigSnippet('{"env":{"A":"1"}}', '{"env":{"A":"9"}}'),
).toBe(false);
});
it("does not let an un-merged snippet delete from Object.prototype", () => {
// deepRemove 这侧更隐蔽:`"__proto__" in target` 恒为 true`in` 查原型链),
// 旧代码会递归进 Object.prototype 并 `delete` 掉命中的键。
(Object.prototype as Record<string, unknown>).polluted = "YES";
const snippet = JSON.stringify({ ["__proto__"]: { polluted: "YES" } });
const result = updateCommonConfigSnippet("{}", snippet, false);
expect(result.error).toBeUndefined();
expect(({} as Record<string, unknown>).polluted).toBe("YES");
});
});
+72 -6
View File
@@ -10,11 +10,53 @@ const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]";
};
/**
* 遍历配置对象时必须跳过的键。
*
* `JSON.parse('{"__proto__":{…}}')` 产生的 `__proto__` 是**自有可枚举属性**
* 会被 `Object.entries` 取出;而 `isPlainObject(target["__proto__"])` 对
* `Object.prototype` 返回 true,于是递归直接写进全局原型。通用配置片段
* (`settings.common_config_*`) 会被 WebDAV/S3 同步的远端覆盖,所以这条路径
* 不需要 XSS 就可达。
*
* 正常流程里片段在入口已经过 `sanitizeSnippet`,下面三个遍历函数
* (`deepMerge` / `deepRemove` / `isSubset`) 不会再见到这些键;它们各自仍带一层
* 检查,是为了让每个函数**单独拿出来用也是安全的**,不依赖调用方记得先净化。
*/
const FORBIDDEN_MERGE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
/**
* 递归剥掉禁键,得到"实际会被写进配置的那份片段"。
*
* 只给**读取侧**`hasCommonConfigSnippet` / `hasTomlCommonConfigSnippet`)用,
* 写入侧不需要——`deepMerge` / `deepRemove` 自身就跳过禁键,净化前后输出相同。
*
* 之所以读取侧非做不可:两侧对禁键的处理**语义不同**。写入侧是"跳过这个键、
* 继续处理其余字段",而 `isSubset` 出于安全必须"见到禁键就整体否决"。于是
* `{"env":{"A":"1"},"__proto__":{}}` 会真的写入 `env.A`,却被判定成"未应用"——
* 片段部分生效,而开关永远显示未启用。
*
* 让读取侧先净化,比对的就是写入侧真正会产生的那份内容,两边不再各说各话。
*/
const sanitizeSnippet = (value: any): any => {
if (Array.isArray(value)) return value.map(sanitizeSnippet);
if (!isPlainObject(value)) return value;
const cleaned: Record<string, any> = {};
for (const [key, child] of Object.entries(value)) {
if (FORBIDDEN_MERGE_KEYS.has(key)) continue;
cleaned[key] = sanitizeSnippet(child);
}
return cleaned;
};
const deepMerge = (
target: Record<string, any>,
source: Record<string, any>,
): Record<string, any> => {
Object.entries(source).forEach(([key, value]) => {
if (FORBIDDEN_MERGE_KEYS.has(key)) return;
if (isPlainObject(value)) {
if (!isPlainObject(target[key])) {
target[key] = {};
@@ -33,6 +75,9 @@ const deepRemove = (
source: Record<string, any>,
) => {
Object.entries(source).forEach(([key, value]) => {
// 同 deepMerge:这里更危险——`"__proto__" in target` 恒为 true`in` 查
// 原型链),不跳过会递归进 `Object.prototype` 并 `delete` 掉它的属性。
if (FORBIDDEN_MERGE_KEYS.has(key)) return;
if (!(key in target)) return;
if (isPlainObject(value) && isPlainObject(target[key])) {
@@ -51,9 +96,17 @@ const deepRemove = (
const isSubset = (target: any, source: any): boolean => {
if (isPlainObject(source)) {
if (!isPlainObject(target)) return false;
return Object.entries(source).every(([key, value]) =>
isSubset(target[key], value),
);
return Object.entries(source).every(([key, value]) => {
// 兜底(正常流程已被 sanitizeSnippet 剥掉)。这里只读不写,不会污染原型,
// 但不拦就会走进 `target["__proto__"]`(索引查原型链),拿
// `Object.prototype` 去比对——`{"__proto__":{}}` 会被判成**任何**配置的子集。
// 选择否决而不是跳过:万一有调用方绕过净化,"误报未应用"(用户再点一次,
// 合并是幂等的)比"误报已应用"安全。
if (FORBIDDEN_MERGE_KEYS.has(key)) return false;
// 继承来的键不算"配置里有这一项",必须是自有属性。
if (!Object.prototype.hasOwnProperty.call(target, key)) return false;
return isSubset(target[key], value);
});
}
if (Array.isArray(source)) {
@@ -119,6 +172,8 @@ export const updateCommonConfigSnippet = (
};
}
// 这里不必净化:deepMerge / deepRemove 自身就跳过禁键,净化前后输出逐字节相同。
// 需要净化的是**读取侧**hasCommonConfigSnippet),原因见 sanitizeSnippet。
const snippet = JSON.parse(snippetString) as Record<string, any>;
if (enabled) {
@@ -143,8 +198,13 @@ export const hasCommonConfigSnippet = (
try {
if (!snippetString.trim()) return false;
const config = jsonString ? JSON.parse(jsonString) : {};
const snippet = JSON.parse(snippetString);
if (!isPlainObject(snippet)) return false;
const parsed = JSON.parse(snippetString);
if (!isPlainObject(parsed)) return false;
// 与 updateCommonConfigSnippet 用同一份净化结果比对。
const snippet = sanitizeSnippet(parsed);
// 全是禁键时净化后为空对象——空片段什么也没应用,不能报"已启用"
//`isSubset(config, {})` 对任何配置都是 true)。
if (Object.keys(snippet).length === 0) return false;
return isSubset(config, snippet);
} catch (err) {
return false;
@@ -357,7 +417,13 @@ export const hasTomlCommonConfigSnippet = (
try {
const config = parseToml(normalizeTomlText(tomlString || ""));
const snippet = parseToml(normalizeTomlText(snippetString));
// 与 JSON 侧同样净化:smol-toml 也会把 `["__proto__"]` 这类表头解析成自有键。
const snippet = sanitizeSnippet(
parseToml(normalizeTomlText(snippetString)),
);
if (!isPlainObject(snippet) || Object.keys(snippet).length === 0) {
return false;
}
return isSubset(config, snippet);
} catch {
// Fallback to text-based matching if TOML parsing fails