PHP 移动支付回调仅检查服务器 IP 是否安全?
结论
来源 IP 检查可以作为附加防线,但不应成为支付回调的唯一认证方式。
对于普通 HTTP/HTTPS 回调,攻击者通常无法仅靠伪造源 IP 完成有效的 TCP 请求,因为 TCP 连接需要完成握手并接收响应。不过,IP 白名单仍有明显局限。供应商的 IP 可能变更或被多个系统共享,服务器也可能遭到入侵,请求还可能经过代理。仅检查 IP 也无法防止回调重放、金额篡改和重复发放。
更可靠的做法是使用 HTTPS,并通过 HMAC、非对称数字签名或支付平台的订单查询接口验证回调。同时还要核对订单数据、限制请求的有效时间,并通过幂等机制避免重复入账。
为什么伪造 IP 不等于能够发送 HTTP 请求
GET 和 POST 通常运行在 TCP 之上。建立 TCP 连接需要经过三次握手:
- 客户端发送
SYN。 - 服务器向源 IP 返回
SYN-ACK。 - 客户端收到响应后发送正确的
ACK。
如果攻击者伪造了一个不属于自己的源 IP,服务器会把响应发往被冒用的地址。攻击者通常无法收到 TCP 序列号,也就很难建立连接并提交完整的 HTTP 请求。
因此,“伪造一个 IP,然后正常调用支付回调接口”并不像伪造某些无连接 UDP 数据包那么简单。除非攻击者能够控制相关网络路径、劫持路由、预测连接状态,或者已经进入受信任的服务器或网络,否则一般无法完成有效请求。
这只能说明简单伪造源地址并不容易,不能证明 IP 白名单足以保护支付业务。
仅检查 IP 的风险
无法证明请求代表真实交易
IP 白名单只能表明连接来自某个地址,无法证明请求对应的订单已经支付成功。如果供应商服务器、代理节点或使用同一出口地址的其他系统被利用,攻击者仍有可能构造回调。
无法防止重放
攻击者一旦获取某次合法回调的内容,就可能反复发送。即使每次请求都来自正确的 IP,只要系统没有幂等控制,用户仍可能被重复发放点数。
无法保护参数完整性
只检查 IP,无法确认 amount、order_id、user_id 等参数是否被修改。如果请求在供应商内部系统、代理链或配置错误的明文链路中遭到篡改,IP 检查无法发现异常。
IP 地址可能变化或共享
供应商可能使用负载均衡、云服务、CDN、IPv6 或多个出口地址。这些地址可能临时变化,也可能由多个租户共享。白名单包含的 CIDR 网段越大,信任范围也越大。
代理头可能被伪造
不要直接信任客户端提交的以下请求头:
X-Forwarded-For
X-Real-IP
Forwarded
Client-IP
如果应用直接暴露在公网,这些请求头通常可以由请求方任意填写。只有在请求确定经过自己管理的反向代理,并且应用端口无法绕过代理直接访问时,才能按照明确的代理配置解析这些字段。
推荐的验证流程
收到回调后,可以按以下顺序处理:
- 只接受 HTTPS 请求。
- 限制允许的 HTTP 方法,例如只允许
POST。 - 检查实际连接的来源 IP,将其作为辅助过滤条件。
- 验证 HMAC 或非对称数字签名。
- 核对订单号、商品、币种、金额、商户号和支付状态。
- 检查时间戳与随机数,拒绝过期或已经使用的请求。
- 通过支付平台的服务端 API 主动查询订单状态。
- 在数据库事务中执行幂等入账。
- 保存回调原始内容和验证结果,供审计与排查使用。
如果供应商既不提供签名,也没有订单查询接口,应要求其提供固定 IP 列表、IP 变更通知机制和重试规则。对于具有实际价值的虚拟货币或点数,仅靠 IP 白名单通常不足以控制风险。
PHP 中正确获取来源 IP
应用没有经过反向代理时,可以使用 REMOTE_ADDR:
<?php
$remoteIp = $_SERVER['REMOTE_ADDR'] ?? '';
$allowedIps = [
'203.0.113.10',
'203.0.113.11',
];
if (!in_array($remoteIp, $allowedIps, true)) {
http_response_code(403);
exit('Forbidden');
}
不要直接把客户端传入的代理头当作来源地址:
<?php
// 不安全:攻击者可以自行设置该请求头。
$remoteIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
如果使用 Nginx、负载均衡器或 CDN,应在代理层配置可信代理范围,由代理统一处理真实地址,并禁止外部流量绕过代理直接访问 PHP 服务。具体配置取决于实际网络架构,PHP 本身无法自动判断哪些代理头可信。
使用 HMAC 验证回调
普通 MD5 摘要不等同于安全签名。如果方案只是 md5(参数拼接),安全性会受到拼接规则和密钥处理方式的影响,还可能出现歧义、重放或其他设计问题。更合适的方式是使用带密钥的 HMAC,例如 HMAC-SHA-256。
<?php
$secret = getenv('PAYMENT_WEBHOOK_SECRET');
if ($secret === false || $secret === '') {
http_response_code(500);
exit('Webhook secret is not configured');
}
$rawBody = file_get_contents('php://input');
$receivedSignature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expectedSignature = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expectedSignature, $receivedSignature)) {
http_response_code(401);
exit('Invalid signature');
}
签名应根据收到的原始请求体计算。不要先解析 JSON 再重新序列化,否则字段顺序、空白或转义方式发生变化时,签名可能不一致。签名格式、编码方式以及是否包含时间戳,都必须严格遵循供应商协议。
如果供应商只能提供 MD5 验证,需要确认计算过程是否包含只有双方知道的密钥。没有密钥的 MD5 只是校验和,任何能修改参数的人都可以重新计算。即使包含密钥,也不能自动解决重放和重复入账问题。
防止重复发放
支付回调可能因网络超时被供应商重复发送,因此订单处理必须具备幂等性。可以在数据库中为供应商交易号建立唯一约束:
CREATE UNIQUE INDEX uq_payment_provider_transaction
ON payments (provider, provider_transaction_id);
处理回调时,应在事务中锁定本地订单,并确认订单尚未入账:
<?php
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'SELECT status, expected_amount, currency
FROM orders
WHERE order_id = ?
FOR UPDATE'
);
$stmt->execute([$orderId]);
$order = $stmt->fetch(PDO::FETCH_ASSOC);
if ($order === false) {
throw new RuntimeException('Unknown order');
}
if ($order['status'] === 'paid') {
$pdo->commit();
http_response_code(200);
exit('OK');
}
if (
$order['expected_amount'] !== $paidAmount ||
$order['currency'] !== $currency
) {
throw new RuntimeException('Payment details do not match');
}
// 这里还应确认签名有效,并按供应商协议查询实际支付状态。
$update = $pdo->prepare(
"UPDATE orders
SET status = 'paid', provider_transaction_id = ?
WHERE order_id = ?"
);
$update->execute([$providerTransactionId, $orderId]);
$credit = $pdo->prepare(
'UPDATE users SET credits = credits + ? WHERE user_id = ?'
);
$credit->execute([$credits, $userId]);
$pdo->commit();
http_response_code(200);
echo 'OK';
} catch (Throwable $e) {
$pdo->rollBack();
http_response_code(400);
echo 'Rejected';
}
金额不宜使用浮点数比较。更稳妥的方式是使用最小货币单位的整数,例如将 10.25 元表示为 1025 分。如果使用定点小数,数据库和应用都应按照明确的精度处理。
最低可接受方案
如果供应商确实只支持 IP 白名单,可以采取以下补偿措施:
- 严格限定供应商公布的单个 IP 或最小 CIDR 范围。
- 使用 HTTPS 并验证证书,不接受明文 HTTP。
- 通过服务端订单查询接口再次确认支付状态。
- 只处理本地已经存在的订单,不允许回调临时指定任意用户和奖励数量。
- 核对订单金额、币种、商品和商户标识。
- 对交易号设置唯一约束,确保重复回调不会造成重复发放。
- 记录来源 IP、请求时间、订单号和处理结果。
- 在请求频率异常、金额不符或订单未知时触发告警。
- 与供应商建立 IP 变更通知流程,避免临时扩大白名单。
如果供应商连订单查询接口也不提供,可以先将交易标记为”待确认”,等到通过对账文件或供应商后台确认后,再发放高价值资产。IP 检查可以拦截大量普通伪造请求,但不能完整证明支付的真实性。
备注:内容仅供参考。
