Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions plugins/webhook-lab/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist/
node_modules/
11 changes: 11 additions & 0 deletions plugins/webhook-lab/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 大小门禁并报告精确体积。
- 将人工界面、运行状态和面向人的错误提示统一为简体中文。
15 changes: 15 additions & 0 deletions plugins/webhook-lab/README.md
Original file line number Diff line number Diff line change
@@ -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 宿主加载仍待验证。
1 change: 1 addition & 0 deletions plugins/webhook-lab/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions plugins/webhook-lab/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions plugins/webhook-lab/package.json
Original file line number Diff line number Diff line change
@@ -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"}}
82 changes: 82 additions & 0 deletions plugins/webhook-lab/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
}
}
1 change: 1 addition & 0 deletions plugins/webhook-lab/scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -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));
39 changes: 39 additions & 0 deletions plugins/webhook-lab/scripts/dist-size.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
36 changes: 36 additions & 0 deletions plugins/webhook-lab/scripts/verify-dist.mjs
Original file line number Diff line number Diff line change
@@ -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)`);
119 changes: 119 additions & 0 deletions plugins/webhook-lab/src/core/server.cjs
Original file line number Diff line number Diff line change
@@ -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 };
1 change: 1 addition & 0 deletions plugins/webhook-lab/src/main/app.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading