mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
fix(frontend): sanitize redirect targets to prevent XSS/open redirect
Sanitize and validate redirect targets from callbackUrl parameter and sessionStorage in login, registration, and OAuth callback flows. Introduced safeRedirectTarget helper which rejects protocol-relative URLs, non-relative schemes, control characters, backslashes, and encoding bypasses.
This commit is contained in:
@@ -18,6 +18,7 @@ import {AuthHeading} from "@/components/auth/auth-shell"
|
|||||||
import {OTPForm} from "./otp-form"
|
import {OTPForm} from "./otp-form"
|
||||||
import services from "@/lib/services"
|
import services from "@/lib/services"
|
||||||
import type {LoginRequest} from "@/lib/services/auth/types"
|
import type {LoginRequest} from "@/lib/services/auth/types"
|
||||||
|
import {safeRedirectTarget} from "@/lib/utils"
|
||||||
|
|
||||||
function getRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
|
function getRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
|
||||||
const callbackUrl = searchParams.get("callbackUrl")
|
const callbackUrl = searchParams.get("callbackUrl")
|
||||||
@@ -31,16 +32,17 @@ function getRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
|
|||||||
sessionStorage.removeItem("redirect_after_login")
|
sessionStorage.removeItem("redirect_after_login")
|
||||||
}
|
}
|
||||||
|
|
||||||
return target
|
return safeRedirectTarget(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
|
function persistRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
|
||||||
const callbackUrl = searchParams.get("callbackUrl")
|
const callbackUrl = searchParams.get("callbackUrl")
|
||||||
if (callbackUrl && typeof window !== "undefined") {
|
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) {
|
function configBool(value: string | undefined, fallback: boolean) {
|
||||||
if (value === undefined) return fallback
|
if (value === undefined) return fallback
|
||||||
return value === "true"
|
return value === "true"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {Check} from "lucide-react"
|
|||||||
|
|
||||||
import services from "@/lib/services"
|
import services from "@/lib/services"
|
||||||
import {useAuth} from "@/components/providers/auth-provider"
|
import {useAuth} from "@/components/providers/auth-provider"
|
||||||
|
import {safeRedirectTarget} from "@/lib/utils"
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,9 +49,10 @@ export function LoginPage() {
|
|||||||
sessionStorage.removeItem('redirect_after_login')
|
sessionStorage.removeItem('redirect_after_login')
|
||||||
}
|
}
|
||||||
|
|
||||||
return target
|
return safeRedirectTarget(target)
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
|
|
||||||
/* 登录页兜底:已登录用户直接跳转 */
|
/* 登录页兜底:已登录用户直接跳转 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const state = searchParams.get('state')
|
const state = searchParams.get('state')
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {Field, FieldGroup, FieldLabel} from "@/components/ui/field"
|
|||||||
import {AuthHeading} from "@/components/auth/auth-shell"
|
import {AuthHeading} from "@/components/auth/auth-shell"
|
||||||
import services from "@/lib/services"
|
import services from "@/lib/services"
|
||||||
import type {RegisterRequest} from "@/lib/services/auth/types"
|
import type {RegisterRequest} from "@/lib/services/auth/types"
|
||||||
|
import {safeRedirectTarget} from "@/lib/utils"
|
||||||
|
|
||||||
function getRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
|
function getRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
|
||||||
const callbackUrl = searchParams.get("callbackUrl")
|
const callbackUrl = searchParams.get("callbackUrl")
|
||||||
@@ -21,9 +22,10 @@ function getRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
|
|||||||
typeof window === "undefined"
|
typeof window === "undefined"
|
||||||
? null
|
? null
|
||||||
: sessionStorage.getItem("redirect_after_login")
|
: sessionStorage.getItem("redirect_after_login")
|
||||||
return callbackUrl || storedRedirect || "/home"
|
return safeRedirectTarget(callbackUrl || storedRedirect || "/home")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function configBool(value: string | undefined, fallback: boolean) {
|
function configBool(value: string | undefined, fallback: boolean) {
|
||||||
if (value === undefined) return fallback
|
if (value === undefined) return fallback
|
||||||
return value === "true"
|
return value === "true"
|
||||||
|
|||||||
@@ -91,3 +91,54 @@ export function generateTransactionCacheKey(params: {
|
|||||||
|
|
||||||
return `${ typesKey }_${ statusesKey }_${ transferStatusKey }_${ clientIdKey }_${ params.page }_${ params.page_size }_${ startTimeKey }_${ endTimeKey }_${ idKey }_${ orderNameKey }_${ payerKey }_${ payeeKey }`
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user