支付后如何安全定义 redirect_to URL?
结论
不要把 $_GET['redirect_to'] 直接拼接到 Location 响应头中。hidden input 的值完全由用户控制,这样做会造成开放重定向(Open Redirect)漏洞。
重定向时,服务器通常不会主动请求目标网站,而是通过 Location 响应头通知用户的浏览器访问该地址。但风险并不会因此消失。攻击者仍可利用你的可信域名,将用户引向钓鱼网站、恶意下载页面或伪造的支付结果页。
更安全的方式是只允许跳转到事先批准的站内路径。更进一步,可以不接收 URL,只接收由服务器解析的目标标识。
推荐方案:只传递目标标识
如果支付完成后只有几个固定去向,就不必让客户端提交完整 URL。客户端只需提交一个简单标识,实际跳转地址由服务器决定。
<input type="hidden" name="redirect_to" value="orders">
<?php
$redirectMap = [
'orders' => '/account/orders',
'profile' => '/account/profile',
'home' => '/',
];
$key = $_GET['redirect_to'] ?? '';
$target = $redirectMap[$key] ?? '/payment/result';
header('Location: ' . $target, true, 303);
exit;
这段代码不信任用户提交的地址。即使用户将标识改成其他内容,也只会被重定向到默认页面。
支付完成后通常适合使用 303 See Other,让浏览器通过 GET 请求结果页,避免用户刷新页面时再次提交与支付有关的 POST 请求。具体使用哪个状态码,仍要根据支付流程来确定。
需要动态地址时,只允许站内路径
如果业务确实需要动态跳转,可以只接受以单个 / 开头的站内绝对路径,并拒绝协议相对地址、反斜杠和控制字符等危险输入。
<?php
function getSafeRedirectPath(mixed $value, string $fallback): string
{
if (!is_string($value) || $value === '') {
return $fallback;
}
// 拒绝换行符和其他 ASCII 控制字符
if (preg_match('/[\x00-\x1F\x7F]/', $value)) {
return $fallback;
}
// 必须以单个正斜杠开头
// 拒绝 //evil.example 和反斜杠形式
if ($value[0] !== '/' || str_starts_with($value, '//')) {
return $fallback;
}
if (str_contains($value, '\\')) {
return $fallback;
}
$parts = parse_url($value);
if ($parts === false) {
return $fallback;
}
// 站内相对目标不应包含协议、主机、用户信息或端口
foreach (['scheme', 'host', 'user', 'pass', 'port'] as $component) {
if (array_key_exists($component, $parts)) {
return $fallback;
}
}
return $value;
}
$target = getSafeRedirectPath(
$_GET['redirect_to'] ?? null,
'/payment/result'
);
header('Location: ' . $target, true, 303);
exit;
这种写法允许 /account/orders?id=123 一类的站内地址,但会拒绝以下内容:
https://evil.example/
http://evil.example/
javascript:alert(1)
//evil.example/
\\evil.example\
如果业务不需要查询参数,最好只允许路径,并对路径格式设置白名单。
必须允许完整 URL 时,应严格校验
有些业务需要跳转到多个受信任域名。此时必须同时校验协议、主机名和端口,不能只判断字符串是否包含某段内容,也不能仅靠后缀判断。
<?php
function getAllowedRedirectUrl(mixed $value, string $fallback): string
{
if (!is_string($value) || filter_var($value, FILTER_VALIDATE_URL) === false) {
return $fallback;
}
if (preg_match('/[\x00-\x1F\x7F]/', $value)) {
return $fallback;
}
$parts = parse_url($value);
if ($parts === false) {
return $fallback;
}
$scheme = strtolower($parts['scheme'] ?? '');
$host = strtolower(rtrim($parts['host'] ?? '', '.'));
$port = $parts['port'] ?? null;
$allowedHosts = [
'www.example.com',
'account.example.com',
];
if ($scheme !== 'https') {
return $fallback;
}
if (!in_array($host, $allowedHosts, true)) {
return $fallback;
}
if ($port !== null && $port !== 443) {
return $fallback;
}
return $value;
}
$target = getAllowedRedirectUrl(
$_GET['redirect_to'] ?? null,
'https://www.example.com/payment/result'
);
header('Location: ' . $target, true, 303);
exit;
不要使用下面这种判断:
str_contains($host, 'example.com')
它可能会错误接受这些主机名:
example.com.evil.test
fake-example.com
如果需要允许某个域名及其所有子域名,应明确检查主机名是否等于根域名,或者是否以“点加根域名”结尾,同时确认这些子域名都属于同一安全边界。通常,逐项列出允许的主机名更稳妥。
更可靠的设计:把跳转目标保存在服务器端
无论对 hidden input 进行编码、Base64 处理,还是通过前端 JavaScript 加以限制,它的值仍然可以被修改。创建订单时,可以将跳转目标保存在服务器端会话或订单记录中:
<?php
session_start();
$_SESSION['payment_redirects'][$orderId] = '/account/orders/' . $orderId;
支付完成并确认订单状态后,再从服务器端取出目标地址:
<?php
session_start();
$target = $_SESSION['payment_redirects'][$orderId] ?? '/payment/result';
unset($_SESSION['payment_redirects'][$orderId]);
header('Location: ' . $target, true, 303);
exit;
如果支付回调可能发生在另一个会话中,应该把目标标识保存在订单数据库中,而不是依赖浏览器 session。
支付流程还要注意
重定向参数不能用来判断“支付成功”。服务器必须通过支付平台的签名回调、订单查询接口或其他可信机制验证支付状态,不能仅凭浏览器访问了成功页面就认定支付完成。
另外还需注意:
- hidden input 不是安全边界。来自
$_GET、$_POST和 Cookie 的数据都应视为不可信。 - 调用
header()后应立即执行exit;,防止后续代码继续运行。 - 不要直接反射含有换行符的输入,以免造成响应头注入。
- 不要把“签名 URL”视为唯一的解决方案。签名可以防止篡改,但如果攻击者能为任意外部 URL 获取有效签名,开放重定向漏洞仍然存在。
- 如果目标只与订单有关,应优先传递订单 ID,再由服务器查询订单和对应的跳转规则。
- 即使业务允许跳转到外部网站,也应显示清晰的离站提示,并且只允许跳转到经过业务确认的 HTTPS 域名。
最简单可靠的原则是:客户端只提交标识,服务器根据白名单生成最终跳转地址。
备注:内容仅供参考。
