Webhook 簽名詳解

Webhook 簽名是傳送方服務附加在每次投遞上的一小串字元——用只有傳送方與接收方共享的金鑰,對請求的原始位元組計算出的 HMAC。它的存在,是為了讓接收伺服器在採取任何行動之前回答兩個問題:這真的來自它聲稱的那個服務嗎?body 是否原樣到達?本文解釋這套驗證到底如何運作:HMAC 覆蓋什麼、為什麼必須拿原始 body 而不是解析後的物件去驗、為什麼比較必須恆定時間、時間戳如何界定重放攻擊、以及為什麼每家廠商的標頭格式都不一樣。如果你想先看一次真實投遞——標頭、簽名與原始 body 放在一起——可以讓某個傳送方把請求打到 Webhook 測試器,看看實際到達的是什麼。

為什麼一個秘密 URL 不算身分驗證

最古老的 Webhook 防護是不可猜測的 URL:一段只有傳送方服務知道的長隨機路徑。它感覺像密碼,但行為像地址,而地址會洩漏。這個 URL 會被寫進存取日誌、代理日誌、監控面板、問題報告裡的截圖,以及傳送方服務自身的設定頁面——任何支援工程師,或者攻陷了其中任何一個系統的人,都能讀到它。密碼可以靜態雜湊、可以按使用者輪換;URL 卻在使用它的每個地方被原樣複製,每一份副本都是一張長期有效的邀請函。

更深層的問題是端點在請求到達後拿它做什麼。Webhook 處理器會把帳單標記為已付、開通帳戶、發放授權、刪除資料。這些都是值得偽造的動作,而偽造一個請求的成本不過是攻擊者從網際網路任意位置發出的一次 HTTP POST。TLS 在這裡幫不上忙,因為 TLS 是向連接方驗證你的伺服器,它對連接方是誰一言不發。簽名把信任從「知道敲哪個門」轉移到「知道一個從不出現在請求本身中的金鑰」,這是類別上更強的主張。

HMAC 簽名到底證明了什麼

傳送方與接收方共享一個金鑰,在端點註冊時產生。對每次投遞,傳送方對一組確定的位元組計算 HMAC——一種帶金鑰的雜湊:通常是原始請求 body,往往在前面加上時間戳,有時還帶上投遞 ID。結果放在一個標頭裡傳輸。接收方用自己那份金鑰,對收到的位元組重新計算 HMAC,並比較兩個值。符合同時證明兩件事:傳送方持有金鑰,並且被覆蓋的位元組與傳送方簽名的位元組逐位元一致。把金額欄位改動一個字元,重新計算出的值就會完全不同。

有兩條局限值得說清楚。第一,簽名不是加密:body 在網路上對任何能看到它的人都是可讀的——讓它保持私密是 TLS 的工作——簽名只為來源與完整性背書。第二,對 body 做一個普通雜湊什麼也證明不了,因為任何人都能算雜湊。是金鑰把雜湊變成了訊息驗證碼。本站的雜湊產生器算的是裸雜湊,作校驗和很好用——但一個人人可重算的校驗和,驗證不了任何人。

逐位元組驗證原始 body

「真實投遞卻驗證失敗」最常見的原因不是攻擊者,而是接收方自己的框架。簽名是對 body 按傳輸時的原樣計算的:這個鍵順序、這些空白、這些逸出字元、這種數字格式。大多數 Web 框架會在你的處理器執行之前體貼地把 JSON body 解析成物件,而如果你把那個物件重新序列化去驗證,得到的是相同的資料、不同的位元組:鍵被重排、空格被添加、1e3 被呈現為 1000、unicode 被逸出或取消逸出。語義上相同,密碼學上不同——於是一次完全真實的投遞驗證失敗。Stripe 的文件把規則說得很直白:簽名驗證需要原始 body,框架不得對它做任何加工。

操作上的推論是順序問題:在任何東西解析它之前,先擷取原始請求 body,用這些位元組驗證簽名,然後才解析和處理。會改寫 body 的代理與中介軟體——重新編碼、正規化、美化排版——和解析器一樣能破壞簽名,這就是為什麼除錯要從「按請求到達時的原樣去看它」開始。接收工具給你的正是這個視圖:一次投遞落到 Webhook 測試器 裡時,你檢查的是未被觸碰的請求,標頭與 body 都按傳輸時的樣子呈現,可以拿它誠實地核對簽名。

恆定時間比較,以及為什麼時鐘是簽名的一部分

比較兩個 HMAC 值看似是最微不足道的一步,卻藏著一個微妙的洩漏。普通字串比較在第一個不相符的字元處停下,所以猜測錯得越早返回越快、恰好對上的前綴越長返回越慢。經過大量請求,這種時間差能讓攻擊者在從不知道金鑰的情況下,一次一個字元地「量」出一個有效簽名。恆定時間比較總是檢查完整長度,無論兩個值在第一個字元還是最後一個字元不同,耗時都相同。各平台文件對此說得很不客氣——比如 GitHub 的文件明確警告不要用普通的相等運算子,並指向 crypto.timingSafeEqual 這類恆定時間函數。用函式庫函數;不要自己寫比較迴圈。

新鮮性是第三個性質,而簽名本身給不了你:一個被截獲的真實投遞原樣重放,帶著完全有效的簽名。這就是為什麼許多方案把時間戳折進被簽名的位元組裡,以及為什麼接收方會拒絕時間戳落在容差視窗之外的投遞——按 Stripe 的文件,其方案預設是五分鐘。這個視窗是一種刻意的折中:寬到足以吸收時鐘偏移與緩慢的佇列,窄到讓被截獲的請求很快過期。在視窗之內,防禦落到冪等性上:記錄你處理過的每一個投遞或事件 ID,丟棄重複——反正你也需要它,因為誠實的傳送方會重試,而重試就是帶著善意的重放。

為什麼每家廠商的標頭長得都不一樣

機制是標準的;包裝從未統一。Stripe 傳送形如 t=<timestamp>,v1=<signature> 的 Stripe-Signature 標頭,並且可以同時包含多個 v1 值。GitHub 傳送 X-Hub-Signature-256,其值是帶演算法前綴的字串 sha256=<hex digest>——沒有時間戳,所以 GitHub 的投遞沒有內建過期,防重放完全靠投遞 ID 去重。Standard Webhooks 規範是對這個問題的統一嘗試:投遞 ID、時間戳與簽名各占一個標頭,簽名對三者加上 body 一起計算,這樣新廠商就不必再發明互不相容的變體。這些差異是歷史偶然,不是不同的安全模型——在每種方案裡,不變量都相同:一個共享金鑰、一組確定的被覆蓋位元組、一次恆定時間比較。

Stripe 格式裡的多個 v1 值,是為每個接收方遲早要面對的操作準備的:輪換金鑰。天真地做——改金鑰、部署、祈禱——輪換會打斷所有在途投遞,因為用舊金鑰簽名的重試與排隊投遞會突然驗證失敗。可行的模式是並行視窗:在重疊期,新舊兩個金鑰下的簽名都被接受,你確認投遞能在新金鑰下驗過,然後才退役舊金鑰。同樣的視窗模式出現在 JWT 的世界裡——金鑰 ID 與重疊金鑰——團隊跳過它時的故障模式也一模一樣,正如JWT 常見錯誤一文所述。一個無法在不停機的情況下輪換的金鑰,即使到了該輪換的時候也不會被輪換——包括它洩漏的那一天——這就是為什麼這個不起眼的並行視窗是協議的一部分,而不是事後補丁。

來源與延伸閱讀

常見問題

我確知是真實的投遞,驗證卻失敗了。先從哪裡查?

先查 body。按順序確認:你驗證的是到達時的原始位元組,還是解析後再重新序列化的版本?你用的金鑰是否屬於這個端點、這種模式——測試與生產端點的金鑰不同,每個註冊的端點也有自己的金鑰?傳送方與你的處理器之間是否有東西——代理、框架中介軟體——在改寫 body?擷取一次未被觸碰的投遞,比如在 Webhook 測試器 裡,並用那些確切的位元組驗證,一步就能區分「傳送方簽得怪」與「我的技術棧改了位元組」。

我們已經用了 HTTPS。簽名不是多餘的嗎?

不多餘——兩者驗證的是不同方向。TLS 向傳送方證明它到達了你真正的伺服器,並加密傳輸中的 body。它對連接方是誰沒有任何要求:網際網路上任何人都能與你的端點建立一個完全有效的 TLS 連接,並 POST 一個偽造的「支付成功」body。簽名才是驗證傳送方與內容的那部分。TLS 讓投遞保持私密;簽名讓投遞值得信任。

Webhook 金鑰應該多久輪換一次?

一旦懷疑洩漏,立即輪換——它躺在面板、環境檔案與 CI 變數裡,所以把暴露視為時間問題。常態下,按你的團隊輪換同類憑據的節奏即可,前提是輪換足夠便宜:有了雙接受視窗,它就是一次設定變更,而不是一次事故。如果某個廠商的方案讓輪換變得痛苦,在選擇整合時把這一點計入考量,並優先選 Standard Webhooks 這類公開發布的方案——能否輪換是協議的安全屬性,不是錦上添花。