← 所有文章
教程

Webhook HMAC 重放防护:时间戳、重试与幂等性

Webhook HMAC 重放防护需要两道彼此独立的控制。首先,对准确的时间戳与原始请求体计算 HMAC-SHA256,并拒绝超出自定义新鲜度窗口的投递。其次,对稳定的事件 ID 去重,因为真实、合法的投递也可能被重试。签名验证能够证明数据完整性,并确认发送方掌握共享密钥;它无法让投递自动变成 exactly-once。

Webhook HMAC 重放防护如何工作

安全的接收端应按顺序回答四个问题:

  1. 签名请求头是否存在且格式正确? 如果端点已配置签名,应拒绝缺少时间戳、签名或事件 ID 的请求。
  2. 请求是否足够新? 解析 RFC 3339 时间戳,并应用为自身基础设施选定的新鲜度窗口。
  3. 原始字节是否与签名匹配?<timestamp>.<raw body> 计算 HMAC-SHA256,并以恒定时间比较摘要。
  4. 这个事件是否已被接收? 在确认投递之前,以唯一约束保存稳定的事件 ID。

最后一项检查很重要,因为 retry 与 replay 并不是一回事。retry 是连接失败或收到非 2xx 响应后的合法再次投递;replay 则是在预期处理路径之外再次使用曾经有效的签名请求。时间戳新鲜度限制了已被获取的请求还能被接受多久;持久化幂等机制则避免一次有效重试重复创建同一工单、回复或工作流。

关键要点

  • 在解析 JSON 或重新序列化之前,先验证原始请求字节。
  • 将时间戳新鲜度视为应用策略;UnifyPort 不会为所有部署规定同一个固定容差。
  • 对长度相同的摘要缓冲区使用恒定时间比较。
  • X-Device-Event-Id 去重,因为 UnifyPort 采用 at-least-once 投递。
  • 只在事件已被持久化接收后返回 2xx,而不是等所有下游工作完成后再响应。

UnifyPort 的精确签名约定

Webhook 端点配置 signing_secret 后,UnifyPort 会发送十六进制编码的 X-Device-Signature。被签名的内容是:

<X-Device-Timestamp>.<raw request body>

X-Device-Timestamp 是 RFC 3339 UTC 值,而不是 Unix 整数。同一事件重试时,X-Device-Event-Id 保持不变;X-Device-Delivery-Id 则标识单次投递尝试。如果省略 signing_secret 或将其留空,签名会被禁用,也不会发送签名请求头。

这一约定遵循 RFC 2104 对 HMAC 的定义:共享密钥的双方可以检查消息完整性,并验证发送方是否掌握该密钥。HMAC 不会加密请求体,不会自行证明请求的新鲜度,也不承诺只投递一次。这些保证分别来自 HTTPS、时间戳策略,以及围绕 HMAC 检查建立的幂等存储。

如果你要构建完整的入站工作流,n8n WhatsApp Webhook 教程展示了签名事件如何进入自动化流程;TikTok 实时私信队列教程则说明了为何同一套已验证事件信封应先存储、再路由。

在 Node.js 中验证时间戳和原始请求体

下面的接收端将请求体保留为 Buffer,从部署配置读取新鲜度容差,使用 Node.js 的 crypto.timingSafeEqual 比较二进制摘要,再把已验证事件交给持久化收件箱。durableInbox.insertIfAbsent 代表一次受事件 ID 唯一键保护的数据库插入;请用服务当前采用的数据存储来实现它。

import crypto from 'node:crypto';
import express from 'express';

const app = express();
const secret = process.env.WEBHOOK_SIGNING_SECRET;
const maxAgeMs = Number(process.env.WEBHOOK_MAX_AGE_MS);

if (!secret || !Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
  throw new Error('Configure WEBHOOK_SIGNING_SECRET and WEBHOOK_MAX_AGE_MS');
}

app.post(
  '/webhooks/unifyport',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const timestamp = req.get('X-Device-Timestamp') ?? '';
    const signature = req.get('X-Device-Signature') ?? '';
    const eventId = req.get('X-Device-Event-Id') ?? '';

    if (!timestamp || !signature || !eventId) {
      return res.sendStatus(401);
    }

    const signedAtMs = Date.parse(timestamp);
    const ageMs = Math.abs(Date.now() - signedAtMs);
    if (!Number.isFinite(signedAtMs) || ageMs > maxAgeMs) {
      return res.sendStatus(401);
    }

    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.')
      .update(req.body)
      .digest();

    const validHex = /^[0-9a-f]{64}$/i.test(signature);
    const provided = validHex ? Buffer.from(signature, 'hex') : Buffer.alloc(0);
    const validSignature =
      provided.length === expected.length &&
      crypto.timingSafeEqual(provided, expected);

    if (!validSignature) {
      return res.sendStatus(401);
    }

    const event = JSON.parse(req.body.toString('utf8'));
    const accepted = await durableInbox.insertIfAbsent({
      id: eventId,
      occurredAt: event.occurred_at,
      payload: event,
    });

    return res.sendStatus(accepted ? 202 : 200);
  },
);

新鲜度检查位于 HMAC 比较之前,但只有两项检查都通过后,时间戳才会被信任。接收端只是提前拒绝明显过期的输入。WEBHOOK_MAX_AGE_MS 的取值应反映你的时钟同步状况、正常投递延迟、事件处理流程和风险模型。不要直接照搬无关提供方的容差,并默认它适合你的队列。

Node.js 文档说明 crypto.timingSafeEqual 适合比较 HMAC 摘要,同时也提醒外围代码不能引入计时信息泄露。应先验证十六进制格式和字节长度,因为 timingSafeEqual 要求两个输入长度相等。

让确认路径安全应对重试

UnifyPort 将任何 2xx 响应视为确认,并丢弃响应体。连接错误以及 HTTP 408、429 和 5xx 响应可以按端点配置的 retry_policy.max_attempts 重试;默认值为 3。其他 4xx 响应不会重试,事件会进入 dead letter。

根据这一行为,可以设计出清晰的接收端:

接收端结果响应原因
签名缺失、过期或无效401请求不应进入可信队列。
已存储的已验证事件200可以安全确认重试,无需重复执行工作。
已持久化插入的已验证事件202接收完成后,worker 可以继续异步处理。
持久化收件箱暂时不可用503与其确认一个尚未存储的事件,让系统重试更安全。

请对 X-Device-Event-Id 建立唯一索引,而不是使用进程内的 Set。本地缓存会在重启后消失,也无法协调多个接收端实例。下游操作也要保持幂等:队列 consumer 可能在调用 CRM 或发送回复之后、记录完成状态之前崩溃。

投递顺序不受保证。处理会改变状态的工作时,应按事件 payload 的 occurred_at 排序,并以事件 ID 作为顺序相同时的判定值,而不是假设 HTTP 到达顺序就是事件顺序。当已读回执可能早于其引用的消息抵达端点时,这一点尤其重要。

UnifyPort 的适用位置

UnifyPort 会跨受支持的渠道投递同一套标准事件信封,其中包括 idtypeprovideraccount_idoccurred_at 和事件专属的 data。因此,上面的接收端保护的是一个统一入口,而不是六套渠道专属 handler。只需注册一次端点,启用 signing_secret,订阅所需事件,并在按 providertype 路由之前应用同样的时间戳、签名和幂等检查。

重要边界在于存储:UnifyPort 不会保存消息历史供日后回填。Webhook 事件就是流量记录,因此接收端应先持久化接收,再返回 2xx。签名验证保护交接过程;收件箱表或队列则负责留存事件。

局限与权衡

  • HMAC 用于验证来源并保护完整性;它不会加密 JSON 请求体。请持续启用 HTTPS,并分别保护日志和队列。
  • 有效的 HMAC 无法阻止重复处理。仍需使用时间戳新鲜度和幂等键。
  • UnifyPort 不会公布一套通用的时间戳容差。较短的窗口会限制请求再次被使用,但对时钟漂移或延迟投递的容忍度也更低。
  • 如果端点禁用了签名,X-Device-Signature 就不会出现。要求身份验证的生产接收端应在缺少该请求头时采用 fail closed。
  • 官方提供方的 Webhook 可能使用不同的请求头、编码或 canonical string。请遵循各提供方自己的约定,不要把 UnifyPort 的字符串格式套用到所有 Webhook 来源。

常见问题

为什么我的 Webhook HMAC 签名不匹配?

最常见的原因是验证了解析后或重新序列化的 JSON,而不是准确的原始字节。还要检查 RFC 3339 时间戳、字面量点号分隔符、正确的 signing_secret、十六进制解码,以及 middleware 是否在验证前消费了请求体。

仅使用 HMAC 能阻止 Webhook 重放吗?

不能。HMAC 只能证明签名字节与共享密钥匹配。还要检查 X-Device-Timestamp 的新鲜度,并对 X-Device-Event-Id 进行持久化去重,以限制请求再次使用和重复处理。

重复事件应该返回错误吗?

不应该。如果同一个事件 ID 已被持久化接收,请返回 2xx。返回错误只会触发另一次合法重试,却不会提高正确性。

应该在处理事件之前确认吗?

应在持久化接收之后、耗时的下游工作之前确认。先将事件插入数据库支持的收件箱或持久化队列,返回 2xx,再由 worker 以幂等方式处理 CRM 写入、AI 处理或回复。

应该使用多长的新鲜度窗口?

请根据已同步的时钟、观测到的投递延迟、事件处理方式和风险模型选择并记录窗口。UnifyPort 的签名约定要求拒绝与本地时钟相差过大的时间戳,但不规定唯一的固定值。

下一步

按照 Webhook 投递与签名验证指南实现准确的请求头和重试约定。诊断签名不匹配时,如需快速逐字节检查,可使用 HMAC 签名生成器作为唯一的辅助工具。

来源

官方来源核验于 2026 年 7 月 17 日: