← 所有文章
教學

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 日: