feat(security): full A13-3 open-redirect validation in postLoginRedirect

Replaces the WS-3 PR-B2a minimum precaution (`startsWith('/') &&
!startsWith('//')`) with a layered validator that rejects every input
that is not a strict relative path.

isSafeRelativePath rejects:
- Empty / null / undefined input
- Non-`/`-prefixed paths (including leading whitespace)
- Protocol-relative URLs (`//evil.com`)
- Backslash anywhere (browsers normalise `\` → `/` in some contexts;
  `/\evil.com` parses as `//evil.com`)
- ASCII control characters `\x00`–`\x1F` and `\x7F` (NUL, tab, LF, CR,
  DEL, etc. — header-injection vectors)
- Anything the URL constructor parses to a different origin than the
  synthetic invalid origin used as the resolution base

The URL-constructor check is the authoritative guard; the prefix and
character checks are fast pre-filters that short-circuit common
attack shapes without paying the URL allocation.

Test coverage expands from 6 → 16 cases. New cases pin the
backslash, control-character, leading-whitespace, and positive-
character-set contracts. The URL-encoded-slash-in-query case
documents that we don't false-positive on `%2F` in query strings.

Closes A13-3 (open-redirect on post-login).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 00:20:12 +02:00
parent 538072241e
commit 96cb1519de
2 changed files with 38 additions and 8 deletions

View File

@@ -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()
}