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)。