Files
CC-Switch/src/utils/deepClone.ts
T
Jason cd17912f04 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__`.
2026-07-29 10:22:30 +08:00

28 lines
1.1 KiB
TypeScript

export function deepClone<T>(value: T): T {
if (typeof globalThis.structuredClone === "function") {
return globalThis.structuredClone(value);
}
return deepCloneFallback(value);
}
function deepCloneFallback<T>(value: T): T {
if (value === null || typeof value !== "object") return value;
if (value instanceof Date) return new Date(value.getTime()) as T;
if (Array.isArray(value)) {
return value.map((item) => deepCloneFallback(item)) as 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;
}