先釐清簽章真正保護的是什麼
Webhook 簽章通常以共享密鑰搭配 HMAC-SHA256 計算,用來確認請求內容在傳輸途中沒有被修改,而且發送端持有正確密鑰。它不是資料加密,也不代表事件內容必然合理;即使簽章有效,系統仍須檢查事件類型、租戶、資源識別碼、金額或狀態轉移是否符合業務規則。TLS 仍然不可省略,因為它保護傳輸內容、降低憑證外洩風險,也能避免簽章標頭被旁觀者收集後嘗試重放。
最常見的實作錯誤,是先把 JSON 解析成物件,再序列化後計算簽章。空白、欄位順序、Unicode、換行或數字格式只要不同,位元組序列就會改變。驗證端應保存 HTTP 請求的原始 body bytes,依供應商規格組合待簽字串,例如時間戳、分隔符與原始內容,再計算摘要。框架的 JSON parser、反向代理或中介層若會解壓縮、轉碼或正規化內容,也必須確認驗證發生在變更之前。
- 演算法:只接受明確允許的版本與演算法,不要由外部標頭任意決定驗證方式。
- 比較方式:先確認摘要格式與長度,再使用固定時間比較函式,避免一般字串比較造成時序資訊洩漏。
- 內容限制:在讀取完整 body 前設定大小上限,避免攻擊者利用大型請求消耗記憶體。
- 錯誤回應:對外回傳簡潔且一致的失敗訊息;詳細原因只留在受控的內部紀錄。
時間戳只能縮小重放範圍,不能單獨阻止重放
攻擊者不需要破解 HMAC。只要取得一份曾經合法的請求,就可能在簽章仍可接受時再次送出。因此,簽章標頭應包含由發送端簽入的時間戳,接收端則檢查它與可信系統時鐘的差距。允許時間窗不應直接套用固定範本,而要考量供應商的排隊延遲、網路抖動與正式的重試政策;設得太短會拒絕正常事件,設得太長則增加可重放期間。
時間戳必須是簽章基底的一部分。若只讀取一個未被簽署的時間標頭,攻擊者可以替舊請求換上新時間。伺服器也需要穩定的時間同步與時鐘偏移告警,否則多台節點可能對同一請求做出不同判斷。對明顯超出時間窗的事件應先拒絕,再進行較昂貴的後續處理,但仍須避免讓回應差異洩漏過多驗證細節。
真正的重放防護還需要事件唯一值。優先使用供應商提供且已納入簽章的 event ID、delivery ID 或 nonce;若沒有,可由簽章版本、時間戳與原始內容摘要產生穩定鍵。將該鍵寫入具備唯一約束的資料庫或支援原子寫入的快取,保存時間至少涵蓋可接受時間窗與合理的延遲重試期。單純先查詢再寫入會有競爭條件,兩個並行請求仍可能同時通過。
把傳輸重送與業務冪等分開處理
Webhook 供應商通常採至少一次投遞:逾時、連線中斷或非成功狀態碼都可能觸發重送。重複事件因此不一定是攻擊,也可能是正常恢復機制。入口層的 replay key 可以擋下完全相同的 delivery,但業務層仍應具備冪等性,因為供應商可能用新的 delivery ID 重送相同事件,或依序送出描述同一資源狀態的多個事件。
建議流程是先完成請求大小、時間戳與簽章驗證,再以原子操作登記 delivery,將已驗證的事件寫入耐久佇列,成功後才回覆供應商。消費端以業務鍵與事件版本執行冪等更新,例如對付款事件使用交易識別碼、對資源更新保存來源版本或事件序號。不要只依賴記憶體中的集合;服務重新啟動、水平擴展或區域切換後,它無法提供一致保護。
- 重複 delivery:標記為已接收並安全回覆,避免供應商持續重試。
- 舊版本事件:若資源已有較新版本,記錄後忽略,不要讓狀態倒退。
- 處理中斷:保留佇列訊息並讓消費端重試,但所有外部副作用都要有冪等鍵。
- 永久失敗:進入隔離佇列,保存可追查資訊,不要無限快速重試。
金鑰生命週期與可觀測性決定長期可靠度
共享密鑰應存放在 secrets manager 或受控環境變數中,不可寫入程式碼、前端設定或一般日誌。輪替時可短暫同時接受現行與前一把密鑰,並以受控的 key ID 對應允許清單;不要根據任意外部值讀取密鑰。輪替完成後應移除舊密鑰,並驗證所有執行環境與備援節點均已更新。不同供應商、環境與租戶最好使用不同密鑰,以縮小外洩影響範圍。
監控至少要能區分格式錯誤、時間過期、未知簽章版本、簽章不符、重複 delivery、佇列寫入失敗與業務處理失敗。日誌可記錄事件 ID、供應商、簽章版本、接收時間及內容摘要,但不應保存密鑰、完整簽章或敏感 payload。上線前則要測試原始 body 被改動、時間戳邊界、並行重送、金鑰輪替、節點時鐘偏移及佇列故障。安全的 Webhook 不是一段 HMAC 程式碼,而是一條從入口驗證到業務副作用都可重試、可稽核且預設拒絕的處理鏈。