diff --git a/apps/app/src/utils/__tests__/postLoginRedirect.spec.ts b/apps/app/src/utils/__tests__/postLoginRedirect.spec.ts index 343df18e..d5bd67c5 100644 Binary files a/apps/app/src/utils/__tests__/postLoginRedirect.spec.ts and b/apps/app/src/utils/__tests__/postLoginRedirect.spec.ts differ diff --git a/apps/app/src/utils/postLoginRedirect.ts b/apps/app/src/utils/postLoginRedirect.ts index 2cafece6..9fde4ddf 100644 --- a/apps/app/src/utils/postLoginRedirect.ts +++ b/apps/app/src/utils/postLoginRedirect.ts @@ -1,19 +1,49 @@ /** * Resolve the post-login redirect target. If the caller supplied a `?to=` - * query that's a same-origin relative path, honour it; otherwise fall back - * to the auth-store's resolveLandingRoute(). + * query that is a same-origin, well-formed relative path, honour it; + * otherwise fall back to the auth-store's resolveLandingRoute(). * - * The `startsWith('/')` + `!startsWith('//')` guard is the **minimum** - * A13-3 (open-redirect) precaution. Full domain-validation lands in - * WS-3 PR-B2b. + * `isSafeRelativePath` rejects every input that is not a strict relative + * path: missing/empty, absolute, protocol-relative (`//`), backslash-bearing + * (browsers normalise `\` → `/` in some contexts), control-character-bearing, + * or anything the URL constructor parses to a different origin than our + * synthetic invalid origin. The URL-constructor check is the authoritative + * guard — the prefix and character checks are fast pre-filters. + * + * Closes A13-3 (open-redirect on post-login). The minimum precaution from + * WS-3 PR-B2a (`startsWith('/') && !startsWith('//')`) is now superseded. */ + +const SYNTHETIC_ORIGIN = 'https://__crewli_safe_relative_check__.invalid' + +function isSafeRelativePath(to: string): boolean { + if (!to || !to.startsWith('/') || to.startsWith('//')) + return false + + if (to.includes('\\')) + return false + + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1F\x7F]/.test(to)) + return false + + try { + const url = new URL(to, SYNTHETIC_ORIGIN) + if (url.origin !== SYNTHETIC_ORIGIN) + return false + } + catch { + return false + } + + return true +} + export function resolvePostLoginTarget( rawTo: string | null | undefined, fallback: () => string, ): string { const to = rawTo ?? '' - if (to.startsWith('/') && !to.startsWith('//')) - return to - return fallback() + return isSafeRelativePath(to) ? to : fallback() }