| 1 | // A post-login `next` redirect target is honoured only if it resolves — via |
| 2 | // full URL parsing against the real page origin, so control characters and |
| 3 | // protocol-relative tricks get the same normalization a browser applies |
| 4 | // before navigating — to a same-origin path, or to an absolute https URL |
| 5 | // under reasonix.io. That lets a subdomain (e.g. crash.reasonix.io) return |
| 6 | // here after sign-in without opening a redirect to an arbitrary host. |
| 7 | // |
| 8 | // Validating a raw string prefix (e.g. checking it starts with "/") is not |
| 9 | // enough: URLSearchParams decodes percent-encoding, so a value like |
| 10 | // "/%09/evil.example" arrives as "/\t/evil.example", which passes a naive |
| 11 | // prefix check but the URL parser strips the tab during navigation, leaving |
| 12 | // "//evil.example" — a protocol-relative redirect off-site. Parsing with |
| 13 | // `new URL()` first and checking the *parsed* origin/host closes that gap |
| 14 | // because it uses the same normalization the browser itself applies. |
| 15 | // |
| 16 | // Both branches return the fully-serialized `u.href`, never a relative |
| 17 | // fragment. Returning `u.pathname` would reopen the hole: dot-segment inputs |
| 18 | // such as "/.//evil.example" or "/a/..//evil.example" resolve to a |
| 19 | // same-origin URL whose *pathname* is "//evil.example", and handing that |
| 20 | // bare pathname back to `location.href` re-parses it as a protocol-relative |
| 21 | // URL to evil.example. `u.href` keeps the origin attached, so assigning it |
| 22 | // can never leave reasonix.io. |
| 23 | export function safeNext(next, origin) { |
| 24 | if (!next) return null; |
| 25 | let u; |
| 26 | try { |
| 27 | u = new URL(next, origin); |
| 28 | } catch { |
| 29 | return null; |
| 30 | } |
| 31 | if (u.origin === origin) return u.href; |
| 32 | if (u.protocol === "https:" && (u.host === "reasonix.io" || u.host.endsWith(".reasonix.io"))) return u.href; |
| 33 | return null; |
| 34 | } |
| 35 |