diff --git a/plugins/webhook-lab/.gitignore b/plugins/webhook-lab/.gitignore new file mode 100644 index 00000000..1eae0cf6 --- /dev/null +++ b/plugins/webhook-lab/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/plugins/webhook-lab/CHANGELOG.md b/plugins/webhook-lab/CHANGELOG.md new file mode 100644 index 00000000..f3c90ff1 --- /dev/null +++ b/plugins/webhook-lab/CHANGELOG.md @@ -0,0 +1,11 @@ +# 更新日志 + +## 0.1.0 + +- 提供有界的本地 Webhook 收件箱。 +- 为 ZTools 2.4+ 增加有界原生 MCP 工具,用于本地 HMAC 计算和脱敏负载预览;旧版宿主可平滑降级。 +- 在文本与嵌套 JSON 预览中脱敏常见 GitHub、OpenAI、AWS 凭据及 PEM 私钥。 +- 通过无原型对象、描述符安全复制、确定性冲突后缀和保留键别名清洗动态 JSON 键。 +- 根目录宿主清单直接指向可加载的源码 UI/preload 入口,同时仅将生成的 `dist/plugin.json` 用于发布。 +- 对未压缩 `dist` 执行递归 14.5 MB 大小门禁并报告精确体积。 +- 将人工界面、运行状态和面向人的错误提示统一为简体中文。 diff --git a/plugins/webhook-lab/README.md b/plugins/webhook-lab/README.md new file mode 100644 index 00000000..179031fb --- /dev/null +++ b/plugins/webhook-lab/README.md @@ -0,0 +1,15 @@ +# Webhook 实验室 + +一个有界的本地 Webhook 接收器。它只监听带随机路由令牌的回环地址,最多保留 200 条小型事件,并可在不向外发送内容的前提下预览和校验负载。首个版本暂不提供重放功能。 + +在 Windows 上,复制出的示例可直接在 PowerShell 中执行:命令使用带单引号参数的 `curl.exe`;若本地监听 URL 含单引号,则会被拒绝。 + +Node 测试覆盖跨平台命令与生命周期契约,回环服务器已在开发用 macOS 设备完成冒烟测试。Windows、macOS、Linux 的真实 ZTools 宿主加载仍未验证;Windows PowerShell 执行和 Linux 运行行为目前仅完成契约测试。 + +根目录 `plugin.json` 直接指向 `src/main/index.html`、`src/preload/index.cjs` 和 `logo.svg`,因此 ZTools 开发模式不依赖 `development` 覆盖即可加载界面与 preload。`npm run build` 会将 `dist/plugin.json` 重写为可独立发布的入口。`verify-dist` 递归统计 `dist` 内所有未压缩文件,打印精确字节数,并执行 14.5 MB(14,500,000 字节)安全门禁。 + +## Agent / MCP 使用 + +ZTools 2.4 及以上版本可将 `hmac` 和 `preview_payload` 分别作为 `webhook_lab_hmac`、`webhook_lab_preview_payload` 提供给 Agent。它们都是纯本地计算:不能启动、列出、停止或重放监听流量。`hmac` 仅接受 SHA-256 或 SHA-512,将 UTF-8 正文限制为 256 KiB、密钥限制为 8192 字节,并且只返回摘要和非敏感元数据。`preview_payload` 复用人工界面的负载解析器与 preload 脱敏器,将 UTF-8 正文限制为 256 KiB、内容类型限制为 256 字节、序列化响应限制为 64 KiB。 + +ZTools MCP 传输允许的请求体最大为 1 MiB,且不会替各工具执行 JSON Schema 校验或限制响应,因此 preload 会独立拒绝未知字段、恶意或自定义原型、访问器、错误类型及越界输入。动态 JSON 键和值使用同一套最终凭据清洗器:带凭据含义的键、Bearer/JWT 值、GitHub 令牌、OpenAI `sk-` 令牌、AWS 访问密钥 ID 和带标签的密钥,以及 PEM 私钥块,都会在预览离开能力桥前被脱敏。脱敏键冲突会添加确定性后缀;`__proto__`、`constructor` 和 `prototype` 则会复制到无原型对象中的安全保留键别名。本功能只提供尽力而为的安全预览,不能证明负载绝对不含秘密;新型、无标签或业务自定义凭据仍需人工检查。旧版宿主没有 `registerTool` 时仍保留人工界面。Windows、macOS、Linux 真机 ZTools 宿主加载仍待验证。 diff --git a/plugins/webhook-lab/logo.svg b/plugins/webhook-lab/logo.svg new file mode 100644 index 00000000..bd32d310 --- /dev/null +++ b/plugins/webhook-lab/logo.svg @@ -0,0 +1 @@ + diff --git a/plugins/webhook-lab/package-lock.json b/plugins/webhook-lab/package-lock.json new file mode 100644 index 00000000..925b54bb --- /dev/null +++ b/plugins/webhook-lab/package-lock.json @@ -0,0 +1 @@ +{"name":"webhook-lab","version":"0.1.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"webhook-lab","version":"0.1.0","engines":{"node":">=16"}}}} diff --git a/plugins/webhook-lab/package.json b/plugins/webhook-lab/package.json new file mode 100644 index 00000000..89101ffa --- /dev/null +++ b/plugins/webhook-lab/package.json @@ -0,0 +1 @@ +{"name":"webhook-lab","version":"0.1.0","private":true,"scripts":{"test":"node --test","build":"npm test && node scripts/build.mjs && node scripts/verify-dist.mjs","verify-dist":"node scripts/verify-dist.mjs"},"engines":{"node":">=16"}} diff --git a/plugins/webhook-lab/plugin.json b/plugins/webhook-lab/plugin.json new file mode 100644 index 00000000..488eda90 --- /dev/null +++ b/plugins/webhook-lab/plugin.json @@ -0,0 +1,82 @@ +{ + "name": "webhook-lab", + "title": "Webhook 实验室", + "version": "0.1.0", + "description": "仅在本机运行的有界 Webhook 收件箱与签名工作台。", + "author": "harris", + "platform": [ + "darwin", + "win32", + "linux" + ], + "categories": [ + "development", + "network" + ], + "main": "src/main/index.html", + "preload": "src/preload/index.cjs", + "logo": "logo.svg", + "features": [ + { + "code": "webhook-lab", + "icon": "logo.svg", + "explain": "接收并检查本地 Webhook 请求", + "cmds": [ + "Webhook 实验室", + "Webhook 调试" + ] + } + ], + "tools": { + "hmac": { + "title": "计算 Webhook HMAC", + "description": "纯本地计算 SHA-256 或 SHA-512 HMAC;不会启动监听器,也不会返回正文或密钥。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "body": { + "type": "string", + "maxLength": 262144 + }, + "secret": { + "type": "string", + "maxLength": 8192 + }, + "algorithm": { + "type": "string", + "enum": [ + "sha256", + "sha512" + ] + } + }, + "required": [ + "body", + "secret" + ] + } + }, + "preview_payload": { + "title": "安全预览 Webhook 负载", + "description": "复用本地负载预览与凭据脱敏逻辑,返回不超过 64 KiB 的只读结果。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "body": { + "type": "string", + "maxLength": 262144 + }, + "contentType": { + "type": "string", + "maxLength": 256 + } + }, + "required": [ + "body" + ] + } + } + } +} diff --git a/plugins/webhook-lab/scripts/build.mjs b/plugins/webhook-lab/scripts/build.mjs new file mode 100644 index 00000000..6b8fa819 --- /dev/null +++ b/plugins/webhook-lab/scripts/build.mjs @@ -0,0 +1 @@ +import{cp,mkdir,readFile,rm,writeFile}from'node:fs/promises';import path from'node:path';import{fileURLToPath}from'node:url';const root=path.dirname(path.dirname(fileURLToPath(import.meta.url))),d=path.join(root,'dist');await rm(d,{recursive:true,force:true});await mkdir(d,{recursive:true});await cp(path.join(root,'src'),d,{recursive:true});await cp(path.join(root,'logo.svg'),path.join(d,'logo.svg'));const m=JSON.parse(await readFile(path.join(root,'plugin.json')));delete m.development;m.main='main/index.html';m.preload='preload/index.cjs';m.logo='logo.svg';await writeFile(path.join(d,'plugin.json'),JSON.stringify(m,null,2)); diff --git a/plugins/webhook-lab/scripts/dist-size.mjs b/plugins/webhook-lab/scripts/dist-size.mjs new file mode 100644 index 00000000..30964eda --- /dev/null +++ b/plugins/webhook-lab/scripts/dist-size.mjs @@ -0,0 +1,39 @@ +import { lstat, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +export const DIST_SIZE_LIMIT_BYTES = 14_500_000; + +export function assertWithinDistSizeLimit(bytes, limit = DIST_SIZE_LIMIT_BYTES) { + if (!Number.isSafeInteger(bytes) || bytes < 0) throw new TypeError('dist byte count must be a non-negative safe integer'); + if (!Number.isSafeInteger(limit) || limit < 0) throw new TypeError('dist size limit must be a non-negative safe integer'); + if (bytes > limit) throw new Error(`dist is ${bytes} bytes and exceeds the 14.5 MB safety limit (${limit} bytes)`); + return bytes; +} + +export async function directoryBytes(directory, options = {}) { + const { + baseDirectory = directory, + readEntries = readdir, + inspectEntry = lstat + } = options; + let total = 0; + + for (const entry of await readEntries(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + const metadata = await inspectEntry(entryPath); + const relative = path.relative(baseDirectory, entryPath) || entry.name; + + if (metadata.isSymbolicLink()) throw new Error(`Unsupported dist symbolic link: ${relative}`); + if (metadata.isDirectory()) { + total += await directoryBytes(entryPath, { baseDirectory, readEntries, inspectEntry }); + } else if (metadata.isFile()) { + if (!Number.isSafeInteger(metadata.size) || metadata.size < 0) throw new Error(`Invalid dist file size: ${relative}`); + total += metadata.size; + if (!Number.isSafeInteger(total)) throw new Error('dist byte count exceeds the safe integer range'); + } else { + throw new Error(`Unsupported dist special file: ${relative}`); + } + } + + return total; +} diff --git a/plugins/webhook-lab/scripts/verify-dist.mjs b/plugins/webhook-lab/scripts/verify-dist.mjs new file mode 100644 index 00000000..696c1ee2 --- /dev/null +++ b/plugins/webhook-lab/scripts/verify-dist.mjs @@ -0,0 +1,36 @@ +import { access, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { assertWithinDistSizeLimit, directoryBytes } from './dist-size.mjs'; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const dist = path.join(root, 'dist'); + +for (const file of ['plugin.json', 'main/index.html', 'preload/index.cjs', 'core/server.cjs', 'logo.svg']) { + await access(path.join(dist, file)); +} + +const manifest = JSON.parse(await readFile(path.join(dist, 'plugin.json'))); +if (manifest.development) throw new Error('development leaked'); +if (manifest.main !== 'main/index.html' || manifest.preload !== 'preload/index.cjs' || manifest.logo !== 'logo.svg') { + throw new Error('dist manifest does not use self-contained release entries'); +} + +const expected = ['hmac', 'preview_payload']; +if (JSON.stringify(Object.keys(manifest.tools || {}).sort()) !== JSON.stringify(expected)) { + throw new Error('MCP tool declarations are missing or unexpected'); +} +for (const name of expected) { + const schema = manifest.tools[name]?.inputSchema; + if (!schema || schema.type !== 'object' || schema.additionalProperties !== false) { + throw new Error(`MCP tool ${name} is not strict`); + } +} + +const source = await readFile(path.join(root, 'src', 'preload', 'index.cjs'), 'utf8'); +const built = await readFile(path.join(dist, 'preload', 'index.cjs'), 'utf8'); +if (source !== built) throw new Error('dist preload is stale'); + +const bytes = await directoryBytes(dist); +assertWithinDistSizeLimit(bytes); +console.log(`webhook-lab dist verified: ${bytes} bytes (14.5 MB safety limit)`); diff --git a/plugins/webhook-lab/src/core/server.cjs b/plugins/webhook-lab/src/core/server.cjs new file mode 100644 index 00000000..b9ef8e4c --- /dev/null +++ b/plugins/webhook-lab/src/core/server.cjs @@ -0,0 +1,119 @@ +const http = require('http'); +const crypto = require('crypto'); +const MAX_BODY = 2 * 1024 * 1024, MAX_PREVIEW = 64 * 1024, MAX_HISTORY = 200, MAX_HISTORY_BYTES = 4 * 1024 * 1024, MAX_HEADERS = 100, MAX_REQUESTS = 10000, MAX_CONNECTIONS = 64; +const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']); +const MAX_JSON_DEPTH = 48, MAX_JSON_NODES = 2000; + +function routeToken() { return crypto.randomBytes(18).toString('base64url'); } +function hostFor() { return '127.0.0.1'; } +function hmac(body, secret, algorithm = 'sha256') { + if (!['sha256', 'sha512'].includes(algorithm)) throw Error('不支持的 HMAC 算法'); + if (Buffer.byteLength(String(body)) > MAX_BODY || Buffer.byteLength(String(secret)) > 8192) throw Error('HMAC 输入过大'); + return crypto.createHmac(algorithm, String(secret)).update(String(body)).digest('hex'); +} +function curlFor(url, platform = process.platform) { + if (!/^http:\/\/127\.0\.0\.1:\d+\/[^']*$/.test(url)) throw Error('仅允许不包含单引号的本地监听 URL'); + if (platform === 'win32') return `curl.exe -X POST '${url}' -H 'content-type: application/json' -d '{\"event\":\"test\"}'`; + return `curl -X POST '${url}' -H 'content-type: application/json' -d '{"event":"test"}'`; +} +function preview(body, contentType) { + const clipped = body.subarray(0, MAX_PREVIEW), text = clipped.toString('utf8'); + let value = text, kind = 'text'; + if (/application\/json/i.test(contentType)) { + try { + const parsed = JSON.parse(text); + if (!withinJsonLimit(parsed)) return { kind: 'text', value: '[preview omitted: JSON nesting limit exceeded]', truncated: true }; + value = parsed; kind = 'json'; + } catch {} + } + else if (/application\/x-www-form-urlencoded/i.test(contentType)) { value = Object.fromEntries(new URLSearchParams(text)); kind = 'form'; } + return { kind, value, truncated: body.length > MAX_PREVIEW }; +} +function withinJsonLimit(root) { + const queue = [[root, 0]]; let nodes = 0; + while (queue.length) { + const [value, depth] = queue.pop(); + if (++nodes > MAX_JSON_NODES || depth > MAX_JSON_DEPTH) return false; + if (value && typeof value === 'object') for (const next of Object.values(value)) queue.push([next, depth + 1]); + } + return true; +} + +class WebhookServer { + constructor(options = {}) { + this.options = { port: 0, token: routeToken(), requestTimeoutMs: 15000, maxRequests: MAX_REQUESTS, maxConnections: MAX_CONNECTIONS, ...options }; + this.events = []; this.historyBytes = 0; this.requestCount = 0; this.sockets = new Set(); + this.server = null; this.starting = null; this.stopping = null; + } + async start() { + if (this.stopping) await this.stopping; + if (this.starting) return this.starting; + if (this.server?.listening) return this.address(); + // Do this before assigning starting: stop waits on starting and would otherwise self-deadlock. + if (this.server) await this.stop(); + this.starting = (async () => { + const server = http.createServer((request, response) => this._request(request, response)); + server.maxHeadersCount = MAX_HEADERS; + server.requestTimeout = Math.min(60000, Math.max(1000, Number(this.options.requestTimeoutMs) || 15000)); + server.headersTimeout = server.requestTimeout; + server.keepAliveTimeout = 5000; + server.on('connection', (socket) => { + if (this.sockets.size >= this.options.maxConnections) return socket.destroy(); + this.sockets.add(socket); + socket.on('close', () => this.sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(this.options.port, hostFor(), () => { server.off('error', reject); resolve(); }); + }); + this.server = server; + return this.address(); + })(); + try { return await this.starting; } finally { this.starting = null; } + } + address() { + const address = this.server?.address(); + return address && typeof address === 'object' ? { host: address.address, port: address.port, path: `/${this.options.token}` } : null; + } + _reply(response, status, body) { + response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'x-content-type-options': 'nosniff', 'cache-control': 'no-store', 'content-length': Buffer.byteLength(body) }); + response.end(body); + } + _request(request, response) { + if (this.requestCount++ >= this.options.maxRequests) return this._reply(response, 429, '{"error":"request limit reached"}'); + if (!METHODS.has(request.method)) return this._reply(response, 405, '{"error":"method not allowed"}'); + const announced = Number(request.headers['content-length']); + if (!Number.isFinite(announced) && request.headers['content-length']) return this._reply(response, 400, '{"error":"invalid content length"}'); + if (announced < 0 || announced > MAX_BODY) return this._reply(response, 413, '{"error":"body too large"}'); + if (request.url !== `/${this.options.token}`) return this._reply(response, 404, '{"error":"unknown route"}'); + let bytes = 0, done = false; const chunks = []; + const finish = (status, body) => { if (!done) { done = true; this._reply(response, status, body); } }; + request.on('aborted', () => finish(400, '{"error":"request aborted"}')); + request.on('data', (chunk) => { bytes += chunk.length; if (bytes > MAX_BODY) { finish(413, '{"error":"body too large"}'); request.destroy(); } else chunks.push(chunk); }); + request.on('end', () => { + if (done) return; + const body = Buffer.concat(chunks); + const event = { id: crypto.randomUUID?.() || crypto.randomBytes(8).toString('hex'), at: new Date().toISOString(), method: request.method, headers: request.headers, bytes, body: preview(body, request.headers['content-type'] || '') }; + this.events.unshift(event); this.historyBytes += Math.min(bytes, MAX_PREVIEW); + while (this.events.length > MAX_HISTORY || this.historyBytes > MAX_HISTORY_BYTES) { const old = this.events.pop(); this.historyBytes -= Math.min(old.bytes, MAX_PREVIEW); } + finish(202, JSON.stringify({ accepted: true, id: event.id })); + }); + request.on('error', () => finish(400, '{"error":"request error"}')); + } + async stop() { + if (this.stopping) return this.stopping; + this.stopping = (async () => { + if (this.starting) await this.starting.catch(() => {}); + const server = this.server; this.server = null; + if (!server) return; + const oldSockets = [...this.sockets]; + this.sockets.clear(); + await new Promise((resolve) => { for (const socket of oldSockets) socket.destroy(); if (!server.listening) return resolve(); server.close(resolve); }); + })(); + try { return await this.stopping; } + finally { this.stopping = null; } + } + async restart(options = {}) { await this.stop(); this.options = { ...this.options, ...options }; return this.start(); } + clear() { this.events = []; this.historyBytes = 0; } +} +module.exports = { WebhookServer, MAX_BODY, MAX_HISTORY, MAX_HISTORY_BYTES, MAX_PREVIEW, MAX_REQUESTS, MAX_CONNECTIONS, hostFor, hmac, curlFor, preview, routeToken }; diff --git a/plugins/webhook-lab/src/main/app.js b/plugins/webhook-lab/src/main/app.js new file mode 100644 index 00000000..50f0f1c9 --- /dev/null +++ b/plugins/webhook-lab/src/main/app.js @@ -0,0 +1 @@ +(function(){const $=s=>document.querySelector(s),events=$('#events');let timer,url='';const visibleTokens=[['[preview omitted: JSON nesting limit exceeded]','【JSON 预览已省略:嵌套层级超限】'],['[preview omitted: redacted output exceeds 64 KiB]','【预览已省略:脱敏后的输出超过 64 KiB】'],['[redacted-private-key]','【私钥已脱敏】'],['[redacted]','【已脱敏】'],['[reserved-key]','【保留字段】'],['[truncated]','【已截断】']];function node(tag,text){const x=document.createElement(tag);x.textContent=String(text);return x;}function visibleError(error,fallback){const message=String(error?.message||'');return /[\u3400-\u9fff]/.test(message)?message:fallback;}function humanize(value){let text=String(value??'');for(const [machine,human]of visibleTokens)text=text.split(machine).join(human);return text;}function bodyLabel(body){if(body?.kind==='json'){try{return humanize(JSON.stringify(body.value)).slice(0,280);}catch{return '【JSON 负载无法显示】';}}return humanize(body?.value).slice(0,280);}function draw(){const list=window.webhookLab?.events?.()||[];events.replaceChildren();if(!list.length){events.append(node('article','正在等待携带签名的请求…'));return;}for(const e of list){const row=node('article','');row.append(node('b',e.method),document.createTextNode(` · ${e.bytes} B · ${e.at}`),document.createElement('br'),node('code',bodyLabel(e.body)));events.append(row);}}function stopTimer(){if(timer){clearInterval(timer);timer=null;}}async function stop(){await window.webhookLab?.stop?.();stopTimer();url='';$('#secret').value='';$('#digest').textContent='';$('#url').textContent='监听器已停止';draw();}$('#start').onclick=async()=>{try{const x=await window.webhookLab?.start?.({});url=x?`http://${x.host}:${x.port}${x.path}`:'';$('#url').textContent=url||'ZTools 能力桥不可用';draw();stopTimer();timer=setInterval(draw,700);}catch(e){$('#url').textContent=visibleError(e,'启动监听失败,请稍后重试。');}};$('#stop').onclick=stop;$('#copy-url').onclick=()=>url&&window.webhookLab?.copyText?.(url);$('#copy-curl').onclick=()=>url&&window.webhookLab?.copyText?.(window.webhookLab?.curl?.(url)||'');$('#sign').onclick=()=>{try{$('#digest').textContent=window.webhookLab?.hmac?.($('#payload').value,$('#secret').value)||'ZTools 能力桥不可用';}catch(e){$('#digest').textContent=visibleError(e,'签名计算失败,请检查输入。');}};window.addEventListener('pagehide',stop);}()); diff --git a/plugins/webhook-lab/src/main/index.html b/plugins/webhook-lab/src/main/index.html new file mode 100644 index 00000000..2968bf46 --- /dev/null +++ b/plugins/webhook-lab/src/main/index.html @@ -0,0 +1 @@ +Webhook 实验室
本地事件收件箱

Webhook 实验室

监听器已停止

仅监听本机回环地址(127.0.0.1);v0.1 不支持局域网绑定。

请求记录

正在等待携带签名的请求…

HMAC 签名试算

diff --git a/plugins/webhook-lab/src/main/style.css b/plugins/webhook-lab/src/main/style.css new file mode 100644 index 00000000..7df34bf8 --- /dev/null +++ b/plugins/webhook-lab/src/main/style.css @@ -0,0 +1 @@ +:root{background:#180c2b;color:#f9eaff;font-family:ui-sans-serif,system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 84% 4%,#5d1c59,transparent 28%),#180c2b}main{max-width:980px;margin:auto;padding:clamp(22px,6vw,72px)}header{display:flex;align-items:center;gap:14px;border-bottom:1px solid #5a2d72;padding-bottom:23px}header button{margin-left:auto}.pulse{width:19px;height:19px;border-radius:50%;background:#ff5b9f;box-shadow:0 0 0 8px #ff5b9f22}h1,h2{margin:4px 0}h1,h2,small,.endpoint p{overflow-wrap:break-word;text-wrap:pretty}.endpoint{padding:28px 0}.endpoint code{font:18px ui-monospace,monospace;color:#aeefff;overflow-wrap:anywhere}.endpoint p{color:#d0a7d8}.events{display:grid;gap:10px;min-height:120px}.events article{border-left:4px solid #aeefff;background:#28153c;padding:15px;font:13px ui-monospace,monospace;overflow-wrap:anywhere}.sign{margin-top:38px;display:grid;gap:10px;max-width:620px}input,textarea{min-width:0;background:#10071d;border:1px solid #704785;color:#fff;padding:12px;font:14px ui-monospace,monospace}textarea{min-height:100px}button{border:0;border-radius:8px;background:#aeefff;color:#220e31;padding:11px 15px;font-weight:800;cursor:pointer;width:max-content;max-width:100%;white-space:normal;overflow-wrap:break-word}output{font:13px ui-monospace,monospace;color:#ffb8d5;overflow-wrap:anywhere}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid white;outline-offset:3px}@media(max-width:560px){header{align-items:flex-start;flex-wrap:wrap}header button{margin-left:0}}@media(prefers-reduced-motion:reduce){*{animation:none!important}} diff --git a/plugins/webhook-lab/src/preload/index.cjs b/plugins/webhook-lab/src/preload/index.cjs new file mode 100644 index 00000000..fb09a59f --- /dev/null +++ b/plugins/webhook-lab/src/preload/index.cjs @@ -0,0 +1,211 @@ +/* ZTools preload: CommonJS, intentionally readable and dependency-light. */ +'use strict'; + +const { WebhookServer, hmac, curlFor, preview } = require('../core/server.cjs'); +let owner = null; +const TOOL_NAMES = Object.freeze({ hmac: 'hmac', preview: 'preview_payload' }); +const MCP_BODY_BYTES = 256 * 1024; +const MCP_SECRET_BYTES = 8192; +const MCP_CONTENT_TYPE_BYTES = 256; +const MCP_RESPONSE_BYTES = 64 * 1024; +const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); +const registeredHosts = new WeakSet(); +const lifecycleHosts = new WeakSet(); +const SENSITIVE = /authorization|cookie|token|secret|api[-_]?key|password|signature|credential|(^|[-_])sig(nature)?($|[-_])/i; +const KNOWN_TOKEN = /(?:github_pat_[A-Za-z0-9_]{10,}|gh[pousr]_[A-Za-z0-9]{16,}|sk-(?:proj-)?[A-Za-z0-9_-]{16,}|(?:AKIA|ASIA)[A-Z0-9]{16})/g; +const PRIVATE_KEY = /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?(?:-----END \1-----|$)/g; +const RESERVED_OUTPUT_KEY = '[reserved-key]'; + +function invalid(message) { + return Object.assign(new TypeError(message), { code: 'INVALID_TOOL_INPUT' }); +} + +function validateObject(value, allowed, label = '工具输入') { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalid(`${label}必须是对象。`); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) throw invalid(`${label}使用了不支持的原型。`); + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || FORBIDDEN_KEYS.has(key) || !allowed.has(key)) throw invalid(`${label}包含不支持的字段。`); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !Object.hasOwn(descriptor, 'value')) throw invalid(`${label}只能包含数据字段。`); + } +} + +function utf8(value, field, maxBytes, required = true) { + if (value === undefined && !required) return ''; + if (typeof value !== 'string') throw invalid(`${field} 必须是字符串。`); + if (Buffer.byteLength(value, 'utf8') > maxBytes) throw invalid(`${field} 超过 UTF-8 字节限制。`); + return value; +} + +function own(value, key) { + return Object.hasOwn(value, key) ? value[key] : undefined; +} + +function validateHmacInput(input) { + validateObject(input, new Set(['body', 'secret', 'algorithm'])); + const algorithmValue = own(input, 'algorithm'); + const algorithm = algorithmValue === undefined ? 'sha256' : algorithmValue; + if (algorithm !== 'sha256' && algorithm !== 'sha512') throw invalid('algorithm 必须是 sha256 或 sha512。'); + return { + body: utf8(own(input, 'body'), 'body', MCP_BODY_BYTES), + secret: utf8(own(input, 'secret'), 'secret', MCP_SECRET_BYTES), + algorithm + }; +} + +function validatePreviewInput(input) { + validateObject(input, new Set(['body', 'contentType'])); + return { + body: utf8(own(input, 'body'), 'body', MCP_BODY_BYTES), + contentType: utf8(own(input, 'contentType'), 'contentType', MCP_CONTENT_TYPE_BYTES, false) + }; +} + +function safeOptions(input = {}) { + const port = Number(input.port), options = {}; + if (Number.isInteger(port) && port >= 0 && port <= 65535) options.port = port; + return options; +} +function assign(target, key, value) { Object.defineProperty(target, key, { value, enumerable: true, configurable: true, writable: true }); } +function redactString(value) { + return value + .replace(PRIVATE_KEY, '[redacted-private-key]') + .replace(KNOWN_TOKEN, '[redacted]') + .replace(/\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{6,}/gi, '[redacted]') + .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[redacted]') + .replace(/(^|[^A-Za-z0-9])(token|secret|password|api[_ -]?key|authorization|signature|credential|aws[_ -]?secret[_ -]?access[_ -]?key)\s*([=:])\s*[^\s&#,;]+/gi, '$1$2$3[redacted]') + .replace(/([?&](?:token|secret|password|api[_-]?key|authorization|signature|credential|aws[_-]?secret[_-]?access[_-]?key)=)[^&#\s]*/gi, '$1[redacted]') + .slice(0, 4096); +} +function sanitizedOutputKey(key) { + const sanitized = redactString(key); + return FORBIDDEN_KEYS.has(sanitized) ? RESERVED_OUTPUT_KEY : sanitized; +} +function allocateOutputKey(key, allocated) { + const base = sanitizedOutputKey(key); + let candidate = base; + for (let suffix = 2; allocated.has(candidate); suffix += 1) candidate = `${base}#${suffix}`; + allocated.add(candidate); + return candidate; +} +function redact(value) { + const root = { value: null }, queue = [{ source: value, target: root, key: 'value', depth: 0, sensitive: false }]; + let nodes = 0; + while (queue.length) { + const item = queue.pop(); + const inheritedSensitive = item.sensitive; + if (inheritedSensitive) { assign(item.target, item.key, '[redacted]'); continue; } + if (item.source === null || typeof item.source !== 'object') { assign(item.target, item.key, typeof item.source === 'string' ? redactString(item.source) : item.source); continue; } + if (++nodes > 2000 || item.depth > 48) { assign(item.target, item.key, '[truncated]'); continue; } + const nameDescriptor = Object.getOwnPropertyDescriptor(item.source, 'name'); + const sourceName = nameDescriptor && Object.hasOwn(nameDescriptor, 'value') ? nameDescriptor.value : undefined; + const namedSecret = typeof sourceName === 'string' && SENSITIVE.test(sourceName); + const copy = Array.isArray(item.source) ? [] : Object.create(null); + assign(item.target, item.key, copy); + const allocated = new Set(); + for (const key of Object.keys(item.source)) { + const descriptor = Object.getOwnPropertyDescriptor(item.source, key); + const outputKey = allocateOutputKey(key, allocated); + if (!descriptor || !Object.hasOwn(descriptor, 'value')) { + assign(copy, outputKey, '[redacted]'); + continue; + } + queue.push({ source: descriptor.value, target: copy, key: outputKey, depth: item.depth + 1, sensitive: namedSecret || SENSITIVE.test(key) }); + } + } + return root.value; +} + +function encodedBytes(value) { + return Buffer.byteLength(JSON.stringify(value), 'utf8'); +} + +function hmacForMcp(input) { + const value = validateHmacInput(input); + return { + algorithm: value.algorithm, + digest: hmac(value.body, value.secret, value.algorithm), + bodyBytes: Buffer.byteLength(value.body, 'utf8') + }; +} + +function previewForMcp(input) { + const value = validatePreviewInput(input); + const body = Buffer.from(value.body, 'utf8'); + const result = redact(preview(body, value.contentType)); + const response = { + kind: result && ['json', 'form', 'text'].includes(result.kind) ? result.kind : 'text', + value: result?.value, + truncated: Boolean(result?.truncated), + bodyBytes: body.length, + outputLimitBytes: MCP_RESPONSE_BYTES + }; + if (encodedBytes(response) <= MCP_RESPONSE_BYTES) return response; + return { + kind: response.kind, + value: '[preview omitted: redacted output exceeds 64 KiB]', + truncated: true, + bodyBytes: body.length, + outputLimitBytes: MCP_RESPONSE_BYTES + }; +} + +function safeEvents() { return redact((owner?.events || []).slice(0, 200)); } +function registerLifecycle(ztools) { + if (!ztools || (typeof ztools !== 'object' && typeof ztools !== 'function') || lifecycleHosts.has(ztools)) return false; + const stop = async () => { const current = owner; owner = null; current?.clear(); await current?.stop(); }; + try { + if (typeof ztools.onPluginOut === 'function') ztools.onPluginOut(stop); + else if (typeof ztools.onPluginExit === 'function') ztools.onPluginExit(stop); + } catch {} + lifecycleHosts.add(ztools); + return true; +} +function registerTools(target) { + const ztools = target?.ztools; + if (!ztools || typeof ztools.registerTool !== 'function' || registeredHosts.has(ztools)) return Object.freeze([]); + const registered = []; + for (const [name, handler] of [[TOOL_NAMES.hmac, hmacForMcp], [TOOL_NAMES.preview, previewForMcp]]) { + try { ztools.registerTool.call(ztools, name, handler); registered.push(name); } catch {} + } + registeredHosts.add(ztools); + return Object.freeze(registered); +} +function bridge(ztools) { + registerLifecycle(ztools); + return Object.freeze({ + start: async (options) => { const next = safeOptions(options); if (!owner) owner = new WebhookServer(next); else if (Object.hasOwn(next, 'port') && owner.options.port !== next.port) await owner.restart(next); return owner.start(); }, + stop: async () => { const current = owner; owner = null; await current?.stop(); }, + events: safeEvents, + hmac: (body, secret, algorithm) => hmac(body, secret, algorithm), + curl: (url) => curlFor(url, process.platform), + copyText: (text) => ztools?.copyText?.(String(text)) + }); +} +function attachWebhookLab(target) { + if (!target || (typeof target !== 'object' && typeof target !== 'function')) throw new TypeError('需要一个类似 window 的挂载目标。'); + const value = bridge(target.ztools || {}); + Object.defineProperty(target, 'webhookLab', { value, enumerable: true, configurable: true, writable: true }); + registerTools(target); + return value; +} +if (typeof globalThis !== 'undefined') attachWebhookLab(globalThis); +module.exports = { + TOOL_NAMES, + MCP_BODY_BYTES, + MCP_SECRET_BYTES, + MCP_CONTENT_TYPE_BYTES, + MCP_RESPONSE_BYTES, + validateObject, + validateHmacInput, + validatePreviewInput, + redact, + hmacForMcp, + previewForMcp, + registerTools, + attachWebhookLab, + bridge, + __testOwner: () => owner, + __testSetOwner: (next) => { owner = next; } +}; diff --git a/plugins/webhook-lab/test/dist-size.test.mjs b/plugins/webhook-lab/test/dist-size.test.mjs new file mode 100644 index 00000000..d3353342 --- /dev/null +++ b/plugins/webhook-lab/test/dist-size.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { + DIST_SIZE_LIMIT_BYTES, + assertWithinDistSizeLimit, + directoryBytes +} from '../scripts/dist-size.mjs'; + +function metadata(kind, size = 0) { + return { + size, + isDirectory: () => kind === 'directory', + isFile: () => kind === 'file', + isSymbolicLink: () => kind === 'symlink' + }; +} + +function virtualDirectory(root, tree, entries) { + return directoryBytes(root, { + readEntries: async (directory) => (tree.get(directory) || []).map((name) => ({ name })), + inspectEntry: async (entryPath) => entries.get(entryPath), + baseDirectory: root + }); +} + +test('size gate allows exactly 14,500,000 bytes', () => { + assert.equal(assertWithinDistSizeLimit(DIST_SIZE_LIMIT_BYTES), 14_500_000); +}); + +test('size gate rejects 14,500,001 bytes without creating a large file', () => { + assert.throws(() => assertWithinDistSizeLimit(DIST_SIZE_LIMIT_BYTES + 1), /14\.5 MB safety limit/); +}); + +test('directory byte count includes nested regular files recursively', async () => { + const root = path.resolve('/virtual/webhook-dist'); + const nested = path.join(root, 'nested'); + const tree = new Map([[root, ['root.js', 'nested']], [nested, ['child.css']]]); + const entries = new Map([ + [path.join(root, 'root.js'), metadata('file', 17)], + [nested, metadata('directory')], + [path.join(nested, 'child.css'), metadata('file', 23)] + ]); + assert.equal(await virtualDirectory(root, tree, entries), 40); +}); + +test('directory byte count rejects symbolic links and other special files', async () => { + for (const [name, kind, pattern] of [ + ['linked.js', 'symlink', /symbolic link/], + ['socket', 'special', /special file/] + ]) { + const root = path.resolve(`/virtual/webhook-${kind}`); + const entryPath = path.join(root, name); + await assert.rejects( + virtualDirectory(root, new Map([[root, [name]]]), new Map([[entryPath, metadata(kind)]])), + pattern + ); + } +}); diff --git a/plugins/webhook-lab/test/host-contract.test.cjs b/plugins/webhook-lab/test/host-contract.test.cjs new file mode 100644 index 00000000..b11cc0ad --- /dev/null +++ b/plugins/webhook-lab/test/host-contract.test.cjs @@ -0,0 +1,42 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); + +function resolveLocalEntry(relative) { + assert.equal(path.isAbsolute(relative), false); + const resolved = path.resolve(root, relative); + assert.ok(resolved.startsWith(`${root}${path.sep}`)); + assert.ok(fs.statSync(resolved).isFile(), `${relative} must be a loadable file`); + return resolved; +} + +test('host manifest directly loads source entries while dist remains a self-contained CI artifact', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(root, 'plugin.json'), 'utf8')); + assert.equal(Object.hasOwn(manifest, 'development'), false); + assert.deepEqual( + { main: manifest.main, preload: manifest.preload, logo: manifest.logo }, + { main: 'src/main/index.html', preload: 'src/preload/index.cjs', logo: 'logo.svg' } + ); + + const main = resolveLocalEntry(manifest.main); + resolveLocalEntry(manifest.preload); + resolveLocalEntry(manifest.logo); + const html = fs.readFileSync(main, 'utf8'); + assert.match(html, //); + assert.match(html, /Webhook 实验室/); + assert.doesNotMatch(html, />Open listener<|>Stop<|>Copy URL<|>Signal stream { + const handlers = new Map(); + const target = { ztools: { registerTool(name, handler) { handlers.set(name, handler); } } }; + preload.attachWebhookLab(target); + assert.deepEqual([...handlers.keys()].sort(), Object.keys(manifest.tools).sort()); + assert.deepEqual(Object.keys(manifest.tools).sort(), ['hmac', 'preview_payload']); + assert.ok([...handlers.values()].every((handler) => typeof handler === 'function')); + const result = await handlers.get('hmac')({ body: 'hello', secret: 'world' }); + assert.equal(result.digest, crypto.createHmac('sha256', 'world').update('hello').digest('hex')); + assert.equal(typeof target.webhookLab.start, 'function'); +}); + +test('top-level preload registration is synchronous', () => { + const file = require.resolve('../src/preload/index.cjs'); + const script = `const names=[];globalThis.ztools={registerTool(name,handler){if(typeof handler!=='function')throw Error('bad handler');names.push(name)}};require(${JSON.stringify(file)});process.stdout.write(JSON.stringify(names.sort()))`; + const child = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8' }); + assert.equal(child.status, 0, child.stderr); + assert.deepEqual(JSON.parse(child.stdout), ['hmac', 'preview_payload']); +}); + +test('one registration failure neither blocks the other tool nor breaks the human UI', async () => { + const handlers = new Map(); + const target = { + ztools: { + registerTool(name, handler) { + if (name === 'hmac') throw new Error('simulated host failure'); + handlers.set(name, handler); + } + } + }; + assert.doesNotThrow(() => preload.attachWebhookLab(target)); + assert.equal(typeof target.webhookLab.events, 'function'); + assert.deepEqual([...handlers.keys()], ['preview_payload']); + const result = await handlers.get('preview_payload')({ body: 'ok', contentType: 'text/plain' }); + assert.equal(result.value, 'ok'); +}); + +test('older hosts degrade to the unchanged human bridge', () => { + const copied = []; + const target = { ztools: { copyText(value) { copied.push(value); } } }; + assert.doesNotThrow(() => preload.attachWebhookLab(target)); + target.webhookLab.copyText('human'); + assert.deepEqual(copied, ['human']); + assert.equal(typeof target.webhookLab.start, 'function'); +}); + +test('HMAC tool is bounded, strict and never echoes body or secret', () => { + const result = preload.hmacForMcp({ body: 'payload', secret: 'top-secret', algorithm: 'sha512' }); + assert.equal(result.algorithm, 'sha512'); + assert.equal(result.digest.length, 128); + assert.equal(result.bodyBytes, 7); + assert.doesNotMatch(JSON.stringify(result), /payload|top-secret/); + assert.throws(() => preload.hmacForMcp({ body: 'x', secret: 's', algorithm: 'md5' }), { code: 'INVALID_TOOL_INPUT' }); + assert.throws(() => preload.hmacForMcp({ body: 'x'.repeat(preload.MCP_BODY_BYTES + 1), secret: 's' }), { code: 'INVALID_TOOL_INPUT' }); + assert.throws(() => preload.hmacForMcp({ body: '汉'.repeat(Math.floor(preload.MCP_BODY_BYTES / 3) + 1), secret: 's' }), { code: 'INVALID_TOOL_INPUT' }); + assert.throws(() => preload.hmacForMcp({ body: 'x', secret: '密'.repeat(Math.floor(preload.MCP_SECRET_BYTES / 3) + 1) }), { code: 'INVALID_TOOL_INPUT' }); +}); + +test('payload preview reuses parser and redaction without credential output', () => { + const body = JSON.stringify({ ok: true, authorization: 'Bearer abcdefghijklmnop', nested: { apiKey: 'never-return-me' }, url: 'https://example.test/?token=hidden' }); + const result = preload.previewForMcp({ body, contentType: 'application/json' }); + const serialized = JSON.stringify(result); + assert.equal(result.kind, 'json'); + assert.match(serialized, /"ok":true/); + assert.match(serialized, /\[redacted\]/); + assert.doesNotMatch(serialized, /abcdefghijklmnop|never-return-me|token=hidden/); + assert.ok(Buffer.byteLength(serialized, 'utf8') <= preload.MCP_RESPONSE_BYTES); +}); + +test('registered payload handler sanitizes dynamic keys, reserved names and deterministic collisions', async () => { + const handlers = new Map(); + preload.attachWebhookLab({ ztools: { registerTool(name, handler) { handlers.set(name, handler); } } }); + const githubA = `ghp_${'A'.repeat(36)}`; + const githubB = `ghp_${'B'.repeat(36)}`; + const labeledCredential = 'metadata_token=must-not-leak'; + const body = JSON.stringify(Object.fromEntries([ + ['visible', { ok: true }], + [githubA, 'first'], + [githubB, 'second'], + ['[redacted]', 'literal'], + [labeledCredential, 'sensitive-value'], + ['__proto__', 'proto-value'], + ['constructor', 'constructor-value'], + ['prototype', 'prototype-value'] + ])); + + const result = await handlers.get('preview_payload')({ body, contentType: 'application/json' }); + const output = result.value; + const serialized = JSON.stringify(result); + assert.equal(result.kind, 'json'); + assert.equal(Object.getPrototypeOf(output), null); + assert.equal(Object.getPrototypeOf(output.visible), null); + assert.deepEqual(output.visible, Object.assign(Object.create(null), { ok: true })); + assert.equal(output['[redacted]'], 'first'); + assert.equal(output['[redacted]#2'], 'second'); + assert.equal(output['[redacted]#3'], 'literal'); + assert.equal(output['metadata_token=[redacted]'], '[redacted]'); + assert.equal(output['[reserved-key]'], 'proto-value'); + assert.equal(output['[reserved-key]#2'], 'constructor-value'); + assert.equal(output['[reserved-key]#3'], 'prototype-value'); + assert.equal(Object.hasOwn(output, '__proto__'), false); + assert.equal(Object.hasOwn(output, 'constructor'), false); + assert.equal(Object.hasOwn(output, 'prototype'), false); + assert.doesNotMatch(serialized, new RegExp(`${githubA}|${githubB}|must-not-leak|sensitive-value`)); + assert.ok(Buffer.byteLength(serialized, 'utf8') <= preload.MCP_RESPONSE_BYTES); +}); + +test('text and nested JSON previews redact known token prefixes and PEM private keys', () => { + const secrets = { + githubFine: `github_pat_${'A'.repeat(40)}`, + githubClassic: `ghp_${'B'.repeat(36)}`, + openAiProject: `sk-proj-${'C'.repeat(32)}`, + openAiLegacy: `sk-${'D'.repeat(32)}`, + awsAccess: `AKIA${'E'.repeat(16)}`, + awsSecret: 'aws_secret_access_key=abcdefghijklmnopqrstuvwxyz0123456789ABCD', + pem: `-----BEGIN PRIVATE KEY-----\n${'F'.repeat(64)}\n-----END PRIVATE KEY-----` + }; + const rawValues = Object.values(secrets); + const textResult = preload.previewForMcp({ body: `visible\n${rawValues.join('\n')}`, contentType: 'text/plain' }); + const jsonResult = preload.previewForMcp({ body: JSON.stringify({ visible: true, nested: { values: rawValues } }), contentType: 'application/json' }); + for (const serialized of [JSON.stringify(textResult), JSON.stringify(jsonResult)]) { + assert.match(serialized, /\[redacted/); + for (const secret of rawValues) assert.equal(serialized.includes(secret), false); + assert.doesNotMatch(serialized, /github_pat_|ghp_|sk-proj-|\bsk-[A-Z]|AKIA[A-Z0-9]{16}|BEGIN PRIVATE KEY|abcdefghijklmnopqrstuvwxyz0123456789ABCD/); + } + assert.match(JSON.stringify(textResult), /\[redacted-private-key\]/); + assert.match(JSON.stringify(jsonResult), /"visible":true/); +}); + +test('payload preview enforces its 64 KiB serialized response boundary', () => { + const large = {}; + let body = '{}'; + for (let index = 0; ; index += 1) { + large[`field_${index}`] = 'v'.repeat(64); + const next = JSON.stringify(large); + if (Buffer.byteLength(next) > 65_520) { delete large[`field_${index}`]; break; } + body = next; + } + assert.ok(Buffer.byteLength(body) > 65_000); + const result = preload.previewForMcp({ body, contentType: 'application/json' }); + assert.equal(result.truncated, true); + assert.equal(result.value, '[preview omitted: redacted output exceeds 64 KiB]'); + assert.doesNotMatch(result.value, /[\u3400-\u9fff]/); + assert.ok(Buffer.byteLength(JSON.stringify(result), 'utf8') <= preload.MCP_RESPONSE_BYTES); +}); + +test('strict runtime validation rejects unknown fields, hostile prototypes and accessors', () => { + assert.throws(() => preload.hmacForMcp({ body: 'x', secret: 's', start: true }), { code: 'INVALID_TOOL_INPUT' }); + assert.throws(() => preload.previewForMcp({ body: 'x', listener: 'start' }), { code: 'INVALID_TOOL_INPUT' }); + assert.throws(() => preload.previewForMcp(JSON.parse('{"body":"x","__proto__":{}}')), { code: 'INVALID_TOOL_INPUT' }); + assert.throws(() => preload.previewForMcp(Object.assign(Object.create({ inherited: true }), { body: 'x' })), { code: 'INVALID_TOOL_INPUT' }); + const accessor = {}; + Object.defineProperty(accessor, 'body', { enumerable: true, get() { throw new Error('must not execute'); } }); + assert.throws(() => preload.previewForMcp(accessor), { code: 'INVALID_TOOL_INPUT' }); + const symbol = { body: 'x' }; + symbol[Symbol('hidden')] = true; + assert.throws(() => preload.previewForMcp(symbol), { code: 'INVALID_TOOL_INPUT' }); + const safeNullPrototype = Object.assign(Object.create(null), { body: 'x', secret: 's' }); + assert.equal(preload.hmacForMcp(safeNullPrototype).digest.length, 64); + assert.throws(() => preload.previewForMcp({ body: 'x', contentType: '汉'.repeat(86) }), { code: 'INVALID_TOOL_INPUT' }); + const previousBody = Object.getOwnPropertyDescriptor(Object.prototype, 'body'); + Object.defineProperty(Object.prototype, 'body', { value: 'inherited-body', configurable: true }); + try { + assert.throws(() => preload.hmacForMcp({ secret: 's' }), { code: 'INVALID_TOOL_INPUT' }); + } finally { + if (previousBody) Object.defineProperty(Object.prototype, 'body', previousBody); + else delete Object.prototype.body; + } +}); diff --git a/plugins/webhook-lab/test/server.test.cjs b/plugins/webhook-lab/test/server.test.cjs new file mode 100644 index 00000000..50709a9f --- /dev/null +++ b/plugins/webhook-lab/test/server.test.cjs @@ -0,0 +1,162 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const http = require('node:http'); +const fs = require('node:fs'); +const { WebhookServer, MAX_BODY, MAX_HISTORY, MAX_HISTORY_BYTES, MAX_PREVIEW, hostFor, hmac, curlFor, preview } = require('../src/core/server.cjs'); +const preload = require('../src/preload/index.cjs'); + +function invoke(server, { path = '/safe', body = '', method = 'POST', headers = {} } = {}) { + return new Promise((resolve) => { + const request = new EventEmitter(); + Object.assign(request, { url: path, method, headers, destroy() { this.destroyed = true; } }); + const response = { status: 0, body: '', writeHead(status) { this.status = status; }, end(bodyText) { this.body = bodyText; resolve(this); } }; + server._request(request, response); + process.nextTick(() => { if (body) request.emit('data', Buffer.from(body)); request.emit('end'); }); + }); +} + +test('bounded receiver and cleanup', async () => { + const server = new WebhookServer({ token: 'safe' }); + const missing = await invoke(server, { path: '/bad' }); assert.equal(missing.status, 404); assert.equal(missing.body, '{"error":"unknown route"}'); + assert.equal((await invoke(server, { body: '{"ok":1}', headers: { 'content-type': 'application/json' } })).status, 202); + let destroyed = false; + server.sockets.add({ destroy() { destroyed = true; } }); server.server = { listening: false }; + await Promise.all([server.stop(), server.stop()]); + assert.equal(destroyed, true); +}); +test('rejects method, content length and body', async () => { + const server = new WebhookServer({ token: 'safe' }); + assert.equal((await invoke(server, { method: 'TRACE' })).status, 405); + assert.equal((await invoke(server, { headers: { 'content-length': String(MAX_BODY + 1) } })).status, 413); + assert.equal((await invoke(server, { body: 'x'.repeat(MAX_BODY + 1) })).status, 413); +}); +test('concurrent starts share exactly one live server and stop closes it', async () => { + const original = http.createServer; let created = 0; + http.createServer = () => { + created++; + const fake = new EventEmitter(); fake.listening = false; fake.address = () => ({ address: '127.0.0.1', port: 45678 }); + fake.listen = (_port, _host, callback) => { fake.listening = true; queueMicrotask(callback); return fake; }; + fake.close = (callback) => { fake.listening = false; queueMicrotask(callback); }; + return fake; + }; + try { + const server = new WebhookServer({ token: 'safe' }); + const [left, right] = await Promise.all([server.start(), server.start()]); + assert.equal(left.port, right.port); assert.equal(created, 1); + const live = server.server; await server.stop(); + assert.equal(live.listening, false); assert.equal(server.server, null); + } finally { http.createServer = original; } +}); +test('stop/start is linearized across a delayed close', async () => { + const original = http.createServer; let created = 0, closeCalls = 0, finishClose; + http.createServer = () => { + created++; + const fake = new EventEmitter(); fake.listening = false; fake.address = () => ({ address: '127.0.0.1', port: 45000 + created }); + fake.listen = (_port, _host, callback) => { fake.listening = true; queueMicrotask(callback); return fake; }; + fake.close = (callback) => { closeCalls++; finishClose = () => { fake.listening = false; callback(); }; }; + return fake; + }; + try { + const server = new WebhookServer({ token: 'safe' }); await server.start(); + const oldSocket = { destroyed: false, destroy() { this.destroyed = true; } }; server.sockets.add(oldSocket); + const firstStop = server.stop(), secondStop = server.stop(), duringCloseStart = server.start(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(closeCalls, 1); assert.equal(created, 1); assert.equal(oldSocket.destroyed, true); + finishClose(); await Promise.all([firstStop, secondStop, duringCloseStart]); + assert.equal(created, 2); + const newSocket = { destroy() {} }; server.sockets.add(newSocket); + assert.ok(server.sockets.has(newSocket)); const lastStop = server.stop(); finishClose(); await lastStop; + } finally { http.createServer = original; } +}); +test('bridge start without a port does not restart its owner', async () => { + const bridge = preload.bridge({}); + let stopped = false; + const server = { listening: true, address: () => ({ address: '127.0.0.1', port: 45678 }), close: (done) => { stopped = true; done(); } }; + preload.__testSetOwner({ options: { port: 0, token: 'safe' }, server, start: WebhookServer.prototype.start, address: WebhookServer.prototype.address, stop: async () => { stopped = true; } }); + const first = await bridge.start({}); + const second = await bridge.start({}); + assert.equal(first.port, second.port); + assert.equal(preload.__testOwner().server, server); + await bridge.stop(); + assert.equal(stopped, true); +}); +test('history honors 64 KiB previews, 4 MiB budget and clear on exit', () => { + const server = new WebhookServer(); + server.events = Array.from({ length: MAX_HISTORY + 1 }, (_, index) => ({ bytes: MAX_PREVIEW, index })); + server.historyBytes = server.events.length * MAX_PREVIEW; + while (server.events.length > MAX_HISTORY || server.historyBytes > MAX_HISTORY_BYTES) { const old = server.events.pop(); server.historyBytes -= Math.min(old.bytes, MAX_PREVIEW); } + assert.ok(server.events.length <= MAX_HISTORY); + assert.ok(server.historyBytes <= MAX_HISTORY_BYTES); + server.clear(); + assert.deepEqual(server.events, []); assert.equal(server.historyBytes, 0); +}); +test('safe events redact header name/value and signature credentials', () => { + preload.__testSetOwner({ events: [{ headers: [{ name: 'Authorization', value: 'Bearer x' }, { name: 'X-Signature', value: 'x' }], credential: 'x', sig: 'x', body: { signature: 'x', safe: true } }] }); + const event = preload.bridge({}).events()[0]; + assert.equal(event.headers[0].value, '[redacted]'); assert.equal(event.headers[1].value, '[redacted]'); + assert.equal(event.credential, '[redacted]'); assert.equal(event.sig, '[redacted]'); assert.equal(event.body.signature, '[redacted]'); + preload.__testSetOwner(null); +}); +test('safe event copies hostile keys without prototype pollution', () => { + preload.__testSetOwner({ events: [{ body: { value: JSON.parse('{"__proto__":{"polluted":true},"constructor":"safe","prototype":"safe"}') } }] }); + const value = preload.bridge({}).events()[0].body.value; + assert.equal(Object.getPrototypeOf(value), null); + assert.equal(value['[reserved-key]'].polluted, true); + assert.equal(value['[reserved-key]#2'], 'safe'); + assert.equal(value['[reserved-key]#3'], 'safe'); + assert.equal(Object.hasOwn(value, '__proto__'), false); + assert.equal(Object.hasOwn(value, 'constructor'), false); + assert.equal(Object.hasOwn(value, 'prototype'), false); + assert.equal({}.polluted, undefined); + preload.__testSetOwner(null); +}); +test('safe event copying never executes accessors', () => { + const payload = Object.create(null); + Object.defineProperty(payload, 'visible', { value: 'ok', enumerable: true }); + Object.defineProperty(payload, 'derived', { enumerable: true, get() { throw new Error('must not execute'); } }); + preload.__testSetOwner({ events: [{ body: { value: payload } }] }); + const value = preload.bridge({}).events()[0].body.value; + assert.equal(Object.getPrototypeOf(value), null); + assert.equal(value.visible, 'ok'); + assert.equal(value.derived, '[redacted]'); + preload.__testSetOwner(null); +}); +test('deep JSON is bounded before preview, bridge redaction, and renderer', () => { + const json = `${'{"x":'.repeat(5000)}{"token":"secret"}${'}'.repeat(5000)}`; + const deepPreview = preview(Buffer.from(json), 'application/json'); + assert.deepEqual(deepPreview, { kind: 'text', value: '[preview omitted: JSON nesting limit exceeded]', truncated: true }); + let value = { token: 'secret' }; for (let index = 0; index < 5000; index++) value = { child: value }; + preload.__testSetOwner({ events: [{ body: { kind: 'json', value } }] }); + const event = preload.bridge({}).events()[0]; + let cursor = event.body.value; for (let index = 0; index < 60 && cursor && typeof cursor === 'object'; index++) cursor = cursor.child; + assert.equal(cursor, '[truncated]'); + const source = fs.readFileSync(require.resolve('../src/main/app.js'), 'utf8'); + assert.match(source, /humanize\(JSON\.stringify\(body\.value\)\)\.slice\(0,280\)/); + preload.__testSetOwner(null); +}); +test('normal JSON stays renderable while secrets remain redacted', () => { + preload.__testSetOwner({ events: [{ body: { kind: 'json', value: { ok: true, token: 'do-not-leak' } } }] }); + const visible = JSON.stringify(preload.bridge({}).events()[0].body.value); + assert.match(visible, /"ok":true/); assert.doesNotMatch(visible, /do-not-leak/); assert.match(visible, /\[redacted\]/); + preload.__testSetOwner(null); +}); +test('text payload leaves receive final credential-pattern redaction', async () => { + const server = new WebhookServer({ token: 'safe' }); + const privateKey = `-----BEGIN PRIVATE KEY-----\n${'A'.repeat(64)}\n-----END PRIVATE KEY-----`; + await invoke(server, { body: `note=visible token=AKIA_SUPER_SECRET_VALUE_123456 Authorization: Bearer abcdefghijklmnop https://x.test/?signature=hidden\n${privateKey}`, headers: { 'content-type': 'text/plain' } }); + preload.__testSetOwner(server); + const text = preload.bridge({}).events()[0].body.value; + assert.match(text, /note=visible/); + assert.match(text, /\[redacted-private-key\]/); + assert.doesNotMatch(text, /AKIA_SUPER_SECRET_VALUE_123456|abcdefghijklmnop|signature=hidden|BEGIN PRIVATE KEY/); + preload.__testSetOwner(null); +}); +test('renderer localizes every machine redaction token and cross-platform curl is explicit', () => { + const app = fs.readFileSync(require.resolve('../src/main/app.js'), 'utf8'); const html = fs.readFileSync(require.resolve('../src/main/index.html'), 'utf8'); + assert.equal(app.includes('innerHTML'), false); assert.match(app, /\['\[redacted-private-key\]','【私钥已脱敏】'\]/); assert.match(app, /\['\[redacted\]','【已脱敏】'\]/); assert.match(app, /\['\[preview omitted: JSON nesting limit exceeded\]','【JSON 预览已省略:嵌套层级超限】'\]/); assert.match(app, /\['\[preview omitted: redacted output exceeds 64 KiB\]','【预览已省略:脱敏后的输出超过 64 KiB】'\]/); assert.match(app, /\$\('#secret'\)\.value=''/); assert.match(html, /id="secret" type="password"/); + assert.equal(hmac('x', 's').length, 64); assert.equal(hmac('x', 's', 'sha512').length, 128); assert.throws(() => hmac('x', 's', 'md5')); + for (const platform of ['win32', 'darwin', 'linux']) { assert.equal(hostFor('lan', platform), '127.0.0.1'); assert.match(curlFor('http://127.0.0.1:123/a', platform), /curl/); } + assert.match(curlFor('http://127.0.0.1:123/a', 'win32'), /curl\.exe.*'http:\/\/127\.0\.0\.1:123\/a'/); + assert.throws(() => curlFor("http://127.0.0.1:123/a'b", 'win32')); +});