适用场景
开放重定向防御适用于所有存在「跳转目标由 URL 参数决定」的站点:登录成功后回跳原页面、支付完成后返回商户页、短信与邮件链接中转、语言或地区自动切换、下载站的文件跳转、短链与推广链接平台。若 ?url=、?next=、?redirect=、?returnUrl=、?callback= 这类参数未做校验,攻击者就能构造出 https://你的域名/jump?url=https://钓鱼站 这样的链接,借用可信域名实施钓鱼。
开放重定向单独看是中危漏洞,但它常常与 OAuth 授权码窃取、SSRF 探测、Cookie 泄露串联,在实际渗透测试中往往被提升为高危。本文给出 Nginx 层与主流后端语言层的完整防护配置,并提供可直接执行的绕过用例验证脚本。
前置条件
- 已定位站点中所有执行跳转的参数名与 URL 路径(可通过日志检索
url=、redirect=、goto=等关键字统计)。 - 具备 Nginx 或 Apache 配置修改权限,以及后端应用代码的修改与发布能力。
- 明确站点的合法跳转白名单:主域名、移动端域名、已备案的关联业务域名、允许的站内路径前缀。
- 已开启访问日志,便于验证阶段检索 302 响应与 Location 头。
原理说明
开放重定向的本质是「跳转目标未经白名单校验」。服务端接收到用户可控参数后直接写入 Location 响应头,浏览器随即跳转到攻击者指定的地址。由于地址栏在跳转前展示的是可信域名,用户警惕性会显著下降,因此钓鱼成功率远高于普通链接。
常见的绕过手法有五类,防护时都必须覆盖:
- 协议相对地址:
//evil.com会被浏览器按当前协议补全为https://evil.com。只判断开头是否为http的方案会漏掉它。 - 伪协议变体:
https:evil.com、http:/evil.com、https:\\evil.com,部分 URL 解析库会把它们规整为外部地址。 - userinfo 混淆:
https://trusted.com@evil.com,真正的主机是evil.com,而字符串包含判断会误认为域名可信。 - 编码绕过:
%2f%2fevil.com、%252f%252fevil.com(双重编码),若校验发生在解码之前就会被绕过。 - 后缀包含绕过:白名单使用
trusted.com.evil.com或evil.com/?trusted.com时,strstr、includes、endsWith判断均可能误放行。
正确的判定原则只有一条:先规范化,再比较主机名,最后只允许白名单内主机或纯站内相对路径。业务上最简单可靠的做法是:跳转目标只接受相对路径(以单个 / 开头且第二个字符不是 / 或 \),一旦需要跳到外部域名,则改用带确认提示的中转页。
操作步骤
步骤 1:Nginx 层拦截非法跳转参数
在跳转入口的 location 中使用 map 做规范化校验,只放行站内相对路径,其余一律重写到安全默认页。
# /etc/nginx/conf.d/open-redirect.conf
map $arg_url $safe_url {
default "";
# 仅允许单个斜杠开头,且第二个字符不是 / 或 \
"~^/(?!/|\\)[^\r\n]*$" $arg_url;
}
server {
listen 443 ssl;
server_name www.example.com;
# 统一跳转入口
location = /jump {
if ($safe_url = "") {
return 302 https://www.example.com/;
}
return 302 $safe_url;
}
# 备用参数名也纳入同一套校验
location = /login/callback {
if ($arg_next !~ ^/(?!/|\\)[^\r\n]*$) {
return 403;
}
return 302 $arg_next;
}
# 禁止 TRACE 等方法(与跳转无关,但常与探测行为同时出现)
if ($request_method !~ ^(GET|HEAD|POST)$) {
return 405;
}
}
要点说明:~^/(?!/|\\) 正则使用否定前瞻,确保匹配到的字符串不是 // 或 /\ 开头,从根上排除协议相对地址;return 302 而不是 301,避免浏览器长期缓存错误跳转;default “” 表示任何不匹配的值都落到空串,由后续 if 拦截。
步骤 2:需要跳转到外部域名时使用中转确认页
业务确实需要跳出站外时,不要直接 302,而是打开站点自身的中转页,把目标地址以文本形式呈现给用户。
location = /go {
# 中转页本身由应用渲染,Nginx 只做参数透传与长度限制
if ($arg_target ~ "[\r\n]") { return 400; }
if ($arg_target = "") { return 400; }
proxy_pass http://127.0.0.1:8080/redirect-notice;
proxy_set_header X-Real-IP $remote_addr;
}
中转页需输出 rel="nofollow noopener" 与 referrer 策略,避免把当前页地址作为 Referer 泄露给外部站点:
<meta name="referrer" content="no-referrer">
<a href="https://third-party.example.org/landing"
rel="nofollow noopener noreferrer" target="_blank">
即将离开本站,前往 third-party.example.org
</a>
步骤 3:应用层统一校验函数
后端必须独立实现校验,不能依赖 Nginx 一层。以下为各语言的「仅允许站内相对路径」实现。
PHP:
function safe_redirect_path(string $raw): ?string {
$v = trim($raw);
if ($v === '' || str_contains($v, "\r") || str_contains($v, "\n")) {
return null;
}
// 只接受单个斜杠开头的站内路径
if ($v[0] !== '/' || (isset($v[1]) && ($v[1] === '/' || $v[1] === '\\'))) {
return null;
}
$parts = parse_url($v);
// 出现 host / scheme 说明是绝对地址,一律拒绝
if (!empty($parts['host']) || !empty($parts['scheme'])) {
return null;
}
return $v;
}
$target = safe_redirect_path($_GET['next'] ?? '');
header('Location: ' . ($target ?? '/'), true, 302);
header('Cache-Control: no-store');
exit;
Java(Spring):
public static String safePath(String raw) {
if (raw == null) return "/";
String v = raw.trim();
if (v.isEmpty() || v.contains("\r") || v.contains("\n")) return "/";
if (!v.startsWith("/") || v.startsWith("//") || v.startsWith("/\\")) return "/";
URI uri;
try {
uri = new URI(v);
} catch (URISyntaxException e) {
return "/";
}
if (uri.isAbsolute() || uri.getHost() != null) return "/";
return v;
}
// 使用处
response.setStatus(302);
response.setHeader("Location", safePath(request.getParameter("next")));
response.setHeader("Cache-Control", "no-store");
Node.js(Express):
function safePath(raw) {
if (typeof raw !== 'string') return '/';
const v = raw.trim();
if (!v || v[0] !== '/' || v[1] === '/' || v[1] === '\\') return '/';
if (/[\r\n]/.test(v)) return '/';
try {
const u = new URL(v, 'https://internal.invalid');
if (u.origin !== 'https://internal.invalid') return '/';
} catch { return '/'; }
return v;
}
app.get('/jump', (req, res) => {
res.set('Cache-Control', 'no-store');
res.redirect(302, safePath(req.query.next));
});
核心逻辑一致:拒绝空值、拒绝换行、拒绝双斜杠与反斜杠开头、用 URL 解析器确认没有 host 与 scheme。凡是用 includes、indexOf、strstr 判断「是否包含可信域名」的写法,都必须替换掉。
步骤 4:需要跨域白名单时的正确比较方式
确有跨站跳转需求时,应先解析出主机名,再用严格相等或明确的正则后缀匹配:
map $arg_url $allowed_host {
default 0;
"~*^https?://([^/]+)" $1;
}
map $allowed_host $jump_ok {
default 0;
"www.example.com" 1;
"m.example.com" 1;
"~*^([a-z0-9-]+\.)?example\.com$" 1;
"~*^([a-z0-9-]+\.)?example\.org$" 1;
}
location = /jump {
if ($jump_ok = 0) { return 403; }
return 302 $arg_url;
}
后缀匹配的正则必须以 \.example\.com$ 结尾,锚定结束位置。这样 evil.com.evil.net 与 trusted-example.com 都无法通过。
步骤 5:WAF 与 ModSecurity 兜底规则
# ModSecurity / OWASP CRS 自定义规则,拦截以外部地址为目标的跳转参数
SecRule ARGS_NAMES "@rx ^(url|next|redirect|returnUrl|goto|target)$" \
"id:1000901,phase:2,chain,deny,status:403,log,\
msg:'Open redirect attempt'"
SecRule ARGS "@rx ^(//|\\\\|%2f%2f|%5c|https?:|//|\\\\/)" "t:urlDecodeUni,t:lowercase"
# 拦截 Location 头中出现不可信主机的响应
SecRule RESPONSE_HEADERS:Location "!@rx ^/(?!/|\\\\)" \
"id:1000902,phase:4,pass,log,msg:'Non-relative Location header'"
若站点接入的是云 WAF,等效做法是在自定义规则中新增一条:请求参数名匹配 url|next|redirect|return_url|goto 且值以 //、http、\ 开头时返回 403。这条规则的价值在于覆盖尚未审计到的老接口,与代码层修复形成互补。
配置验证
用一批已知绕过用例做回归,全部应返回 302 且 Location 指向站内,或直接返回 403/400:
DOMAIN="https://www.example.com"
for p in \
"/jump?url=https://evil.com" \
"/jump?url=//evil.com" \
"/jump?url=https:evil.com" \
"/jump?url=https://www.example.com@evil.com" \
"/jump?url=%2f%2fevil.com" \
"/jump?url=%252f%252fevil.com" \
"/jump?url=/\evil.com" \
"/jump?url=/redirect?x=https://evil.com" \
"/jump?url=http://evil.com/?www.example.com" \
"/jump?url=/dashboard" ; do
code=$(curl -s -o /dev/null -w '%{http_code}' "$DOMAIN$p")
loc=$(curl -sI "$DOMAIN$p" | grep -i '^location:' | tr -d '\r')
printf '%-58s %s %s\n' "$p" "$code" "$loc"
done
判定标准:evil.com 出现在 Location 中的任何一行都是未修复;/dashboard 这一行必须正常跳转到站内路径,说明防护没有误伤正常业务。
再从日志侧做一次真实流量核查:
# 统计 302 响应且 Location 指向外部的请求(需要日志含 $sent_http_location)
awk '$9 == 302' /var/log/nginx/access.log | wc -l
# 检索跳转参数的异常取值
grep -ioE '(url|next|redirect|goto)=([^& ]*)' /var/log/nginx/access.log \
| grep -E '=|//|%2f|http' | sort | uniq -c | sort -rn | head -20
常见问题
FAQ 1:白名单判断写成「字符串包含可信域名」为什么不安全?举例说明
假设代码为 if (target.includes('trusted.com')) redirect(target)。攻击者传入 https://trusted.com.evil.com,字符串确实包含可信域名,但浏览器实际访问的是 evil.com 的子域。换一种写法 https://evil.com/?trusted.com 同样能通过包含判断,而真实主机仍是 evil.com。此外 https://trusted.com@evil.com 中,@ 之前的部分是 userinfo 而非主机名,包含判断也会放行。正确做法是用 URL 解析器取出 host 字段后做严格相等或锚定后缀的正则匹配,并且优先采用「只允许相对路径」这一更保守的策略。
FAQ 2:站点有多个域名(PC 站、移动站、App 回调域名),白名单如何维护才不失控?
建议把白名单收敛为一处配置,而不是散落在几十处业务代码中。做法分两层:域名层维护一张受控清单(写入配置文件或配置中心,例如 allowed_redirect_hosts),并规定新增域名必须走变更流程;路径层对每个入口只允许本业务前缀,例如 /order/、/user/,防止 A 业务把用户跳到 B 业务之外的地址。同时保留一个兜底默认值(如首页),任何校验失败都不抛异常而是跳首页,这样即使新参数未及时登记,也不会打开新的跳转面。正则后缀匹配务必锚定 $,避免 evil-example.com 这类混淆域名被放行。
FAQ 3:修复后用户反馈「登录回跳失效」或浏览器一直跳到旧地址,怎么排查?
两类原因。第一类是校验过严:把合法的站内绝对地址(带本站域名的完整 URL)一并拒绝了,或没有允许带查询串的相对路径,例如 /order/detail?id=9 被正则误判。此时应放宽为「主机名等于本站任一合法域名」也放行,而不是只允许相对路径。第二类是浏览器缓存:若此前使用 301 永久重定向,浏览器会长期缓存旧跳转结果,修复后仍表现为跳到旧地址。解决办法是全部改用 302 或 307,并显式加 Cache-Control: no-store;已污染的缓存只能由用户清缓存或更换参数路径(例如把 /jump 改为 /go)来规避。回归验证时建议固定使用无痕窗口并带一个随机参数,避免测试结果被缓存干扰。
总结
开放重定向的修复原则是白名单而非黑名单:只接受站内相对路径,路径需以单个 / 开头且第二个字符不是 / 或 \;确需跨站时改为中转确认页,并配上 no-referrer。校验必须覆盖协议相对地址、伪协议、userinfo 混淆、单双重编码、后缀包含五类绕过。落地时 Nginx 层用 map 做第一道拦截,应用层用 URL 解析器做第二道确认,WAF 自定义规则兜底历史接口,三层叠加才能覆盖未审计到的入口。
最后建议把跳转参数纳入日常巡检:定期在访问日志中统计 url=、next=、redirect= 的取值分布,一旦发现外部域名的取值出现频次上升,往往意味着有人在批量测试或已经开始投递钓鱼链接。