#!/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();