PAYATHON 2026

如何确保 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:用于保护用户登录状态下的浏览器操作,不适合验证第三方支付网关的身份。

推荐的验证流程

收到支付通知后,可按以下顺序处理:

  1. 读取原始请求体,不要先解析再重新拼接。
  2. 从请求头中读取签名、时间戳等字段。
  3. 按照支付网关规定的算法,使用 webhook secret 计算签名。
  4. 使用恒定时间比较函数核验签名。
  5. 检查时间戳是否处于允许范围内,防止旧请求被重复发送。
  6. 检查事件 ID 或通知 ID 是否已经处理。
  7. 根据订单号调用支付网关的服务端 API,再次查询实际支付状态。
  8. 核对订单号、商户号、金额、币种和支付状态。
  9. 在数据库事务中更新订单,同时记录通知内容和处理结果。
  10. 返回支付网关要求的成功响应。

签名字段、拼接顺序、编码方式和响应格式都必须以支付网关的官方文档为准,不能自行设计一种规则再套用到现有网关。

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 可以作为额外防线,但不能代替对通知签名和交易内容的验证。

备注:内容仅供参考。