PAYATHON 2026

production env为何能使用localhost测试支付网关

支付老李

结论

问题不在前端的 select,而在于 is_testing_host() 把可能受网络架构或请求内容影响的信息当成了生产环境的安全边界。

生产用户能进入测试网关,通常有两个原因:

  1. 生产服务器部署在反向代理、负载均衡器或容器网关之后,PHP 看到的 REMOTE_ADDR 可能始终是 127.0.0.1 或 ::1。这样一来,所有经过本机代理的生产请求都会被误判为测试请求。
  2. request()->getHost() 取自请求中的 Host 信息。如果生产服务器接受 site.test 这个 Host,攻击者就可能构造相应请求。Host 检查不能替代服务端环境配置。

测试网关应该由服务端配置明确启用或禁用。同时,测试支付的回调绝不能把生产订单标记为已支付。

为什么修改前端选项没有复现

前端条件渲染只能控制用户看到的内容,不能用作权限控制。用户可以修改表单、直接发送 HTTP 请求,或者调用支付接口:

POST /payment/request
Content-Type: application/x-www-form-urlencoded

bankName=ZARINPAL

修改 select 后没有进入 Zarinpal,是因为 is_testing_host() 返回 false 时,switch 会继续执行下一个分支:

case Bank::ZARINPAL:
    if (is_testing_host()) {
        return $this->redirectTo(new Zarinpal(), $price, $user);
    }

case Bank::SAMAN:
    return $this->redirectTo(new Saman(), $price, $user);

这段代码没有 break,也没有抛出异常。选择 Zarinpal 但条件不成立时,代码会 fall-through 到 Bank::SAMAN。这能解释为什么测试请求最终跳转到了另一个网关,但不能证明生产环境中的检查没有漏洞。

REMOTE_ADDR 检查的问题

REMOTE_ADDR 表示直接连接到 PHP 或 Web 服务器的一端,不一定是最终用户。

例如,生产环境中的请求链路可能是:

用户 -> CDN -> Nginx -> PHP-FPM

如果 Nginx 通过本机网络转发请求,应用看到的地址可能是:

$_SERVER['REMOTE_ADDR'] === '127.0.0.1'

此时,下面的判断会错误地开放测试网关:

in_array($_SERVER['REMOTE_ADDR'], ['localhost', '127.0.0.1', '::1'])

另外,REMOTE_ADDR 通常是 IP 地址,不会是字符串 localhost,因此列表中的 localhost 基本没有作用。

也不要简单地改用 X-Forwarded-For。这个请求头可能被伪造,只有正确配置并信任固定的反向代理后,才能通过它获取客户端地址。即便拿到了真实的客户端 IP,也不应根据 IP 地址判断应用是否处于生产环境。

Hostname 检查的问题

request()->getHost() 通常根据请求的 Host 头和框架的可信代理配置得出。Host 不能作为可靠的部署环境标识。

攻击者可能:

  • 直接向服务器 IP 发送请求,并指定 Host: site.test;
  • 在自己的 hosts 文件中将 site.test 指向生产服务器;
  • 利用错误的虚拟主机、反向代理或可信代理配置;
  • 通过代理传入 X-Forwarded-Host,是否生效取决于框架配置。

Web 服务器应拒绝未知 Host,但即使已经做了限制,也不应根据 Host 决定是否开放测试支付能力。

推荐的修复方式

在服务端配置中显式控制测试网关。例如,在 Laravel 配置文件中加入开关:

// config/payment.php

return [
    'test_gateway_enabled' => (bool) env('TEST_PAYMENT_GATEWAY_ENABLED', false),
];

生产环境必须设置为:

TEST_PAYMENT_GATEWAY_ENABLED=false

只有测试环境才设置为:

TEST_PAYMENT_GATEWAY_ENABLED=true

不要在业务代码中直接调用 env()。Laravel 启用配置缓存后,业务代码读取环境变量可能得到不符合预期的结果,应统一通过 config() 访问配置。

控制器可以改为显式拒绝请求:

function request($bankName, $price, $user = null)
{
    switch (strtoupper($bankName)) {
        case Bank::MELLAT:
            return $this->redirectTo(new Mellat(), $price, $user);

        case Bank::ZARINPAL:
            if (!config('payment.test_gateway_enabled', false)) {
                abort(403, 'Test gateway is disabled');
            }

            return $this->redirectTo(new Zarinpal(), $price, $user);

        case Bank::SAMAN:
            return $this->redirectTo(new Saman(), $price, $user);

        case Bank::SAMANKISH:
            return $this->redirectTo(new SamanKish(), $price, $user);

        default:
            throw new \InvalidArgumentException('Gateway not found');
    }
}

还可以同时校验 Laravel 环境,增加一层限制:

private function canUseTestGateway(): bool
{
    return app()->environment(['local', 'testing'])
        && config('payment.test_gateway_enabled', false);
}

然后在对应分支中调用:

case Bank::ZARINPAL:
    if (!$this->canUseTestGateway()) {
        abort(403, 'Test gateway is disabled');
    }

    return $this->redirectTo(new Zarinpal(), $price, $user);

判断依据必须来自服务器的部署配置,不能依赖客户端 IP、Host、表单值或其他请求头。

还要保护支付回调

只限制跳转到网关还不够。真正决定是否交付商品的是支付验证和订单状态变更逻辑。

创建订单时,应在服务端保存实际使用的网关和运行模式:

$order->gateway = Bank::SAMANKISH;
$order->payment_mode = 'production';
$order->amount = $price;
$order->status = 'pending';
$order->save();

处理回调时,应检查:

if ($order->payment_mode !== 'production') {
    abort(403, 'Invalid payment mode');
}

if ($order->gateway !== Bank::SAMANKISH) {
    abort(403, 'Gateway mismatch');
}

还必须做到以下几点:

  • 使用对应网关的服务端 API 验证交易,不能相信回调参数;
  • 校验订单号、金额、币种、商户号和交易状态;
  • 验证签名或 MAC;
  • 防止同一笔交易重复入账;
  • 不允许使用测试商户凭据验证生产订单;
  • 不直接采用用户提交的商品价格;
  • 在支付成功与发货之间保留可审计的交易记录。

如何排查这次事件

仅凭现有代码无法判断该用户具体采用了哪种方法,需要检查事发时的请求和订单记录。重点查看:

  • 应用当时记录的 REMOTE_ADDR;
  • Host、X-Forwarded-Host 和 X-Forwarded-For;
  • 生产服务器前是否存在 Nginx、CDN、负载均衡器或容器代理;
  • 创建订单时选择的网关,而不只是回调中的网关名称;
  • 使用的是测试商户凭据还是生产商户凭据;
  • 支付回调是否确实经过网关的服务端验证;
  • 是否存在旧订单、重复回调、人工修改状态或其他入口;
  • 生产配置缓存中测试网关开关的实际值。

记录这些信息时,不要将密钥、完整银行卡信息或敏感支付数据写入日志。

注意事项

修复 switch 的 fall-through 可以让拒绝行为更明确,但这并不是完整的安全修复。需要从设计上移除根据 Host 或来源 IP 开放测试支付的做法。

如果生产环境完全不需要测试网关,最稳妥的方案是不要在生产部署中注册相关路由、服务和凭据。这样即使有人伪造表单、Host 或请求头,生产进程也无法创建测试支付。

备注:内容仅供参考。