|
| 1 | +// The post-login `redirect` query param is attacker-controlled, so it must |
| 2 | +// never be handed to `redirect()`/`goto()` without validation — otherwise it |
| 3 | +// is an open redirect (an attacker can send `?redirect=https://evil.example` |
| 4 | +// or the protocol-relative `?redirect=//evil.example` to bounce a logged-in |
| 5 | +// user off-site). Only same-origin, path-absolute targets are allowed. |
| 6 | + |
| 7 | +export const DEFAULT_REDIRECT = '/portal/dashboard'; |
| 8 | + |
| 9 | +/** |
| 10 | + * True if `value` contains any C0 control character (U+0000–U+001F, incl. |
| 11 | + * TAB/LF/CR). Browsers strip these from URLs before navigating, so a value |
| 12 | + * like `/\t/evil.example` or `/\n//evil.example` would slip past the slash |
| 13 | + * checks below and then normalise to a protocol-relative, off-origin URL. |
| 14 | + * Strip-then-parse is the classic open-redirect bypass, so we reject any such |
| 15 | + * value up front. (Expressed as a char-code scan to keep literal control |
| 16 | + * characters out of the source.) |
| 17 | + */ |
| 18 | +function hasControlChar(value: string): boolean { |
| 19 | + for (let i = 0; i < value.length; i++) { |
| 20 | + if (value.charCodeAt(i) <= 0x1f) return true; |
| 21 | + } |
| 22 | + return false; |
| 23 | +} |
| 24 | + |
| 25 | +/** |
| 26 | + * Return a safe post-login redirect target. A value is only accepted when it |
| 27 | + * begins with a single `/` (a same-origin absolute path). Everything else — |
| 28 | + * empty/missing values, values containing control characters, absolute URLs |
| 29 | + * (`https://…`), protocol-relative URLs (`//host`) and the backslash variant |
| 30 | + * browsers normalise to them (`/\host`) — falls back to {@link DEFAULT_REDIRECT}. |
| 31 | + */ |
| 32 | +export function safeRedirect(target: string | null | undefined): string { |
| 33 | + if (!target) return DEFAULT_REDIRECT; |
| 34 | + if (hasControlChar(target)) return DEFAULT_REDIRECT; |
| 35 | + if (!target.startsWith('/')) return DEFAULT_REDIRECT; |
| 36 | + // Reject protocol-relative URLs. Browsers treat `\` as `/`, so `/\host` |
| 37 | + // escapes the origin just like `//host` does. |
| 38 | + if (target[1] === '/' || target[1] === '\\') return DEFAULT_REDIRECT; |
| 39 | + return target; |
| 40 | +} |
0 commit comments