PAYATHON 2026

使用 PHP 和 cURL 创建支付页面时需注意哪些安全事项?

支付小周

结论

只启用 SSL/TLS 并不足以保护支付页面。更重要的是,尽量不要让银行卡号、CVV 等敏感数据经过自己的服务器。优先使用支付网关提供的托管收银台、Hosted Fields 或客户端令牌化方案。

服务端还必须重新计算金额、检查订单状态、验证支付回调签名,并妥善保管 API 密钥。前端提交的金额、商品信息以及”支付成功”状态均不能直接作为可信依据。

尽量减少接触银行卡数据

如果 PHP 接收到完整卡号或 CVV,即使只是通过 cURL 转发,服务器也可能进入更严格的 PCI DSS 合规范围。日志、异常追踪、数据库备份和内存泄漏也可能暴露这些数据。

通常可以采用以下流程:

  1. 服务端创建订单或支付会话。
  2. 浏览器跳转到支付网关的托管页面,或者通过网关 SDK 将卡片信息直接发送给网关。
  3. 网关返回一次性 token、payment method ID 或 session ID。
  4. PHP 使用该标识完成后续交易,不接触原始卡片信息。
  5. 服务端通过签名回调或主动查询确认最终支付状态。

不要自行存储 CVV。除非有明确的业务需求和合规依据,也不要保存完整卡号。展示银行卡信息时,通常只保留品牌、末四位以及必要的到期信息,具体做法应符合支付网关及适用的 PCI DSS 要求。

金额和订单必须由服务端确定

攻击者可以修改 HTML、JavaScript 和请求参数,因此以下内容不能以浏览器提交的数据为准:

  • 支付金额
  • 币种
  • 商品单价
  • 折扣
  • 用户身份
  • 订单归属
  • 支付是否成功

浏览器最好只提交内部订单 ID。PHP 应根据当前登录用户从数据库读取订单,再重新计算应付金额。

<?php

declare(strict_types=1);

session_start();

$orderId = filter_input(INPUT_POST, 'order_id', FILTER_VALIDATE_INT);

if (!$orderId) {
    http_response_code(400);
    exit('Invalid order');
}

// 示例函数,需要根据实际数据库实现。
$order = findPendingOrderForUser($orderId, (int) $_SESSION['user_id']);

if ($order === null) {
    http_response_code(404);
    exit('Order not found');
}

// 金额应存储为最小货币单位,例如分,避免浮点数误差。
$amountInCents = calculateOrderTotalInCents($order);

if ($amountInCents <= 0) {
    http_response_code(400);
    exit('Invalid amount');
}

不要使用二进制浮点数直接计算货币金额。可以统一用整数保存最小货币单位,例如用 1099 表示 10.99 元。不同币种的最小单位规则可能不同,应按照支付网关的币种说明处理。

防止 CSRF、越权和重复支付

支付请求属于敏感操作,需要验证登录状态、订单归属和 CSRF token。

生成 token:

<?php

session_start();

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>

<form method="post" action="/create-payment.php">
    <input
        type="hidden"
        name="csrf_token"
        value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>"
    >
    <input type="hidden" name="order_id" value="123">
    <button type="submit">支付</button>
</form>

服务端验证:

<?php

session_start();

$submittedToken = $_POST['csrf_token'] ?? '';
$storedToken = $_SESSION['csrf_token'] ?? '';

if (
    !is_string($submittedToken) ||
    !is_string($storedToken) ||
    !hash_equals($storedToken, $submittedToken)
) {
    http_response_code(403);
    exit('Invalid CSRF token');
}

每次支付还应使用唯一的幂等键,避免网络重试、重复点击或并发请求造成重复扣款。如果支付网关提供官方的 idempotency key 功能,应使用其规定的请求头或参数。不同网关使用的具体名称并不通用。

服务器本身也需要设置相应约束:

  • 一个订单只能有一个已确认的成功支付。
  • 每个支付请求使用唯一流水号。
  • 通过数据库事务或条件更新修改订单状态。
  • 对正在处理的订单实施并发控制。
  • 禁用前端提交按钮只能改善操作体验,不能代替服务端防重。

安全配置 PHP cURL

cURL 必须验证支付网关的 TLS 证书和主机名。遇到证书错误时,不要关闭验证来绕过问题。

<?php

declare(strict_types=1);

$gatewayUrl = 'https://payments.example.com/api/payments';
$apiKey = getenv('PAYMENT_GATEWAY_API_KEY');

if ($apiKey === false || $apiKey === '') {
    throw new RuntimeException('Payment gateway API key is not configured');
}

$payload = [
    'order_id' => (string) $order['id'],
    'amount'   => $amountInCents,
    'currency' => $order['currency'],
    // 应使用网关生成的 token,不要在这里传递原始卡号或 CVV。
    'payment_method_token' => $paymentMethodToken,
];

$ch = curl_init($gatewayUrl);

curl_setopt_array($ch, [
    CURLOPT_POST            => true,
    CURLOPT_RETURNTRANSFER  => true,
    CURLOPT_POSTFIELDS      => json_encode($payload, JSON_THROW_ON_ERROR),
    CURLOPT_HTTPHEADER      => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        'Accept: application/json',
        'Idempotency-Key: ' . $idempotencyKey,
    ],
    CURLOPT_CONNECTTIMEOUT  => 5,
    CURLOPT_TIMEOUT         => 20,
    CURLOPT_FOLLOWLOCATION  => false,
    CURLOPT_SSL_VERIFYPEER  => true,
    CURLOPT_SSL_VERIFYHOST  => 2,
    CURLOPT_PROTOCOLS       => CURLPROTO_HTTPS,
]);

$responseBody = curl_exec($ch);

if ($responseBody === false) {
    $error = curl_error($ch);
    curl_close($ch);

    // 生产环境中不要向用户输出内部错误或密钥。
    error_log('Payment request transport failure: ' . $error);
    http_response_code(502);
    exit('Payment service is temporarily unavailable');
}

$statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

if ($statusCode < 200 || $statusCode >= 300) {
    error_log('Payment gateway returned HTTP status ' . $statusCode);
    http_response_code(502);
    exit('Payment could not be processed');
}

$response = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR);

示例中的接口地址、字段和 Idempotency-Key 只是常见写法,实际使用时必须替换为支付网关官方文档规定的内容。

还需注意以下几点:

  • 不要允许用户控制支付接口 URL,否则可能引发 SSRF。
  • 默认禁止跟随重定向。只有网关明确要求且目标受到严格限制时,才应开启。
  • 使用操作系统维护的可信 CA。使用私有 CA 时,应显式配置 CURLOPT_CAINFO。
  • 设置连接超时和总请求超时。
  • 不要把 API 密钥写入源码或提交到 Git 仓库。
  • 不要通过 CURLOPT_SSL_VERIFYPEER => false 处理证书问题。
  • 不要在生产环境中开启会记录敏感请求内容的 cURL verbose 日志。

以后端确认的支付结果为准

用户被重定向到”支付成功”页面,并不代表款项已经到账。?success=1 之类的查询参数更不能作为发货依据。

服务端应接收支付网关的回调,并按照网关规定的算法验证签名。下面只演示 HMAC 验签的基本结构。实际使用的签名头、摘要算法、时间戳格式和签名原文必须严格遵循相应网关的文档。

<?php

declare(strict_types=1);

$rawBody = file_get_contents('php://input');
$receivedSignature = $_SERVER['HTTP_X_GATEWAY_SIGNATURE'] ?? '';
$webhookSecret = getenv('PAYMENT_WEBHOOK_SECRET');

if (
    !is_string($rawBody) ||
    $webhookSecret === false ||
    $webhookSecret === ''
) {
    http_response_code(400);
    exit;
}

$expectedSignature = hash_hmac('sha256', $rawBody, $webhookSecret);

if (!hash_equals($expectedSignature, $receivedSignature)) {
    http_response_code(401);
    exit;
}

$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);

// 还需检查事件 ID 是否已处理,以抵抗回调重放。
if (webhookEventAlreadyProcessed($event['id'])) {
    http_response_code(200);
    exit;
}

// 根据自己的订单记录再次核对订单号、金额、币种和商户账号。
processVerifiedPaymentEvent($event);

http_response_code(200);

回调处理至少需要检查:

  • 签名是否合法。
  • 时间戳是否处于允许范围内。
  • 事件 ID 是否已经处理。
  • 商户账号是否匹配。
  • 订单号是否存在并属于当前商户。
  • 金额和币种是否与本地订单一致。
  • 当前支付状态是否允许转换到回调所指示的状态。

如果回调无法通过验证或信息不完整,应调用支付网关的服务端 API 主动查询交易状态,不能转而信任浏览器返回的数据。

保护密钥、会话和日志

API 密钥和 webhook secret 应保存在环境变量或专用密钥管理系统中,并限制生产密钥的访问范围。测试环境与生产环境必须使用不同的凭据。一旦怀疑密钥泄露,应立即轮换。

建议为会话 Cookie 启用以下配置:

<?php

session_set_cookie_params([
    'secure'   => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

session_start();

如果支付流程需要通过跨站 POST 返回,SameSite 可能需要设为 None。此时必须同时启用 Secure,并根据实际流程加强 state、nonce 或签名校验。

日志中不要记录:

  • 完整卡号
  • CVV
  • API 密钥
  • Authorization 请求头
  • 完整支付 token
  • 完整回调内容中的敏感字段
  • 用户密码或会话 ID

为了便于审计和排查,可以记录订单 ID、内部流水号、网关交易 ID、结果状态以及脱敏后的错误信息。

加固支付页面

支付页面还需要采用常规的 Web 安全措施:

  • 校验所有输入的类型、长度、范围和允许值。
  • 向 HTML 输出内容时使用 htmlspecialchars(),防止 XSS。
  • 使用参数化查询,防止 SQL 注入。
  • 配置 Content-Security-Policy,限制脚本、框架和表单的提交来源。
  • 设置 frame-ancestors 或 X-Frame-Options,降低点击劫持风险。如果网关组件必须嵌入 iframe,应按照其文档精确放行。
  • 启用 HSTS,例如 Strict-Transport-Security。
  • 关闭详细的 PHP 错误页面,只向用户返回通用错误信息。
  • 限制登录、支付创建、优惠码尝试和回调接口的请求频率。
  • 及时更新 PHP、cURL、OpenSSL、Web 服务器、框架和依赖包。
  • 为管理后台启用多因素认证,并限制高权限账号。
  • 定期检查依赖漏洞、访问日志和异常支付行为。

上线前检查

上线前至少确认:

  • 卡片数据能否完全绕开自己的服务器。
  • cURL 是否始终验证证书和主机名。
  • 金额和币种是否由服务端生成并复核。
  • 是否实现了 CSRF 防护、订单归属校验和幂等处理。
  • 是否通过签名回调或服务端查询确认付款。
  • 是否能够处理重复回调和乱序回调。
  • 日志、监控和异常报告是否已经脱敏。
  • 测试环境与生产环境的密钥是否相互隔离。
  • 是否符合支付网关合同、当地隐私法规和适用的 PCI DSS 要求。

与其不断给自建卡片表单增加防护,不如先减少系统对敏感支付数据的接触,再在服务端落实订单校验、回调验签、密钥管理和幂等控制。

备注:内容仅供参考。