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;