From 5ca5b72b01a60862401890d92d6a3d14be6e6597 Mon Sep 17 00:00:00 2001 From: aeonframework Date: Wed, 27 May 2026 20:06:22 +0000 Subject: [PATCH] fix(security): block open redirect in login redirect target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The login redirect accepted any value beginning with a single slash, so a protocol-relative URL such as "//evil.com" (or a backslash variant) slipped through and the browser resolved it to an external site. Both the Go OAuth state decoder and the web login page used the same prefix-only check, so an attacker could send a victim to /login?redirect=//evil.com — or supply it via the Linux.do OAuth redirect param — and bounce them off-site after login. Harden both layers: strip Tab/CR/LF (which browsers ignore inside URLs) and reject protocol-relative and backslash-prefixed targets, allowing only genuine same-site relative paths. Detected by Aeon + semgrep (go.lang.security.injection.open-redirect). Severity: medium CWE-601 (URL Redirection to Untrusted Site) Co-Authored-By: Claude Opus 4.7 --- service/auth.go | 18 +++++++++++--- service/auth_redirect_test.go | 41 +++++++++++++++++++++++++++++++ web/src/app/(user)/login/page.tsx | 16 +++++++++--- 3 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 service/auth_redirect_test.go diff --git a/service/auth.go b/service/auth.go index ad32df7..e518268 100644 --- a/service/auth.go +++ b/service/auth.go @@ -543,11 +543,23 @@ func decodeState(state string) string { if err != nil { return "/" } - redirect := string(data) - if !strings.HasPrefix(redirect, "/") { + return safeRedirectPath(string(data)) +} + +// safeRedirectPath 仅放行站内相对路径,拦截开放重定向。浏览器会忽略 URL 中的 +// Tab/换行/回车,并把 //host 或 /\host 解析为协议相对的跨站地址,因此先剥离这些 +// 控制字符,再拒绝 // 与 /\ 前缀。 +func safeRedirectPath(redirect string) string { + cleaned := strings.Map(func(r rune) rune { + if r == '\t' || r == '\n' || r == '\r' { + return -1 + } + return r + }, redirect) + if !strings.HasPrefix(cleaned, "/") || strings.HasPrefix(cleaned, "//") || strings.HasPrefix(cleaned, "/\\") { return "/" } - return redirect + return cleaned } func RequestOrigin(r *http.Request) string { diff --git a/service/auth_redirect_test.go b/service/auth_redirect_test.go new file mode 100644 index 0000000..de17b35 --- /dev/null +++ b/service/auth_redirect_test.go @@ -0,0 +1,41 @@ +package service + +import ( + "encoding/base64" + "testing" +) + +func TestSafeRedirectPath(t *testing.T) { + cases := map[string]string{ + "/": "/", + "/canvas/abc": "/canvas/abc", + "/login?redirect=/x": "/login?redirect=/x", + "": "/", + "//evil.com": "/", + "/\\evil.com": "/", + "https://evil.com": "/", + "http://evil.com": "/", + "javascript:alert(1)": "/", + "evil.com": "/", + "/\t/evil.com": "/", // browsers strip the tab → //evil.com + "/normal\tpath": "/normalpath", + } + for in, want := range cases { + if got := safeRedirectPath(in); got != want { + t.Errorf("safeRedirectPath(%q) = %q, want %q", in, got, want) + } + } +} + +func TestDecodeStateRejectsOpenRedirect(t *testing.T) { + for _, in := range []string{"//evil.com", "/\\evil.com", "https://evil.com"} { + state := base64.RawURLEncoding.EncodeToString([]byte(in)) + if got := decodeState(state); got != "/" { + t.Errorf("decodeState(state(%q)) = %q, want \"/\"", in, got) + } + } + state := base64.RawURLEncoding.EncodeToString([]byte("/canvas/1")) + if got := decodeState(state); got != "/canvas/1" { + t.Errorf("decodeState(state(/canvas/1)) = %q, want /canvas/1", got) + } +} diff --git a/web/src/app/(user)/login/page.tsx b/web/src/app/(user)/login/page.tsx index 010a12d..e9f1936 100644 --- a/web/src/app/(user)/login/page.tsx +++ b/web/src/app/(user)/login/page.tsx @@ -15,6 +15,16 @@ type LoginFormValues = { confirmPassword?: string; }; +// 仅放行站内相对路径,拦截开放重定向。浏览器会忽略 URL 中的 Tab/换行/回车,并把 +// //host 或 /\host 解析为协议相对的跨站地址,因此先剥离控制字符,再拒绝 // 与 /\ 前缀。 +function safeRedirect(value: string | null): string { + const cleaned = (value ?? "").replace(/[\t\n\r]/g, ""); + if (!cleaned.startsWith("/") || cleaned.startsWith("//") || cleaned.startsWith("/\\")) { + return "/"; + } + return cleaned; +} + export default function LoginPage() { return ( @@ -34,7 +44,7 @@ function LoginContent() { const linuxDoEnabled = useConfigStore((state) => state.publicSettings?.auth?.linuxDo?.enabled === true); const allowRegister = useConfigStore((state) => state.publicSettings?.auth?.allowRegister !== false); const [mode, setMode] = useState<"login" | "register">("login"); - const redirect = searchParams.get("redirect") || "/"; + const redirect = safeRedirect(searchParams.get("redirect")); useEffect(() => { const token = searchParams.get("token"); @@ -44,7 +54,7 @@ function LoginContent() { void fetchCurrentUser(token).then((user) => { setSession(token, user); message.success("登录成功"); - router.replace(redirect.startsWith("/") ? redirect : "/"); + router.replace(redirect); router.refresh(); }); }, [message, redirect, router, searchParams, setSession]); @@ -66,7 +76,7 @@ function LoginContent() { const action = mode === "register" ? register : login; const user = await action({ username: values.username, password: values.password }); message.success(mode === "register" ? "注册成功" : "登录成功"); - router.replace(redirect.startsWith("/") ? redirect : "/"); + router.replace(redirect); router.refresh(); if (user.role !== "admin") router.replace("/"); } catch (error) {