概念定义
应用信任客户端提供的去向。攻击者发给受害者一个“你的域名”的登录链接,重定向参数却指向假冒登录页:用户看到的是真域名,认证流程却滑向冒充者。
工作机制
校验必须先解析目的地再按精确规则判断:只允许以单斜杠开头(且非双斜杠)的相对路径,或 authority 与白名单精确匹配的绝对 URL。方案相对地址 //host 与反斜杠技巧能穿透子串检查;编码与嵌套编码必须确定性解码一次后再判定。
使用场景
所有出现重定向参数的地方,包括登出与邮箱验证落地流程。更稳的做法是服务端已知目的地:短码映射到配置好的 URL,而不是到处传原始 URL。
常见误区
“重定向只是无害管道”低估了可见主机名的钓鱼价值;域名前缀校验挡不住 attacker.example.com;只查路径的检查会放过 //evil.example。OAuth 的 redirect_uri 校验缺口与开放重定向叠加会升级成令牌窃取;营销随手加的跟踪跳转应统一走同一个校验助手,仅对外部目的地展示插页警告。
代码示例
export interface RedirectPolicy {
allowedHosts?: readonly string[];
allowedProtocols?: readonly string[];
baseUrl?: string;
}
export function isSafeRedirect(target: string, policy: RedirectPolicy): boolean {
if (typeof target !== "string") return false;
const trimmed = target.trim();
if (trimmed === "" || /[\u0000-\u001f\\]/.test(trimmed)) return false;
const protocols = policy.allowedProtocols ?? ["https:"];
let url: URL;
try {
url = new URL(trimmed, policy.baseUrl);
} catch {
return false;
}
if (!protocols.includes(url.protocol)) return false;
if (url.username || url.password) return false;
const base = policy.baseUrl ? new URL(policy.baseUrl) : undefined;
const sameOrigin = base ? url.origin === base.origin : false;
const allowed = (policy.allowedHosts ?? []).some((candidate) => {
return candidate.toLowerCase() === url.host.toLowerCase();
});
return sameOrigin || allowed;
}
export function resolveSafeRedirect(target: string, policy: RedirectPolicy, fallback = "/"): string {
if (!isSafeRedirect(target, policy)) return fallback;
const url = new URL(target, policy.baseUrl);
if (!policy.baseUrl) return url.toString();
return url.pathname + url.search + url.hash;
}
// SSRF guard: validate every serverSide request target before dialing
package main
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
)
var blockedZones = []string{
"127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12",
"192.168.0.0/16", "169.254.0.0/16", "::1/128", "fc00::/7",
}
var allowedHosts = map[string]bool{
"api.partner.com": true, "cdn.partner.net": true, "hooks.vendor.io": true,
}
func validateTarget(raw string) (*url.URL, error) {
parsed, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("parse url: %w", err)
}
if parsed.Scheme != "https" {
return nil, fmt.Errorf("scheme %q not allowed", parsed.Scheme)
}
if !allowedHosts[parsed.Hostname()] {
return nil, fmt.Errorf("host %q is not allowlisted", parsed.Hostname())
}
return parsed, nil
}
func dialGuard(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if !isPublic(ip.IP) {
return nil, fmt.Errorf("resolved %s to private address %s", host, ip.IP)
}
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
}
func isPublic(ip net.IP) bool {
for _, zone := range blockedZones {
_, network, _ := net.ParseCIDR(zone)
if network.Contains(ip) {
return false
}
}
return !ip.IsLoopback() && !ip.IsUnspecified() && !ip.IsLinkLocalUnicast()
}
func guardedClient() *http.Client {
transport := &http.Transport{
DialContext: dialGuard,
// 重定向必须重走校验:禁止自动跟随
DisableKeepAlives: true,
}
return &http.Client{
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return validateSameHost(via[0], req.URL)
},
Timeout: 8 * time.Second,
}
}
func validateSameHost(orig, next *url.URL) error {
if strings.EqualFold(orig.Hostname(), next.Hostname()) {
return nil
}
return fmt.Errorf("redirect to non-allowlisted host %q", next.Hostname())
}
func main() {
client := guardedClient()
if _, err := validateTarget("https://api.partner.com/v1/data"); err == nil {
resp, err := client.Get("https://api.partner.com/v1/data")
fmt.Println(resp, err)
}
_, err := validateTarget("http://169.254.169.254/latest/meta-data")
fmt.Println("metadata endpoint blocked:", err)
}