Merge pull request #20 from aaronjmars/security/open-redirect-guard
fix(security): block open redirect in login redirect target
This commit is contained in:
+15
-3
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user