← 中文资源

简体中文版本

Webhook — 代码未改而 HMAC 签名校验失败

修复因请求体解析顺序、编码与时间戳容忍度导致的 webhook 签名校验失败,并以恒定时间比较摘要。

阅读英文原文

症状

  失败模式指向基础设施而非密码学:代码未改,一次框架升级或新增的 body 解析中间件让所有 webhook 全挂。机制是——provider 对精确字节 {“a”:1,“b”:2} 签名,中间件解析后重新序列化得到键序不同的 {“b”:2,“a”:1},校验者对后者做 HMAC,必然失配。编码孪生同样机械:拿 hex 摘要对比 base64 头,每个事件都失败。事件与载荷大小或内容(unicode、换行)相关、小事件通过大事件失败,指向解析器限制在校验前截断了流。部分事件通过部分失败、随内容相关,都是字节差异的指纹。

诊断思路

  先对 provider 文档核对方案:哪个头、哪种编码(hex/base64)、签名字符串含哪些字段、时间戳格式——严格照做;再确认路由上 raw body 捕获先于解析器,原始字节没了任何算法都救不了;最后用已知载荷本地计算摘要对比头值,分离编码、顺序、密钥三类问题。

根因

  签名是对 provider 发出的精确原始字节计算的。中间件先解析再序列化后,校验者哈希的是不同字节,失败必然。同类 bug 还表现为编码不匹配(hex 对 base64)或时间窗拒绝合法时钟偏移内的重放。

修复与验证

  webhook 路由先于一切解析器捕获 raw body(express.raw 限定该路由),按文档方案精确校验:hex 编码、对原始 Buffer 计算;带时间戳的方案(Stripe 的 t=…,v1=…)解析各部分、在几分钟窗口内吸收时钟偏移、按文档把时间戳与体一并签名验证;摘要比较用 timingSafeEqual 配长度预检——=== 会泄漏长度与时序。验证时:重放的已签名样本通过、篡改一字节失败、过期时间戳失败、中间件顺序有回归测试保护。

代码示例

provider signs:  {"a":1,"b":2}          (exact bytes)
middleware does: req.body -> JSON.stringify -> {'b':2,'a':1}
verifier hashes: {"b":2,"a":1}          (different bytes)
HMAC mismatch — always
const crypto = require('node:crypto');

// 1. raw bytes BEFORE any JSON parser, on this route only:
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    // 2. exact scheme: hex, over the raw body
    const expected = crypto
      .createHmac('sha256', process.env.STRIPE_SECRET)
      .update(req.body)                    // Buffer: the exact bytes
      .digest('hex');

    const provided = req.headers['stripe-signature'];

    // 3. constant-time compare, with a length pre-check
    const a = Buffer.from(expected);
    const b = Buffer.from(String(provided));
    const ok = a.length === b.length && crypto.timingSafeEqual(a, b);

    if (!ok) return res.status(400).end();
    handle(JSON.parse(req.body.toString()));
  });