diff --git a/frontend/components/auth/login-form.tsx b/frontend/components/auth/login-form.tsx index 41a06c7e..c29be39a 100644 --- a/frontend/components/auth/login-form.tsx +++ b/frontend/components/auth/login-form.tsx @@ -18,6 +18,7 @@ import {AuthHeading} from "@/components/auth/auth-shell" import {OTPForm} from "./otp-form" import services from "@/lib/services" import type {LoginRequest} from "@/lib/services/auth/types" +import {safeRedirectTarget} from "@/lib/utils" function getRedirectTarget(searchParams: ReturnType) { const callbackUrl = searchParams.get("callbackUrl") @@ -31,16 +32,17 @@ function getRedirectTarget(searchParams: ReturnType) { sessionStorage.removeItem("redirect_after_login") } - return target + return safeRedirectTarget(target) } function persistRedirectTarget(searchParams: ReturnType) { const callbackUrl = searchParams.get("callbackUrl") if (callbackUrl && typeof window !== "undefined") { - sessionStorage.setItem("redirect_after_login", callbackUrl) + sessionStorage.setItem("redirect_after_login", safeRedirectTarget(callbackUrl)) } } + function configBool(value: string | undefined, fallback: boolean) { if (value === undefined) return fallback return value === "true" diff --git a/frontend/components/auth/login-page.tsx b/frontend/components/auth/login-page.tsx index 071af845..0b05752b 100644 --- a/frontend/components/auth/login-page.tsx +++ b/frontend/components/auth/login-page.tsx @@ -11,6 +11,7 @@ import {Check} from "lucide-react" import services from "@/lib/services" import {useAuth} from "@/components/providers/auth-provider" +import {safeRedirectTarget} from "@/lib/utils" /** @@ -48,9 +49,10 @@ export function LoginPage() { sessionStorage.removeItem('redirect_after_login') } - return target + return safeRedirectTarget(target) }, [searchParams]) + /* 登录页兜底:已登录用户直接跳转 */ useEffect(() => { const state = searchParams.get('state') diff --git a/frontend/components/auth/register-form.tsx b/frontend/components/auth/register-form.tsx index c5093abf..61815af6 100644 --- a/frontend/components/auth/register-form.tsx +++ b/frontend/components/auth/register-form.tsx @@ -14,6 +14,7 @@ import {Field, FieldGroup, FieldLabel} from "@/components/ui/field" import {AuthHeading} from "@/components/auth/auth-shell" import services from "@/lib/services" import type {RegisterRequest} from "@/lib/services/auth/types" +import {safeRedirectTarget} from "@/lib/utils" function getRedirectTarget(searchParams: ReturnType) { const callbackUrl = searchParams.get("callbackUrl") @@ -21,9 +22,10 @@ function getRedirectTarget(searchParams: ReturnType) { typeof window === "undefined" ? null : sessionStorage.getItem("redirect_after_login") - return callbackUrl || storedRedirect || "/home" + return safeRedirectTarget(callbackUrl || storedRedirect || "/home") } + function configBool(value: string | undefined, fallback: boolean) { if (value === undefined) return fallback return value === "true" diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index 430723b1..d1e85cee 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -91,3 +91,54 @@ export function generateTransactionCacheKey(params: { return `${ typesKey }_${ statusesKey }_${ transferStatusKey }_${ clientIdKey }_${ params.page }_${ params.page_size }_${ startTimeKey }_${ endTimeKey }_${ idKey }_${ orderNameKey }_${ payerKey }_${ payeeKey }` } + +/** + * 验证并净化重定向目标 URL,防止 Open Redirect 和 XSS 攻击。 + * 只允许以单个斜杠 "/" 开头的相对路径,拒绝 "//"、协议、反斜杠、控制字符等编码变体。 + */ +export function safeRedirectTarget(url: string | null | undefined, fallback = "/home"): string { + if (!url) return fallback + + // 1. 拒绝包含控制字符、空白字符或反斜杠的 URL + if (/[\u0000-\u001F\u007F-\u009F\s\\]/.test(url)) { + return fallback + } + + // 2. 必须以单个 '/' 开头,且不能以 '//' 开头 + if (!url.startsWith("/") || url.startsWith("//")) { + return fallback + } + + // 3. 递归 URL 解码并检测潜在的绕过载荷 + try { + let decoded = url + let prev = "" + let attempts = 0 + while (decoded !== prev && decoded.includes("%") && attempts < 3) { + prev = decoded + decoded = decodeURIComponent(decoded) + attempts++ + } + + // 检查解码后的内容是否包含控制字符、空白字符或反斜杠 + if (/[\u0000-\u001F\u007F-\u009F\s\\]/.test(decoded)) { + return fallback + } + + // 检查解码后的内容是否以单个 '/' 开头,且不能以 '//' 开头 + if (!decoded.startsWith("/") || decoded.startsWith("//")) { + return fallback + } + + // 提取路径部分(即问号 ? 或井号 # 之前的内容),确保其中不含冒号(防止 scheme)、双斜杠或反斜杠 + const pathPart = decoded.split(/[?#]/)[0] + if (pathPart.includes(":") || pathPart.includes("//") || pathPart.includes("\\")) { + return fallback + } + } catch { + return fallback + } + + return url +} +