PAYATHON 2026

如何通过 IP 安全验证 POST 请求?

支付阿杰

结论

如果回调请求没有数字签名或共享密钥,而且无法通过 NAB Transact 的服务端 API 查询交易状态,就无法从密码学角度证明该 POST 请求一定来自 NAB Transact。

来源 IP 白名单可以降低风险,但不能作为确认付款成功的唯一依据。处理回调时还需要:

  1. 使用 NAB Transact 官方提供并维护的出口 IP 白名单。
  2. 全程使用 HTTPS。
  3. 严格核对订单号、金额、币种和交易状态。
  4. 检查交易编号是否唯一,并做好幂等处理。
  5. 如果有交易查询、对账或结算接口,以服务端查询结果为准。
  6. 无法二次确认的回调先标记为「待核验」,不要直接发货或充值。

IP 是否容易被伪造

HTTP/HTTPS 建立在 TCP 之上。攻击者通常无法仅靠伪造源 IP 完成有效的 POST 请求,因为 TCP 连接需要完成握手,响应数据也会发往被伪造的地址。

不过,IP 白名单并不等于可靠的身份认证:

  • NAB Transact 的出口 IP 可能发生变化。
  • 白名单内的服务器或网络可能遭到入侵。
  • 如果反向代理配置错误,应用可能误信 X-Forwarded-For。
  • DNS 和网络路由攻击的门槛较高,但并非完全不可能。
  • IP 地址只能说明网络来源,无法证明请求内容未被篡改,也无法阻止合法请求被重放。

IP 检查适合用来缩小攻击面,不能代替 HMAC、数字签名、mTLS 或服务端交易查询。

推荐的处理流程

收到回调后,可以按以下顺序处理:

  1. 从真实连接信息中获取客户端 IP。
  2. 检查该 IP 是否属于 NAB Transact 官方公布的地址或 CIDR 网段。
  3. 拒绝不符合预期的 HTTP 方法和 Content-Type。
  4. 只按字段白名单解析数据,不要直接信任整个 $_POST。
  5. 根据订单号读取本地订单,不要把回调传入的价格当作订单价格。
  6. 核对金额、币种和商户编号等重要字段。
  7. 检查网关交易编号是否已经处理,防止重复回调。
  8. 如果网关支持交易查询,使用服务端保存的凭据查询并核对结果。
  9. 在数据库事务中更新订单,并通过唯一索引保证幂等性。
  10. 保存原始回调、来源 IP、接收时间和处理结果,以便审计和对账。

如果第 8 步无法执行,IP 白名单和业务字段校验只能算风险控制,不能算强认证。对于高价值订单,可以等结算对账完成或经过人工复核后再履约。

PHP 实现 IP 白名单

下面的示例支持单个 IPv4/IPv6 地址和 CIDR 网段:

<?php

declare(strict_types=1);

function ipInCidr(string $ip, string $cidr): bool
{
    if (!str_contains($cidr, '/')) {
        return hash_equals($cidr, $ip);
    }

    [$network, $prefixLength] = explode('/', $cidr, 2);

    $ipBinary = inet_pton($ip);
    $networkBinary = inet_pton($network);

    if ($ipBinary === false || $networkBinary === false) {
        return false;
    }

    if (strlen($ipBinary) !== strlen($networkBinary)) {
        return false;
    }

    $prefixLength = filter_var(
        $prefixLength,
        FILTER_VALIDATE_INT,
        ['options' => ['min_range' => 0, 'max_range' => strlen($ipBinary) * 8]]
    );

    if ($prefixLength === false) {
        return false;
    }

    $wholeBytes = intdiv($prefixLength, 8);
    $remainingBits = $prefixLength % 8;

    if (
        $wholeBytes > 0 &&
        substr($ipBinary, 0, $wholeBytes) !== substr($networkBinary, 0, $wholeBytes)
    ) {
        return false;
    }

    if ($remainingBits === 0) {
        return true;
    }

    $mask = (0xFF << (8 - $remainingBits)) & 0xFF;

    return (
        ord($ipBinary[$wholeBytes]) & $mask
    ) === (
        ord($networkBinary[$wholeBytes]) & $mask
    );
}

function ipIsAllowed(string $ip, array $allowedSources): bool
{
    if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
        return false;
    }

    foreach ($allowedSources as $source) {
        if (ipInCidr($ip, $source)) {
            return true;
        }
    }

    return false;
}

$allowedSources = [
    // 只能填写由 NAB Transact 官方确认的地址。
    '203.0.113.10',
    '2001:db8:1234::/48',
];

$remoteIp = $_SERVER['REMOTE_ADDR'] ?? '';

if (!ipIsAllowed($remoteIp, $allowedSources)) {
    error_log(sprintf('Rejected payment callback from IP %s', $remoteIp));
    http_response_code(403);
    exit('Forbidden');
}

示例中使用的是文档保留地址,不能直接用于生产环境。实际 IP 应由 NAB Transact 书面确认,同时还要确认地址发生变化时是否会收到通知。

正确处理反向代理

如果 PHP 直接接收公网连接,应使用:

$remoteIp = $_SERVER['REMOTE_ADDR'] ?? '';

不要直接信任:

$_SERVER['HTTP_X_FORWARDED_FOR']

X-Forwarded-For 只是普通的 HTTP 请求头,外部调用者可以自行填写。

如果应用位于 Cloudflare、负载均衡器或 Nginx 反向代理之后,REMOTE_ADDR 通常记录的是代理服务器地址。此时需要在代理层配置「可信代理」,只允许已知代理覆盖客户端 IP,再让应用读取代理清洗后的结果。例如,Nginx 可以使用 real_ip 模块,但 set_real_ip_from 必须限定为真实的代理网段,不能信任任意来源。

校验回调内容并保证幂等

即使来源 IP 在白名单内,也不能直接把订单标记为已支付。至少要核对本地订单信息:

<?php

declare(strict_types=1);

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    http_response_code(405);
    exit('Method Not Allowed');
}

$orderId = filter_input(INPUT_POST, 'order_id', FILTER_UNSAFE_RAW);
$transactionId = filter_input(INPUT_POST, 'transaction_id', FILTER_UNSAFE_RAW);
$status = filter_input(INPUT_POST, 'status', FILTER_UNSAFE_RAW);
$amount = filter_input(INPUT_POST, 'amount', FILTER_UNSAFE_RAW);
$currency = filter_input(INPUT_POST, 'currency', FILTER_UNSAFE_RAW);

if (
    !is_string($orderId) ||
    !is_string($transactionId) ||
    !is_string($status) ||
    !is_string($amount) ||
    !is_string($currency)
) {
    http_response_code(400);
    exit('Invalid callback');
}

// $order 必须根据 $orderId 从数据库读取。
// 金额应使用最小货币单位(例如 cents)或十进制定点数处理,避免 float。
$order = loadOrderFromDatabase($orderId);

if ($order === null) {
    http_response_code(404);
    exit('Unknown order');
}

if ($currency !== $order->currency) {
    http_response_code(400);
    exit('Currency mismatch');
}

if (normalizeMoney($amount) !== $order->amountInMinorUnits) {
    http_response_code(400);
    exit('Amount mismatch');
}

if (transactionAlreadyProcessed($transactionId)) {
    // 重复通知应返回成功,避免网关持续重试。
    http_response_code(200);
    exit('OK');
}

if ($status !== 'approved') {
    recordPaymentAttempt($orderId, $transactionId, $status);
    http_response_code(200);
    exit('OK');
}

/*
 * 如果网关提供交易查询能力,应在此查询并核对:
 * - transactionId
 * - merchant/order reference
 * - amount
 * - currency
 * - final payment status
 *
 * 如果完全没有查询能力,建议先标记为 pending_verification。
 */
markPaymentPendingVerification($orderId, $transactionId);

http_response_code(200);
echo 'OK';

这里的字段名和状态值只是结构示例,必须替换成 NAB Transact 实际回调协议中的名称,不能假定 approved 是官方状态值。

数据库还应为网关交易编号添加唯一约束,例如:

CREATE UNIQUE INDEX uq_payment_transaction_id
ON payments (transaction_id);

即使两个相同的回调同时到达,唯一约束也能防止重复入账、发货或充值。

不建议使用反向 DNS 进行认证

使用 gethostbyaddr() 对来源 IP 做 reverse lookup,无法可靠地认证请求。PTR 记录由 IP 地址的控制方管理,查询得到的名称本身不能证明服务器属于某个支付网关。

如果确实要把 DNS 作为额外的判断信号,至少应执行 forward-confirmed reverse DNS:

  1. 查询来源 IP 的 PTR 记录。
  2. 再查询所得主机名的 A/AAAA 记录。
  3. 确认查询结果包含原始 IP。
  4. 检查主机名是否位于预期域名下。

即使这些检查全部通过,DNS 也只能作为辅助手段,不能代替签名或交易查询。

可以向 NAB Transact 确认的安全能力

应向 NAB Transact 确认当前产品或账户配置是否支持以下能力:

  • 带 HMAC 或数字签名的回调。
  • 可配置的 HTTP Basic Authentication。
  • mTLS 客户端证书。
  • 服务端交易查询或对账接口。
  • 官方固定出口 IP/CIDR 列表。
  • 带高熵随机值的独立回调 URL。

随机回调 URL 能减少被扫描和随意伪造的概率,但它本质上仍是 bearer secret,也可能出现在日志、监控和配置记录中,因此只能作为附加措施。

如果这些能力都不存在,系统就只能对回调进行风险判断,无法完成强身份认证。此时不能仅凭回调自动执行不可逆操作,而应通过订单字段核对、幂等控制、延迟履约和结算对账来控制风险。

备注:内容仅供参考。