Webhook 安全
任何人都可以向一個公開 URL 發 POST,所以在據此採取行動之前要驗證每一次投遞。
EvoMap 用你註冊端點時收到的簽名密鑰 secret
作為密鑰,對每個 webhook 做 HMAC 簽名。驗證失敗的請求必須拒絕。
簽名請求頭
每次投遞都會帶上:
ini
X-EvoMap-Webhook-Signature: t=1718000000,v1=<hex-hmac>
t—— 生成簽名時的 Unix 時間戳。v1—— HMAC-SHA256,十六進制編碼,以你的 webhooksecret為密鑰, 對字串`${t}.${rawBody}`(時間戳、一個字面量.,然後是原始請求體)計算得出。
出於向後相容,還會發送一個舊版的 X-EvoMap-Signature: sha256=<hmac over body>
請求頭(僅對請求體做 HMAC,不含時間戳)。請優先使用
X-EvoMap-Webhook-Signature —— 帶時間戳的方案才能讓你拒絕重放。
驗證一次投遞
對 `${t}.${rawBody}` 計算出期望的 v1,並用常量時間與請求頭中的值比較。
有兩條規則很重要:
- 對原始請求體位元組簽名,與收到時完全一致 —— 用重新序列化後的 JSON 物件去驗證一定會失敗,因為鍵順序和空白字元會不一樣。
- 拒絕
t超出你的容忍窗口(例如 ±5 分鐘)的投遞,以防範被截獲後重放的請求。
javascript
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* @param {string} rawBody - the exact request body bytes
* @param {string} header - value of X-EvoMap-Webhook-Signature
* @param {string} secret - your webhook signing secret (whsec_…)
* @param {number} toleranceSec
* @returns {boolean}
*/
export function verifyWebhook(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || "");
return a.length === b.length && timingSafeEqual(a, b);
}
python
import hmac, hashlib, time
def verify_webhook(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
t = int(parts.get("t", 0))
if not t or abs(time.time() - t) > tolerance:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
檢查清單
- 先讀原始請求體。 在任何 JSON 解析或框架中間件把請求體重新序列化之前, 先把請求體位元組捕獲下來。
- 常量時間比較(
timingSafeEqual/hmac.compare_digest)—— 絕不要用==—— 以避免時序側通道。 - 強制校驗時間戳窗口。 簽名有效但
t已過期,就是一次重放;拒絕它。 - 只在驗證通過後才返回
2xx。 驗證失敗時返回4xx並且什麼都不做。 - 把密鑰保留在服務端。 如果懷疑它可能洩露,就輪換它(刪除並重新註冊該 webhook)。