在 PHP 中创建自制 paylink 是否安全?
结论
不安全。meta robots 不具备访问控制能力,而 md5(time()) 生成的 key 很容易预测,也可能重复。支付链接应通过密码学安全的随机数生成器创建,同时使用 HTTPS,并设置有效期、撤销机制和访问限流。账单信息还必须由服务端校验。
为什么 md5(time()) 不安全
time() 返回当前 Unix 时间戳,通常只精确到秒。攻击者只要知道链接大致的创建时间,就能枚举前后几分钟内的时间戳:
md5($possibleTimestamp);
MD5 不会让时间戳变得更随机,只是将这个可预测的值转换成固定格式,输入范围依然很小。
这种写法还有两个问题:
- 同一秒内创建的两笔付款会得到相同的 key。
- MD5 已不适合安全用途。不过,即使换成 SHA-256,
hash('sha256', time())仍然可以预测,因为问题出在输入缺少足够的随机性。
uniqid()、当前时间、订单号,或者这些值的哈希组合,同样不适合用来生成支付链接令牌。
robots 不是安全措施
下面的标签只是告诉愿意遵守规则的搜索引擎不要收录页面:
<meta name="robots" content="noindex, nofollow">
它既不能阻止别人直接访问链接,也约束不了恶意爬虫。链接还可能从以下位置泄露:
- 浏览器历史记录和同步服务
- Web 服务器、代理或监控日志
- 邮件转发、聊天记录和截图
- 第三方资源收到的
Referer - 分析统计、错误追踪和客服系统
所以,noindex 可以作为辅助性的隐私设置,但不能代替身份验证或安全令牌。
使用安全随机令牌
可以先生成 32 字节的密码学安全随机数,再编码成十六进制字符串:
$token = bin2hex(random_bytes(32));
生成的令牌有 64 个字符,包含 256 位随机数据。不要将它截短到容易被穷举的长度。
如果运行环境不支持 random_bytes(),应使用经过审查的 CSPRNG 兼容库,不能退回到 rand()、mt_rand()、uniqid() 或时间戳。
推荐:数据库只保存令牌哈希
原始令牌只需发送给客户,数据库中保存它的 SHA-256 哈希。这样即使数据库泄露,攻击者也不能直接使用其中的数据访问支付链接。
创建支付链接:
use Carbon\Carbon;
$token = bin2hex(random_bytes(32));
$registration->key_hash = hash('sha256', $token);
$registration->expires_at = Carbon::now()->addHours(24);
$registration->used_at = null;
$registration->save();
$url = url('/pay/' . $token);
路由可以这样定义:
Route::get('/pay/{token}', 'PaymentController@show');
Route::post('/pay/{token}', 'PaymentController@pay');
收到访问请求后,对令牌执行相同的哈希运算,再用结果查询记录:
public function show($token)
{
if (!preg_match('/\A[a-f0-9]{64}\z/', $token)) {
abort(404);
}
$keyHash = hash('sha256', $token);
$registration = Registration::where('key_hash', $keyHash)->first();
if (!$registration) {
abort(404);
}
if ($registration->expires_at->isPast()) {
abort(410);
}
if ($registration->used_at !== null) {
abort(410);
}
return response()
->view('payments.show', compact('registration'))
->header('Cache-Control', 'no-store, private')
->header('Referrer-Policy', 'no-referrer')
->header('X-Robots-Tag', 'noindex, nofollow, noarchive');
}
数据库中的 key_hash 应设置唯一索引。256 位随机令牌发生碰撞的概率虽然极低,但唯一约束仍有助于保证数据一致性。
支付提交必须在服务端校验
安全链接解决的只是访问权限问题,也就是持有链接的人可以打开页面。付款金额、币种、账单归属和支付状态必须从数据库读取,不能相信浏览器提交的隐藏字段:
public function pay(Request $request, $token)
{
if (!preg_match('/\A[a-f0-9]{64}\z/', $token)) {
abort(404);
}
$registration = Registration::where(
'key_hash',
hash('sha256', $token)
)->firstOrFail();
if ($registration->expires_at->isPast() ||
$registration->used_at !== null) {
abort(410);
}
// 金额和币种必须来自服务端保存的账单。
$amount = $registration->amount;
$currency = $registration->currency;
// 调用支付服务,并为支付请求设置稳定的幂等键。
// 不要直接处理或保存银行卡敏感数据。
// 只有在支付服务端回调验证成功后,才最终标记账单已支付。
}
用户从支付页面跳转回来,并不能证明付款已经成功。最终状态应根据支付服务商经过签名验证的 Webhook 或服务端查询结果更新。
还需要注意什么
- 全程强制使用 HTTPS,防止令牌在传输过程中被窃取。
- 为链接设置较短的有效期,并允许主动撤销和重新签发。
- 根据业务需要决定链接是否只能使用一次。付款成功后,链接应立即失效。
- 对无效令牌和支付尝试进行限流,同时避免在响应中暴露账单是否存在。
- 不要将完整令牌写入应用日志、分析平台或错误追踪系统。
- 支付页面应尽量少加载不必要的第三方脚本、图片和统计资源。
- 使用
Cache-Control: no-store和Referrer-Policy: no-referrer减少泄露。 - 为支付请求设置幂等键,防止重复提交导致重复扣款。
- 如果账单信息敏感,或者只允许指定客户付款,应在随机链接之外增加登录验证、一次性验证码等身份校验。
- 优先使用支付服务商提供的托管支付页面,避免自行接触银行卡数据,也避免扩大 PCI DSS 合规范围。
安全随机链接本质上是一种 bearer token。任何拿到完整 URL 的人,都可能获得相应权限。它可以作为低风险账单的访问凭证,但不能因为链接难以猜中,就忽视链接泄露、生命周期管理和服务端支付校验。
备注:内容仅供参考。
