Node.js 实战:给 GitHub Webhook 验签,别让伪造请求触发脚本

老K 12 0

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

Node.js 实战:给 GitHub Webhook 验签,别让伪造请求触发脚本
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。

发表评论 取消回复
OwO 图片 链接 代码

分享