使用 PHP 和 cURL 创建支付页面时需注意哪些安全事项?
结论
只启用 SSL/TLS 并不足以保护支付页面。更重要的是,尽量不要让银行卡号、CVV 等敏感数据经过自己的服务器。优先使用支付网关提供的托管收银台、Hosted Fields 或客户端令牌化方案。
服务端还必须重新计算金额、检查订单状态、验证支付回调签名,并妥善保管 API 密钥。前端提交的金额、商品信息以及”支付成功”状态均不能直接作为可信依据。
尽量减少接触银行卡数据
如果 PHP 接收到完整卡号或 CVV,即使只是通过 cURL 转发,服务器也可能进入更严格的 PCI DSS 合规范围。日志、异常追踪、数据库备份和内存泄漏也可能暴露这些数据。
通常可以采用以下流程:
- 服务端创建订单或支付会话。
- 浏览器跳转到支付网关的托管页面,或者通过网关 SDK 将卡片信息直接发送给网关。
- 网关返回一次性 token、payment method ID 或 session ID。
- PHP 使用该标识完成后续交易,不接触原始卡片信息。
- 服务端通过签名回调或主动查询确认最终支付状态。
不要自行存储 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 要求。
与其不断给自建卡片表单增加防护,不如先减少系统对敏感支付数据的接触,再在服务端落实订单校验、回调验签、密钥管理和幂等控制。
备注:内容仅供参考。
