Merge pull request #20 from aaronjmars/security/open-redirect-guard

fix(security): block open redirect in login redirect target
This commit is contained in:
kunkun
2026-06-01 11:17:39 +08:00
committed by GitHub
3 changed files with 69 additions and 6 deletions
+15 -3
View File
@@ -543,11 +543,23 @@ func decodeState(state string) string {
if err != nil { if err != nil {
return "/" return "/"
} }
redirect := string(data) return safeRedirectPath(string(data))
if !strings.HasPrefix(redirect, "/") { }
// 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 "/"
} }
return redirect return cleaned
} }
func RequestOrigin(r *http.Request) string { func RequestOrigin(r *http.Request) string {
+41
View File
@@ -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)
}
}
+13 -3
View File
@@ -15,6 +15,16 @@ type LoginFormValues = {
confirmPassword?: string; 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() { export default function LoginPage() {
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
@@ -34,7 +44,7 @@ function LoginContent() {
const linuxDoEnabled = useConfigStore((state) => state.publicSettings?.auth?.linuxDo?.enabled === true); const linuxDoEnabled = useConfigStore((state) => state.publicSettings?.auth?.linuxDo?.enabled === true);
const allowRegister = useConfigStore((state) => state.publicSettings?.auth?.allowRegister !== false); const allowRegister = useConfigStore((state) => state.publicSettings?.auth?.allowRegister !== false);
const [mode, setMode] = useState<"login" | "register">("login"); const [mode, setMode] = useState<"login" | "register">("login");
const redirect = searchParams.get("redirect") || "/"; const redirect = safeRedirect(searchParams.get("redirect"));
useEffect(() => { useEffect(() => {
const token = searchParams.get("token"); const token = searchParams.get("token");
@@ -44,7 +54,7 @@ function LoginContent() {
void fetchCurrentUser(token).then((user) => { void fetchCurrentUser(token).then((user) => {
setSession(token, user); setSession(token, user);
message.success("登录成功"); message.success("登录成功");
router.replace(redirect.startsWith("/") ? redirect : "/"); router.replace(redirect);
router.refresh(); router.refresh();
}); });
}, [message, redirect, router, searchParams, setSession]); }, [message, redirect, router, searchParams, setSession]);
@@ -66,7 +76,7 @@ function LoginContent() {
const action = mode === "register" ? register : login; const action = mode === "register" ? register : login;
const user = await action({ username: values.username, password: values.password }); const user = await action({ username: values.username, password: values.password });
message.success(mode === "register" ? "注册成功" : "登录成功"); message.success(mode === "register" ? "注册成功" : "登录成功");
router.replace(redirect.startsWith("/") ? redirect : "/"); router.replace(redirect);
router.refresh(); router.refresh();
if (user.role !== "admin") router.replace("/"); if (user.role !== "admin") router.replace("/");
} catch (error) { } catch (error) {