[aru_27] 如果我想让 GitHub 在仓库发生事件时通知自己的 Node.js 服务,第一步不是执行脚本,而是先确认请求的正文有没有被篡改。这次我搭了一个只监听本机的示例,记录如何验证 GitHub Webhook 的签名,以及几个容易踩的坑。

为什么不能只看请求里的仓库名?
任何人都可以构造一段带有仓库名的 JSON 发给你的接口。GitHub Webhook 配置了 secret 后,会使用这个密钥对请求体计算 HMAC-SHA256,并把结果放在 X-Hub-Signature-256 请求头,格式是 sha256=十六进制摘要。接收端用同一个 secret 和收到的原始请求体字节计算摘要,再比较签名。
验签只说明正文与持有该密钥的一方生成的签名匹配;密钥泄露、重放、业务权限和执行安全还要另外处理。下面是本地演示,不会连接真实仓库,也不会触发部署。
一个不用安装依赖的 Node.js 接收端
我用 Node.js v22.22.2 在本机验证。将下面代码保存为 server.mjs,先收集原始字节并限制缓冲大小,验签通过以后才解析 JSON:
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { createHmac, timingSafeEqual } from 'node:crypto';
const secret = process.env.WEBHOOK_SECRET;
if (!secret) throw new Error('请先设置 WEBHOOK_SECRET');
const limit = 1024 * 1024;
export function verifySignature(body, header, key) {
if (typeof header !== 'string' || !/^sha256=[a-f0-9]{64}$/i.test(header)) return false;
const received = Buffer.from(header.slice(7), 'hex');
const expected = createHmac('sha256', key).update(body).digest();
return received.length === expected.length && timingSafeEqual(received, expected);
}
export function createServer(key) {
return http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/webhook') {
res.writeHead(404).end('not found');
return;
}
const chunks = [];
let size = 0;
let tooLarge = false;
req.on('data', chunk => {
size += chunk.length;
if (size > limit) { tooLarge = true; chunks.length = 0; }
else if (!tooLarge) chunks.push(chunk);
});
req.on('error', () => { if (!res.destroyed) res.destroy(); });
req.on('end', () => {
if (tooLarge) return res.writeHead(413).end('payload too large');
const body = Buffer.concat(chunks);
if (!verifySignature(body, req.headers['x-hub-signature-256'], key)) {
return res.writeHead(401).end('invalid signature');
}
let payload;
try { payload = JSON.parse(body.toString('utf8')); }
catch { return res.writeHead(400).end('invalid JSON'); }
// 演示只确认验签与 JSON 解析,不执行部署或其他业务操作。
console.log('verified event:', req.headers['x-github-event'] ?? 'unknown',
'action:', typeof payload?.action === 'string' ? payload.action : '-');
res.writeHead(200).end('ok');
});
});
}
// 显式绑定本地回环地址;此示例不会暴露到公网。
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
createServer(secret).listen(8787, '127.0.0.1', () => console.log('listening on 127.0.0.1:8787'));
}
这里先检查签名格式和长度,再用 timingSafeEqual 比较相同长度的字节,避免它因长度不同而抛错。它只保证比较操作本身的性质,不能单独保证整个程序没有时间侧信道。
在本机发一个模拟请求
先在一个终端设置仅用于本地实验的 secret 并启动服务:
WEBHOOK_SECRET='local-test-only' node server.mjs 再打开另一个终端,进入同一目录,保存并运行下面的 send.mjs。发送端和接收端共享的测试密钥只是示例;生产密钥应随机生成并由服务器安全保存。
import { createHmac } from 'node:crypto';
const body = Buffer.from('{"action":"opened","title":"测试"}', 'utf8');
const signature = 'sha256=' + createHmac('sha256', 'local-test-only')
.update(body).digest('hex');
const response = await fetch('http://127.0.0.1:8787/webhook', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature },
body,
});
console.log(response.status, await response.text()); 运行 node send.mjs 应得到 200 ok,服务端记录已验证的事件。想观察失败,可以在发送时改动 body 而不重新计算签名,响应会变成 401。结束实验时在服务端终端按 Ctrl+C。
为什么 JSON 内容看起来一样,签名却不一样?
签名针对的是字节。例如 {"a":1} 与 {"a": 1} 解析出来含义相同,但空格不同,原始字节和签名也不同。如果框架先执行 JSON 解析、再重新序列化来验签,就可能因为空格、转义或字段顺序变化而失败。接入 Express 等框架时,应按框架文档在相应路由保留原始 body,再验签;也要确认反向代理没有改写正文。
验证通过后,离自动部署还有距离
- 限制请求大小,并在反向代理和服务器设置合理的请求时间限制;示例只限制了缓冲大小,不是完整的公网防护。
- GitHub 建议尽快返回 2xx,把耗时工作交给队列。只有在任务已可靠接收后再确认;示例没有队列,也没有持久化。
- 按需订阅事件并核对业务条件,不要把请求中的分支名、仓库名直接拼进 shell 命令。事件相关请求头也不是 HMAC 覆盖的正文。
- 重复投递或重放仍需处理。GitHub 的 delivery ID 对重投保持不变,可用于正常投递去重;它本身不在签名正文里,不能单独当作防恶意重放的凭据。
- 生产环境使用 HTTPS、安全保存并轮换 secret;别把真实密钥写进仓库、日志或示例命令。
这次实际验证了什么
我先用 GitHub 文档给出的测试向量检查摘要,再向本机回环接口发送请求,验证了中文正文的正常签名、正文空格变化、缺失/格式错误/错误签名、验签通过但 JSON 无效、超大请求和错误路径。示例没有连接 GitHub,也没有验证公网代理、队列或生产部署流程。
参考文档:GitHub:验证 Webhook 投递、GitHub:Webhook 最佳实践、Node.js:timingSafeEqual。
🎨 原创不易,支持请点赞、转载请注明本文作者为老K