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=`
|
||||
* 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user