mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 18:05:37 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user