package shared import ( "net/url" "strings" "testing" "github.com/stretchr/testify/require" ) // TestSafeRedirectTarget 锁定「跳转回原页面」参数的白名单行为。 // // 重点是那批"看起来是站内路径、浏览器却会当外站"的载荷:反斜杠变体 // (WHATWG 在 http/https 下把 \ 当 /)、协议相对引用、以及编码后的同类载荷。 // 修复前两处调用点各写一份判断,其中登录那处用 `path.Clean("/"+to)`,只挡住 //host 与绝对 URL, // 反斜杠直接漏到 Location 头(页面密码那处同样漏)。 func TestSafeRedirectTarget(t *testing.T) { cases := []struct { name string in string want string // 空串表示"不可信,调用方回退默认落地页" }{ // —— 合法站内路径:应当原样(或归一化后)放行 —— {"箱主页", "/_/someone", "/_/someone"}, {"后台", "/user", "/user"}, {"单题页带 token", "/_/someone/12?t=abc", "/_/someone/12?t=abc"}, {"根路径", "/", "/"}, {"多点归一化", "/user/../explore", "/explore"}, {"编码斜杠归一化后仍在站内", "/%2f%2fevil.example", "/evil.example"}, // —— 必须拒绝的载荷 —— {"空串", "", ""}, {"相对路径", "user", ""}, {"反斜杠跳转", `/\evil.example`, ""}, {"双反斜杠", `\\evil.example`, ""}, {"混入反斜杠的站内路径", `/_/a\b`, ""}, {"协议相对", "//evil.example", ""}, {"三斜杠", "///evil.example", ""}, {"绝对 http", "http://evil.example", ""}, {"绝对 https", "https://evil.example", ""}, {"同协议不同站", "https:/evil.example", ""}, {"javascript 伪协议", "javascript:alert(1)", ""}, {"data 伪协议", "data:text/html,", ""}, {"userinfo 形态", "/@evil.example", "/@evil.example"}, // 站内路径,host 为空 {"编码反斜杠", `/%5cevil.example`, ""}, {"大写编码反斜杠", `/%5Cevil.example`, ""}, {"CRLF 注入", "/x%0d%0aSet-Cookie:%20a=b", ""}, {"内嵌 TAB", "/\t/evil.example", ""}, {"前导空格", " /evil.example", ""}, {"尾部空格", "/evil.example ", ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := SafeRedirectTarget(tc.in) require.Equal(t, tc.want, got) if got == "" { return } // 不变式:返回值必须是浏览器解析后仍留在本站的路径。 // 只要 Host/Scheme 非空,或者以 // 开头,就会被浏览器当成外站。 require.True(t, strings.HasPrefix(got, "/"), "返回值必须以 / 开头: %q", got) require.False(t, strings.HasPrefix(got, "//"), "返回值不能是协议相对引用: %q", got) require.NotContains(t, got, `\`, "返回值不能含反斜杠: %q", got) u, err := url.Parse(got) require.NoError(t, err) require.Empty(t, u.Host, "返回值不能带 host: %q", got) require.Empty(t, u.Scheme, "返回值不能带 scheme: %q", got) }) } } // TestSafeRedirectTargetNeverLeavesSite 是全量不变式的兜底: // 上面表的每一个输入(含被拒绝的)都必须满足"要么空、要么是站内绝对路径"。 func TestSafeRedirectTargetNeverLeavesSite(t *testing.T) { payloads := []string{ "/\\evil.example", `\\evil.example`, "//evil.example", "///evil.example", "https://evil.example", "http://evil.example", "//", "/%5c%5cevil.example", "/\u0000evil", "/\u00a0evil", "\\\\evil.example/x", "/..//evil.example", } for _, p := range payloads { got := SafeRedirectTarget(p) if got == "" { continue } u, err := url.Parse(got) require.NoError(t, err) require.True(t, strings.HasPrefix(got, "/") && !strings.HasPrefix(got, "//"), "载荷 %q 逃逸为 %q", p, got) require.Empty(t, u.Host) require.Empty(t, u.Scheme) } }