diff --git a/src/components/Setup/Views/SetupPasskey.tsx b/src/components/Setup/Views/SetupPasskey.tsx
index 87548c4b6..12811f074 100644
--- a/src/components/Setup/Views/SetupPasskey.tsx
+++ b/src/components/Setup/Views/SetupPasskey.tsx
@@ -10,7 +10,7 @@ import * as Sentry from '@sentry/nextjs'
import { WalletProviderType, AccountType } from '@/interfaces'
import { WebAuthnError } from '@simplewebauthn/browser'
import Link from 'next/link'
-import { getFromCookie, getFromLocalStorage, sanitizeRedirectURL } from '@/utils'
+import { getFromCookie, getFromLocalStorage, getValidRedirectUrl, sanitizeRedirectURL } from '@/utils'
import { POST_SIGNUP_ACTIONS } from '@/components/Global/PostSignupActionManager/post-signup-action.consts'
const SetupPasskey = () => {
@@ -47,9 +47,11 @@ const SetupPasskey = () => {
const redirect_uri = searchParams.get('redirect_uri')
if (redirect_uri) {
- const sanitizedRedirectUrl = sanitizeRedirectURL(redirect_uri)
- router.push(sanitizedRedirectUrl)
+ const validRedirectUrl = getValidRedirectUrl(redirect_uri, '/home')
+ // Only redirect if the URL is safe (same-origin)
+ router.push(validRedirectUrl)
return
+ // If redirect_uri was invalid, fall through to other redirect logic
}
const localStorageRedirect = getFromLocalStorage('redirect')
@@ -62,7 +64,8 @@ const SetupPasskey = () => {
router.push('/home')
} else {
localStorage.removeItem('redirect')
- router.push(localStorageRedirect)
+ const validRedirectUrl = getValidRedirectUrl(localStorageRedirect, '/home')
+ router.push(validRedirectUrl)
}
} else {
router.push('/home')
diff --git a/src/components/Setup/Views/Welcome.tsx b/src/components/Setup/Views/Welcome.tsx
index 6bde37fd7..217189d8c 100644
--- a/src/components/Setup/Views/Welcome.tsx
+++ b/src/components/Setup/Views/Welcome.tsx
@@ -24,10 +24,23 @@ const WelcomeStep = () => {
const redirect_uri = searchParams.get('redirect_uri')
if (redirect_uri) {
const sanitizedRedirectUrl = sanitizeRedirectURL(redirect_uri)
- push(sanitizedRedirectUrl)
+ // Only redirect if the URL is safe (same-origin)
+ if (sanitizedRedirectUrl) {
+ push(sanitizedRedirectUrl)
+ } else {
+ // Reject external redirects, go to home instead
+ push('/home')
+ }
} else if (localStorageRedirect) {
localStorage.removeItem('redirect')
- push(localStorageRedirect)
+ const sanitizedLocalRedirect = sanitizeRedirectURL(localStorageRedirect)
+ // Only redirect if the URL is safe (same-origin)
+ if (sanitizedLocalRedirect) {
+ push(sanitizedLocalRedirect)
+ } else {
+ // Reject external redirects, go to home instead
+ push('/home')
+ }
} else {
push('/home')
}
diff --git a/src/hooks/useLogin.tsx b/src/hooks/useLogin.tsx
new file mode 100644
index 000000000..a7ef328a9
--- /dev/null
+++ b/src/hooks/useLogin.tsx
@@ -0,0 +1,52 @@
+import { useAuth } from '@/context/authContext'
+import { useZeroDev } from './useZeroDev'
+import { useEffect } from 'react'
+import { getFromLocalStorage, getValidRedirectUrl, sanitizeRedirectURL } from '@/utils'
+import { useRouter, useSearchParams } from 'next/navigation'
+
+/**
+ * Hook for handling user login and post-login redirects.
+ *
+ * Manages the login flow by coordinating authentication state and routing.
+ * After successful login, redirects users to:
+ * 1. `redirect_uri` query parameter (if present and safe)
+ * 2. Saved redirect URL from localStorage (if present and safe)
+ * 3. '/home' as fallback
+ *
+ * All redirects are sanitized to prevent external URL redirection attacks.
+ *
+ * @returns {Object} Login handlers and state
+ * @returns {Function} handleLoginClick - Async function to initiate login
+ * @returns {boolean} isLoggingIn - Loading state during login process
+ */
+
+export const useLogin = () => {
+ const { user } = useAuth()
+ const { handleLogin, isLoggingIn } = useZeroDev()
+ const searchParams = useSearchParams()
+ const router = useRouter()
+
+ // Wait for user to be fetched, then redirect
+ useEffect(() => {
+ if (user) {
+ const localStorageRedirect = getFromLocalStorage('redirect')
+ const redirect_uri = searchParams.get('redirect_uri')
+ if (redirect_uri) {
+ const validRedirectUrl = getValidRedirectUrl(redirect_uri, '/home')
+ router.push(validRedirectUrl)
+ } else if (localStorageRedirect) {
+ localStorage.removeItem('redirect')
+ const validRedirectUrl = getValidRedirectUrl(String(localStorageRedirect), '/home')
+ router.push(validRedirectUrl)
+ } else {
+ router.push('/home')
+ }
+ }
+ }, [user, router, searchParams])
+
+ const handleLoginClick = async () => {
+ await handleLogin()
+ }
+
+ return { handleLoginClick, isLoggingIn }
+}
diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts
index f9e0404df..909762a83 100644
--- a/src/hooks/useZeroDev.ts
+++ b/src/hooks/useZeroDev.ts
@@ -61,7 +61,7 @@ export const useZeroDev = () => {
// invite code can also be store in cookies, so we need to check both
const userInviteCode = inviteCode || inviteCodeFromCookie
- if (userInviteCode.trim().length > 0) {
+ if (userInviteCode?.trim().length > 0) {
try {
const result = await invitesApi.acceptInvite(userInviteCode, inviteType)
if (!result.success) {
diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts
index 97242166f..3e2dac022 100644
--- a/src/utils/general.utils.ts
+++ b/src/utils/general.utils.ts
@@ -1214,18 +1214,27 @@ export const clearRedirectUrl = () => {
}
}
-export const sanitizeRedirectURL = (redirectUrl: string): string => {
+export const sanitizeRedirectURL = (redirectUrl: string): string | null => {
try {
const u = new URL(redirectUrl, window.location.origin)
+ // Only allow same-origin URLs
if (u.origin === window.location.origin) {
return u.pathname + u.search + u.hash
}
+ console.log('Rejecting off-origin URL:', redirectUrl)
+ // Reject off-origin URLs
+ return null
} catch {
- if (redirectUrl.startsWith('/')) {
- return redirectUrl
+ // For strings that can't be parsed as URLs, only allow relative paths
+ if (redirectUrl.startsWith('/') && !redirectUrl.startsWith('//')) {
+ // Additional check: ensure it doesn't contain a protocol
+ if (!redirectUrl.includes('://')) {
+ return redirectUrl
+ }
}
+ // Reject anything else (including protocol-relative URLs like //evil.com)
+ return null
}
- return redirectUrl
}
export const formatPaymentStatus = (status: string): string => {
@@ -1329,3 +1338,21 @@ export const generateInviteCodeLink = (username: string) => {
const inviteLink = `${consts.BASE_URL}/invite?code=${inviteCode}`
return { inviteLink, inviteCode }
}
+
+export const getValidRedirectUrl = (redirectUrl: string, fallbackRoute: string) => {
+ let decodedRedirect = redirectUrl
+ try {
+ decodedRedirect = decodeURIComponent(redirectUrl)
+ } catch {
+ // if decoding URI fails, push to /login as fallback
+ return fallbackRoute
+ }
+ const sanitizedRedirectUrl = sanitizeRedirectURL(decodedRedirect)
+ // Only redirect if the URL is safe (same-origin)
+ if (sanitizedRedirectUrl) {
+ return sanitizedRedirectUrl
+ } else {
+ // Reject external redirects, go to home instead
+ return fallbackRoute
+ }
+}