PAYATHON 2026

Sermepa payment system 与 CodeIgniter 的 CSRF 问题

支付老李

结论

不要全局关闭 CodeIgniter 的 CSRF 保护。应该为 Sermepa(在现有环境中也可能称为 Redsys)单独设置服务器回调地址,只将这个 URI 排除在 CSRF 校验之外,再使用支付平台提供的签名算法验证通知是否真实。

CSRF Token 用于保护浏览器表单和用户会话。Sermepa 的服务器拿不到网站会话中的 Token,因此它发送的后台 POST 无法通过常规的 CSRF 校验。支付回调的安全性应由签名验证、金额和订单核对、幂等处理以及 HTTPS 来保证。

CodeIgniter 3 的配置方式

假设回调地址是:

https://example.com/payment/sermepa-notification

在 application/config/config.php 中仅排除这个地址:

$config['csrf_protection'] = TRUE;

$config['csrf_exclude_uris'] = [
    'payment/sermepa-notification',
];

对应的路由可以这样写:

$route['payment/sermepa-notification']['post'] =
    'payment/sermepa_notification';

不同的 CodeIgniter 3 小版本可能对 HTTP Method 路由有不同支持。如果项目不支持这种写法,可以改用普通路由,并在控制器中明确限制请求方法:

$route['payment/sermepa-notification'] =
    'payment/sermepa_notification';

控制器示例:

<?php

defined('BASEPATH') OR exit('No direct script access allowed');

class Payment extends CI_Controller
{
    public function sermepa_notification()
    {
        if ($this->input->method(TRUE) !== 'POST') {
            return $this->output
                ->set_status_header(405)
                ->set_header('Allow: POST')
                ->set_output('Method Not Allowed');
        }

        $signatureVersion   = $this->input->post('Ds_SignatureVersion', TRUE);
        $merchantParameters = $this->input->post('Ds_MerchantParameters', FALSE);
        $receivedSignature  = $this->input->post('Ds_Signature', FALSE);

        if (
            empty($signatureVersion) ||
            empty($merchantParameters) ||
            empty($receivedSignature)
        ) {
            return $this->output
                ->set_status_header(400)
                ->set_output('Invalid notification');
        }

        try {
            /*
             * 使用 Sermepa/Redsys 针对当前协议版本提供的官方库:
             *
             * 1. 解码 Ds_MerchantParameters;
             * 2. 根据商户密钥重新计算通知签名;
             * 3. 以恒定时间方式比较签名;
             * 4. 读取订单号、金额、币种和 Ds_Response。
             *
             * 不要仅因为 POST 中的 Ds_Response 表示成功,
             * 就直接把订单标记为已支付。
             */
            $notification = $this->verifySermepaNotification(
                $signatureVersion,
                $merchantParameters,
                $receivedSignature
            );

            if (!$notification['signature_valid']) {
                log_message('error', 'Invalid Sermepa notification signature');

                return $this->output
                    ->set_status_header(400)
                    ->set_output('Invalid signature');
            }

            $order = $this->order_model->findByReference(
                $notification['order']
            );

            if (
                !$order ||
                $order->amount_minor !== $notification['amount_minor'] ||
                $order->currency !== $notification['currency']
            ) {
                log_message(
                    'error',
                    'Sermepa notification does not match local order'
                );

                return $this->output
                    ->set_status_header(400)
                    ->set_output('Order mismatch');
            }

            if ($notification['payment_accepted']) {
                /*
                 * 必须以订单号或交易标识做幂等更新。
                 * 同一通知可能被支付平台重复发送。
                 */
                $this->order_model->markPaidOnce(
                    $order->id,
                    $notification['transaction_id']
                );
            } else {
                $this->order_model->recordRejectedPayment(
                    $order->id,
                    $notification
                );
            }

            return $this->output
                ->set_content_type('text/plain')
                ->set_status_header(200)
                ->set_output('OK');
        } catch (Throwable $e) {
            log_message(
                'error',
                'Sermepa notification error: ' . $e->getMessage()
            );

            return $this->output
                ->set_status_header(500)
                ->set_output('Internal Server Error');
        }
    }

    private function verifySermepaNotification(
        $signatureVersion,
        $merchantParameters,
        $receivedSignature
    ) {
        /*
         * 此处应封装与当前 Sermepa/Redsys 接口版本匹配的官方签名库。
         * 返回字段只是业务层示意,不代表官方库的固定 API。
         */
        throw new RuntimeException('Signature verifier is not configured');
    }
}

这里没有自行实现签名算法,因为 Sermepa/Redsys 的字段、编码和签名方式取决于实际接入的协议及版本。应该使用对应版本的官方 SDK 或接入文档,以免在 Base64、Base64 URL、安全填充或密钥派生环节引入不易发现的错误。

CodeIgniter 4 的配置方式

如果项目使用 CodeIgniter 4,可以在 app/Config/Filters.php 中为精确路径设置例外:

public array $globals = [
    'before' => [
        'csrf' => [
            'except' => [
                'payment/sermepa-notification',
            ],
        ],
    ],
];

然后将路由限制为 POST:

$routes->post(
    'payment/sermepa-notification',
    'Payment::sermepaNotification'
);

控制器仍然需要验证 Sermepa/Redsys 的签名。将路径排除在 CSRF 校验之外,只是允许请求进入应用,并不代表通知本身可信。

回调处理的正确顺序

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

  1. 只允许 POST,拒绝其他 HTTP Method。
  2. 检查签名版本、商户参数和签名字段是否齐全。
  3. 使用商户密钥验证签名。
  4. 签名通过后,再解读支付结果。
  5. 根据订单号查询本地订单。
  6. 核对金额、币种、商户号和终端号等关键数据。
  7. 使用订单号或平台交易号执行幂等更新。
  8. 处理成功后尽快返回 HTTP 200 和简单的文本响应。

不要根据用户支付完成后的浏览器跳转确认付款。用户可能关闭浏览器,网络也可能中断,跳转参数还可能被篡改。订单状态应该以通过验证的服务器通知为准。

常见注意事项

CSRF 排除项要尽可能精确,不要排除整个控制器,也不要使用 payment/.* 这类宽泛路径。普通结账表单、登录表单和后台操作仍然需要 CSRF 保护。

回调地址通常不应该依赖登录状态、Session Cookie 或浏览器跳转逻辑。收到通知后,也不要返回登录页、HTML 错误页或 302 重定向。

支付平台可能会因为请求超时或没有收到预期响应而重试,所以数据库更新必须具备幂等性。例如,可以为平台交易号建立唯一索引,并在事务中检查订单是否已经支付。

IP 白名单可以作为额外限制,但不能替代签名验证。支付机构的出口 IP 可能发生变化,使用前应查看当前商户后台或官方接入资料。

调试时可以记录请求到达时间、订单号、签名验证结果和响应状态,但不要记录商户密钥、完整银行卡资料或其他敏感支付信息。

如果配置排除后仍然返回 500,需要继续检查 CodeIgniter 日志和 Web 服务器日志。CSRF 错误页、路由未命中、PHP 异常、WAF 拦截以及控制器依赖 Session,都可能让支付平台看到相同的 500 error。可以先确认回调路由能够接收 POST 并返回 200 OK,再接入真实的签名校验和订单更新逻辑。

备注:内容仅供参考。