如何确保 POST data 仅来自特定 domain?
结论
服务端不能只根据“来源域名”可靠判断 POST 请求是否来自支付网关。Origin、Referer、Host 等 HTTP 头都无法证明请求方的身份。有些字段可能缺失,也可以被非浏览器客户端伪造。
确认支付结果时,应使用支付网关提供的服务端验证机制,通常需要:
- 验证 webhook 数字签名或 HMAC 签名
- 校验时间戳,防止重放
- 根据订单号调用支付网关 API,主动查询支付状态
- 核对金额、币种、商户号和订单号
- 对通知进行幂等处理
如果支付网关既不提供签名,也没有可供查询的服务端 API,就无法安全证明该 POST 确实由它发送。
为什么不能验证 domain
域名用于定位服务器,不会随 POST 请求附带不可伪造的身份证明。
以下方式都不能单独用于确认支付结果:
$_SERVER['HTTP_REFERER']:可能不存在,也可以伪造。$_SERVER['HTTP_ORIGIN']:主要用于浏览器的跨域控制,普通 HTTP 客户端可以自行设置。- CORS:只能限制浏览器读取跨域响应,无法阻止攻击者通过脚本、命令行或服务器直接发送 POST。
- IP 白名单:可以增加一道限制,但 IP 地址可能变化,也不能验证消息内容是否遭到篡改。
- 隐藏字段或固定 token:客户端可见的值通常都可以复制。
- 仅检查请求方法:
POST本身不可信。 - CSRF token:用于保护用户登录状态下的浏览器操作,不适合验证第三方支付网关的身份。
推荐的验证流程
收到支付通知后,可按以下顺序处理:
- 读取原始请求体,不要先解析再重新拼接。
- 从请求头中读取签名、时间戳等字段。
- 按照支付网关规定的算法,使用 webhook secret 计算签名。
- 使用恒定时间比较函数核验签名。
- 检查时间戳是否处于允许范围内,防止旧请求被重复发送。
- 检查事件 ID 或通知 ID 是否已经处理。
- 根据订单号调用支付网关的服务端 API,再次查询实际支付状态。
- 核对订单号、商户号、金额、币种和支付状态。
- 在数据库事务中更新订单,同时记录通知内容和处理结果。
- 返回支付网关要求的成功响应。
签名字段、拼接顺序、编码方式和响应格式都必须以支付网关的官方文档为准,不能自行设计一种规则再套用到现有网关。
PHP HMAC 验证示例
下面是一个通用示例。假设网关使用 HMAC-SHA256,待签名内容为 timestamp + "." + rawBody。其中的请求头名称和签名格式仅供演示,实际接入时必须替换为网关规定的格式。
<?php
$webhookSecret = getenv('PAYMENT_WEBHOOK_SECRET');
if ($webhookSecret === false || $webhookSecret === '') {
http_response_code(500);
exit('Webhook secret is not configured');
}
$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_PAYMENT_TIMESTAMP'] ?? '';
$receivedSignature = $_SERVER['HTTP_X_PAYMENT_SIGNATURE'] ?? '';
if ($rawBody === false || $timestamp === '' || $receivedSignature === '') {
http_response_code(400);
exit('Missing required data');
}
if (!ctype_digit($timestamp)) {
http_response_code(400);
exit('Invalid timestamp');
}
$allowedClockSkew = 300;
if (abs(time() - (int) $timestamp) > $allowedClockSkew) {
http_response_code(401);
exit('Expired request');
}
$signedPayload = $timestamp . '.' . $rawBody;
$expectedSignature = hash_hmac(
'sha256',
$signedPayload,
$webhookSecret
);
if (!hash_equals($expectedSignature, $receivedSignature)) {
http_response_code(401);
exit('Invalid signature');
}
$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
$eventId = $payload['event_id'] ?? null;
$orderId = $payload['order_id'] ?? null;
if (!is_string($eventId) || !is_string($orderId)) {
http_response_code(400);
exit('Invalid payload');
}
/*
* 1. 检查 event_id 是否已经处理。
* 2. 使用 order_id 调用支付网关服务端 API 查询交易。
* 3. 核对商户号、金额、币种以及支付状态。
* 4. 在数据库事务中更新订单并记录 event_id。
*/
http_response_code(200);
echo 'OK';
PAYMENT_WEBHOOK_SECRET 应保存在服务器环境变量或专用密钥管理系统中,不能写入前端页面、提交到代码仓库,或放在回跳 URL 中。
主动查询支付状态
即使签名验证通过,对于关键支付结果,也建议通过服务端再次查询。伪代码如下:
<?php
$transaction = $gatewayClient->getTransaction($transactionId);
if ($transaction->merchantId !== EXPECTED_MERCHANT_ID) {
throw new RuntimeException('Merchant mismatch');
}
if ($transaction->orderId !== $localOrder->gatewayOrderId) {
throw new RuntimeException('Order mismatch');
}
if ($transaction->amount !== $localOrder->amount) {
throw new RuntimeException('Amount mismatch');
}
if ($transaction->currency !== $localOrder->currency) {
throw new RuntimeException('Currency mismatch');
}
if ($transaction->status !== 'paid') {
throw new RuntimeException('Payment is not complete');
}
金额应使用网关返回的最小货币单位,例如“分”,或者使用精确的十进制定点类型。不要用二进制浮点数直接比较金额。
浏览器回跳不能作为支付凭证
支付完成后,网关可能会把用户浏览器重定向到类似下面的地址:
https://merchant.example.com/payment/return
这类回跳 URL 主要用于展示结果。用户可以修改查询参数、重复访问页面,甚至完全跳过回跳。因此,不能根据回跳结果直接把订单标记为已支付。
各部分可以这样分工:
- 浏览器回跳:向用户显示“正在确认”或当前订单状态。
- webhook:接收网关发送的异步通知。
- 服务端 API:查询并确认最终交易状态。
- 本地数据库:保存核验后的订单状态。
还需要注意什么
支付通知可能重复、乱序或延迟到达,因此处理逻辑必须保证幂等。可以为网关事件 ID 建立唯一索引,也可以限制订单只能按照合法路径变更状态,例如只允许从 pending 更新为 paid,避免重复通知导致再次发货。
密钥应支持轮换。日志中不要记录完整密钥、银行卡信息或其他敏感数据,接口也必须使用 HTTPS。如果支付网关支持非对称签名、官方 SDK、mTLS 或固定 IP 列表,应按照其官方方案接入。IP 白名单和 mTLS 可以作为额外防线,但不能代替对通知签名和交易内容的验证。
备注:内容仅供参考。
