一个支付Webhook到达您的服务器,声称订单已支付。您信任它吗?如果您仅根据HTTP调用来确认订单——而不验证加密签名——您就给攻击者提供了一个免费的方式来将任何订单标记为已支付。
没有验证会出什么错
一个未保护的 webhook 端点很容易被利用。攻击者只需要知道你的回调 URL——这通常可以从浏览器的网络标签、公共 GitHub 仓库或测试结账中发现——然后他们就可以 POST 一个精心制作的有效负载:
POST /webhook HTTP/1.1
Content-Type: application/json
{"order_id": "1337", "status": "paid", "amount": "99.99"}如果你的处理程序读取$_POST['status'] === 'paid'并且确认订单,攻击者就免费购买了价值 $99.99 的商品。这不是一个理论攻击;它经常发生在未保护的支付集成上。
HMAC-SHA256 签名的工作原理
HMAC(基于哈希的消息认证码)与 SHA-256 通过使有效负载在没有秘密密钥的情况下无法伪造来解决这个问题。过程是:
- 支付网关知道你的api_secret——这是你和网关之间共享的秘密。
- 在发送 webhook 之前,它会计算:
HMAC-SHA256(timestamp + "." + body, api_secret) - 它在 HTTP 头中包含此签名和时间戳。
- 您的服务器重新计算相同的 HMAC 并进行比较。如果匹配,则有效负载是可信的。
一个不知道您 api_secret 的攻击者无法伪造有效签名 — SHA-256 是单向函数。时间戳防止重放攻击:即使是有效的拦截 webhook,在 5 分钟后也变得无用。
实现验证:PHP
function verifyPayoraWebhook(string $secret): array {
$ts = $_SERVER['HTTP_X_PAYORA_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_PAYORA_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
// 1. reject stale timestamps (replay protection)
if (!ctype_digit($ts) || abs(time() - (int)$ts) > 300) {
http_response_code(400); exit('stale timestamp');
}
// 2. recompute and compare — hash_equals() prevents timing attacks
$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(401); exit('invalid signature');
}
return json_decode($body, true);
}
$event = verifyPayoraWebhook('sk_your_secret');
if ($event['status'] === 'paid') {
creditOrder($event['order_id']);
}实现验证:Node.js
import crypto from 'node:crypto';
function verifyPayoraWebhook(req, rawBody, secret) {
const ts = req.headers['x-payora-timestamp'] ?? '';
const sig = req.headers['x-payora-signature'] ?? '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return null; // stale
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
// timingSafeEqual prevents timing side-channel attacks
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
? JSON.parse(rawBody)
: null;
}破坏验证的常见错误
1. 读取解析后的主体而不是原始字节。 Express 的 json()中间件重新序列化 JSON,这可能会重新排序键或更改空格。始终缓冲原始请求体之前解析,并将其用于 HMAC。
2. 使用===而不是时间安全比较。简单的字符串比较会泄露时间信息,这可能被利用来逐字猜测签名字节。使用hash_equals()在 PHP 中或crypto.timingSafeEqual()在 Node 中。
3. 跳过时间戳检查。一个有效但过时的 webhook 被网络观察者捕获后可能会被重放。5分钟的窗口(±300秒)消除了这种风险,同时处理合理的时钟漂移。
4. 在验证之前记录原始主体。如果您的日志管道被破坏,记录的有效负载可能会被重放。先验证,后记录。
超越签名:幂等性
验证证明 webhook 是真实的;幂等性确保您不会重复计入订单。网络在非 200 响应时重试 webhook,因此您的处理程序可能会多次接收到相同的事件。解决方案是在您的数据库中记录一个 webhook_id ,如果您之前见过它,则跳过处理:
// pseudo-code
$id = $event['webhook_id'];
if (DB::exists('processed_webhooks', $id)) return respond(200);
DB::insert('processed_webhooks', $id);
creditOrder($event['order_id']);结合签名验证和幂等性,您可以获得一个既安全又可靠的支付集成。查看完整的集成指南 以获取 Payora 的完整 webhook 参考。
Comments