165 lines
5.2 KiB
JavaScript
165 lines
5.2 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
/**
|
|||
|
|
* patch-p1-safe-merge-lib.js
|
|||
|
|
*
|
|||
|
|
* P1.1: 创建 hooks/lib/safe-merge.js
|
|||
|
|
*
|
|||
|
|
* 防原型污染 + 安全 JSON 合并,对齐 OpenClaw infra/prototype-keys + config/merge-patch。
|
|||
|
|
*
|
|||
|
|
* 输出 API:
|
|||
|
|
* - safeMerge(base, patch)
|
|||
|
|
* - safeJsonParse(text, fallback)
|
|||
|
|
* - sanitizeValue(value)
|
|||
|
|
* - assertNoPollution(obj, depth=10)
|
|||
|
|
* - isPlainObject(value)
|
|||
|
|
* - BLOCKED_MERGE_KEYS (Set)
|
|||
|
|
*
|
|||
|
|
* 协议: 补丁脚本写入 hooks/lib/safe-merge.js + sentinel + 自检
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
'use strict';
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
|
|||
|
|
const TARGET = path.join(__dirname, '..', '..', 'hooks', 'lib', 'safe-merge.js');
|
|||
|
|
const SENTINEL = 'P1-SAFE-MERGE-V1';
|
|||
|
|
|
|||
|
|
const CONTENT = `'use strict';
|
|||
|
|
/**
|
|||
|
|
* safe-merge.js — 原型污染防护 (${SENTINEL})
|
|||
|
|
*
|
|||
|
|
* 对齐 OpenClaw 双层架构:
|
|||
|
|
* - infra/prototype-keys.ts (BLOCKED_OBJECT_KEYS)
|
|||
|
|
* - config/merge-patch.ts (applyMergePatch)
|
|||
|
|
* - plugins/provider-auth-choice-helpers.ts (sanitizeConfigPatchValue)
|
|||
|
|
*
|
|||
|
|
* Bookworm 高风险位置:
|
|||
|
|
* - scripts/adaptive-disambiguator.js loadState() 持久化污染
|
|||
|
|
* - hooks/route-interceptor-bundle.js 4 处 JSON.parse 后展开
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const BLOCKED_MERGE_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
|||
|
|
|
|||
|
|
function isPlainObject(value) {
|
|||
|
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|||
|
|
const proto = Object.getPrototypeOf(value);
|
|||
|
|
return proto === Object.prototype || proto === null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sanitizeValue(value) {
|
|||
|
|
if (Array.isArray(value)) return value.map(sanitizeValue);
|
|||
|
|
if (!isPlainObject(value)) return value;
|
|||
|
|
const next = Object.create(null);
|
|||
|
|
for (const [key, nestedValue] of Object.entries(value)) {
|
|||
|
|
if (BLOCKED_MERGE_KEYS.has(key)) continue;
|
|||
|
|
next[key] = sanitizeValue(nestedValue);
|
|||
|
|
}
|
|||
|
|
return next;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function safeMerge(base, patch) {
|
|||
|
|
if (!isPlainObject(patch)) return sanitizeValue(patch);
|
|||
|
|
const result = isPlainObject(base) ? Object.assign({}, base) : {};
|
|||
|
|
for (const [key, value] of Object.entries(patch)) {
|
|||
|
|
if (BLOCKED_MERGE_KEYS.has(key)) continue;
|
|||
|
|
if (value === null) {
|
|||
|
|
delete result[key];
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if (isPlainObject(value) && isPlainObject(result[key])) {
|
|||
|
|
result[key] = safeMerge(result[key], value);
|
|||
|
|
} else {
|
|||
|
|
result[key] = sanitizeValue(value);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function safeJsonParse(text, fallback) {
|
|||
|
|
if (fallback === undefined) fallback = null;
|
|||
|
|
try {
|
|||
|
|
const parsed = JSON.parse(text);
|
|||
|
|
return sanitizeValue(parsed);
|
|||
|
|
} catch (_) {
|
|||
|
|
return fallback;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function assertNoPollution(obj, depth) {
|
|||
|
|
if (depth === undefined) depth = 10;
|
|||
|
|
if (depth <= 0 || !isPlainObject(obj)) return true;
|
|||
|
|
for (const key of Object.keys(obj)) {
|
|||
|
|
if (BLOCKED_MERGE_KEYS.has(key)) return false;
|
|||
|
|
if (!assertNoPollution(obj[key], depth - 1)) return false;
|
|||
|
|
}
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
safeMerge,
|
|||
|
|
safeJsonParse,
|
|||
|
|
sanitizeValue,
|
|||
|
|
assertNoPollution,
|
|||
|
|
isPlainObject,
|
|||
|
|
BLOCKED_MERGE_KEYS,
|
|||
|
|
__sentinel: '${SENTINEL}',
|
|||
|
|
};
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
function main() {
|
|||
|
|
// 创建目录(应已存在)
|
|||
|
|
const dir = path.dirname(TARGET);
|
|||
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|||
|
|
|
|||
|
|
// 幂等
|
|||
|
|
if (fs.existsSync(TARGET)) {
|
|||
|
|
const cur = fs.readFileSync(TARGET, 'utf8');
|
|||
|
|
if (cur.includes(SENTINEL)) {
|
|||
|
|
process.stdout.write('[SKIP] already deployed\n');
|
|||
|
|
process.exit(0);
|
|||
|
|
}
|
|||
|
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|||
|
|
fs.copyFileSync(TARGET, TARGET + '.bak.' + ts);
|
|||
|
|
process.stdout.write('[BACKUP] ' + TARGET + '.bak.' + ts + '\n');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 原子写
|
|||
|
|
const tmpPath = TARGET + '.tmp.' + process.pid;
|
|||
|
|
fs.writeFileSync(tmpPath, CONTENT);
|
|||
|
|
|
|||
|
|
// 加载验证 + 自检
|
|||
|
|
try {
|
|||
|
|
delete require.cache[require.resolve(tmpPath)];
|
|||
|
|
const mod = require(tmpPath);
|
|||
|
|
|
|||
|
|
// 自检 1: BLOCKED_MERGE_KEYS 包含三个核心键
|
|||
|
|
if (!mod.BLOCKED_MERGE_KEYS.has('__proto__')) throw new Error('BLOCKED missing __proto__');
|
|||
|
|
if (!mod.BLOCKED_MERGE_KEYS.has('constructor')) throw new Error('BLOCKED missing constructor');
|
|||
|
|
if (!mod.BLOCKED_MERGE_KEYS.has('prototype')) throw new Error('BLOCKED missing prototype');
|
|||
|
|
|
|||
|
|
// 自检 2: safeJsonParse 拦截污染
|
|||
|
|
const polluted = mod.safeJsonParse('{"__proto__":{"x":1},"a":2}');
|
|||
|
|
if (polluted.x !== undefined) throw new Error('pollution leaked through');
|
|||
|
|
if (polluted.a !== 2) throw new Error('legitimate value lost');
|
|||
|
|
|
|||
|
|
// 自检 3: 全局对象不被污染
|
|||
|
|
if (({}).x !== undefined) throw new Error('global Object.prototype polluted!');
|
|||
|
|
|
|||
|
|
// 自检 4: assertNoPollution
|
|||
|
|
// 注意: 字面量 {"__proto__":{}} 被 JS 引擎特殊处理为 prototype 设置而非 own property
|
|||
|
|
// JSON.parse 才会将 __proto__ 作为 own property 保留
|
|||
|
|
const taintedRaw = JSON.parse('{"__proto__":{"x":1},"normal":2}');
|
|||
|
|
if (mod.assertNoPollution(taintedRaw)) throw new Error('assertNoPollution false negative on JSON.parse');
|
|||
|
|
if (!mod.assertNoPollution({ a: 1, b: { c: 2 } })) throw new Error('assertNoPollution false positive');
|
|||
|
|
|
|||
|
|
fs.renameSync(tmpPath, TARGET);
|
|||
|
|
process.stdout.write('[OK] hooks/lib/safe-merge.js deployed (4 self-tests pass)\n');
|
|||
|
|
} catch (e) {
|
|||
|
|
fs.unlinkSync(tmpPath);
|
|||
|
|
process.stderr.write('[ERROR] self-test failed: ' + e.message + '\n');
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (require.main === module) main();
|