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:
Binary file not shown.
@@ -1,19 +1,49 @@
|
|||||||
/**
|
/**
|
||||||
* Resolve the post-login redirect target. If the caller supplied a `?to=`
|
* 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
|
* query that is a same-origin, well-formed relative path, honour it;
|
||||||
* to the auth-store's resolveLandingRoute().
|
* otherwise fall back to the auth-store's resolveLandingRoute().
|
||||||
*
|
*
|
||||||
* The `startsWith('/')` + `!startsWith('//')` guard is the **minimum**
|
* `isSafeRelativePath` rejects every input that is not a strict relative
|
||||||
* A13-3 (open-redirect) precaution. Full domain-validation lands in
|
* path: missing/empty, absolute, protocol-relative (`//`), backslash-bearing
|
||||||
* WS-3 PR-B2b.
|
* (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(
|
export function resolvePostLoginTarget(
|
||||||
rawTo: string | null | undefined,
|
rawTo: string | null | undefined,
|
||||||
fallback: () => string,
|
fallback: () => string,
|
||||||
): string {
|
): string {
|
||||||
const to = rawTo ?? ''
|
const to = rawTo ?? ''
|
||||||
if (to.startsWith('/') && !to.startsWith('//'))
|
|
||||||
return to
|
|
||||||
|
|
||||||
return fallback()
|
return isSafeRelativePath(to) ? to : fallback()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user