下面這個 Webhook 簽章測試會通過,看起來很負責,卻幾乎什麼都沒證明:
const ts = Math.floor(Date.now() / 1000).toString();
const sig = await hmacHex(`${ts}.${body}`, secret, "SHA-256");
const headers = new Headers({ "stripe-signature": `t=${ts},v1=${sig}` });
expect(await verifySignature("stripe", headers, body, secret)).toBe(true);
這個測試資料(fixture)是用驗證程式所檢查的同一套公式產生的。如果你在寫驗證程式時讀錯了 Stripe 的文件(簽錯了字串、用錯了分隔符號、該用 base64 的地方用了 hex),測試就會寫進同樣的誤讀,兩者一起維持綠燈。你測的不是「我們能驗證 Stripe 簽章」,而是「我們同意我們自己」。
我們有 70 個像這樣的測試,橫跨 20 個服務商。每一個都通過。接著我們問了唯一重要的問題:服務商自己的程式碼產生的簽章,能通過我們的驗證程式嗎?
用服務商的 SDK 交叉驗證
有些服務商會在官方 SDK 裡附上簽章函式,通常就是為了這個目的。Stripe 的是 generateTestHeaderString;Svix 的 Webhook 類別有 .sign()。如果它們本來就是你的相依套件,交叉驗證完全不用成本:
import Stripe from "stripe";
import { Webhook } from "svix";
import { verifySignature } from "./index";
it("accepts a header generated by stripe-node", async () => {
const stripe = new Stripe("sk_test_" + "0".repeat(24));
const payload = JSON.stringify({ id: "evt_1", type: "charge.succeeded" });
const secret = "whsec_" + "s".repeat(32);
const header = stripe.webhooks.generateTestHeaderString({ payload, secret });
expect(
await verifySignature("stripe", new Headers({ "stripe-signature": header }), payload, secret)
).toBe(true);
});
it("accepts a signature generated by the svix SDK", async () => {
const secret = "whsec_" + Buffer.from("k".repeat(24)).toString("base64");
const payload = JSON.stringify({ type: "email.received" });
const msgId = "msg_crosscheck";
const ts = new Date();
const signature = new Webhook(secret).sign(msgId, ts, payload);
const headers = new Headers({
"svix-id": msgId,
"svix-timestamp": Math.floor(ts.getTime() / 1000).toString(),
"svix-signature": signature,
});
expect(await verifySignature("svix", headers, payload, secret)).toBe(true);
});
現在斷言的是我們和 Stripe 一致,而不是我們和自己一致。再加上反向案例(簽一個 payload,然後驗證另一個主體),確認這個測試真的會失敗:
const header = stripe.webhooks.generateTestHeaderString({ payload, secret });
const tampered = JSON.stringify({ id: "evt_1", amount: 999999 });
expect(
await verifySignature("stripe", new Headers({ "stripe-signature": header }), tampered, secret)
).toBe(false);
因為 Clerk 和 Resend 都透過 Svix 的基礎設施傳遞,一個 Svix 交叉驗證就涵蓋三個服務商。我們的二十個驗證程式裡,現在有四個有 SDK 背書的證明,而同樣一個下午的工作,適用於任何技術堆疊。
沒有公開簽章函式的服務商怎麼辦?
Shopify、Discord、PayPal:大多數服務商都沒有附簽章函式。對這些服務商來說,唯一誠實的證據是擷取下來的真實傳遞。把一個 Webhook 指向你能控制的端點,錄下一個簽過章的請求,再把 (headers, body, secret) 這組三元組凍結成 fixture。重播一個真實的簽章,就是任何自製 fixture 都無法成為的基準事實(ground truth)。
在做到這一步之前,要對自己精確地說清楚,測試到底證明了什麼。我們把這份帳寫成註解,放在測試檔案的最底下:哪些服務商經過 SDK 驗證、哪些只是自我一致(self-consistent)、哪些根本沒有正向測試。一份能據以行動的誠實清單,勝過一套你無法信任的綠燈測試。
這種心態當天就找到的 bug
同樣的懷疑也適用於單元測試之外。我們對正式環境端對端驗證 Resend 路徑時(不是單元測試套件,而是真正部署出去的系統),對一個設定了簽章密鑰的端點送了三個請求:
| 請求 | 預期 | 實際 |
|---|---|---|
| 正確的簽章 | 200 | 200 |
| 錯誤的簽章 | 401 | 401 |
| 完全沒有簽章 | 401 | 200 |
一個沒有簽章的請求,順利穿過了一個看似受保護的端點。原因是政策上的漏洞,不是加密的 bug。來源為 generic 的 App(一個接收網址,對應一個 Webhook 來源)依設計會跳過驗證(「我只是想要一個網址」),而設定密鑰並沒有讓 App 脫離 generic。密鑰就擺在那裡,什麼都沒驗證,API 卻對儲存它的請求回傳成功。
再多自我一致的單元測試也找不到這個問題,因為每個單元都是正確的。修正方式是直接拒絕這種會誤導人的設定:現在對 generic App 設定簽章密鑰,會得到一個 400,告訴你應該改設哪個欄位。
檢查清單
- SDK 有附簽章函式的服務商:交叉驗證,兩個方向都要(有效的通過、竄改的失敗)
- 其他服務商:擷取一次真實的簽章傳遞,凍結成 fixture
- 維護一份看得見的清單,記錄每個服務商有哪個等級的證明
- 用缺少簽章的請求測試部署出去的系統,而不是只測錯誤的簽章。繞過驗證的漏洞藏在政策裡,不在數學裡