| 1 | import { test } from "node:test"; |
| 2 | import assert from "node:assert/strict"; |
| 3 | import { safeNext } from "./safe-next.js"; |
| 4 | |
| 5 | const ORIGIN = "https://reasonix.io"; |
| 6 | |
| 7 | // The security invariant: whatever safeNext returns, re-resolving it the way a |
| 8 | // browser does when it assigns `location.href` must stay on our origin or an |
| 9 | // allowed reasonix.io host. Asserting this (rather than a fixed string) catches |
| 10 | // any value that looks same-origin but re-parses off-site. |
| 11 | function assertLandsSomewhereSafe(returned) { |
| 12 | if (returned === null) return; |
| 13 | const landed = new URL(returned, ORIGIN); |
| 14 | const ok = landed.origin === ORIGIN || landed.host === "reasonix.io" || landed.host.endsWith(".reasonix.io"); |
| 15 | assert.ok(ok, `returned ${JSON.stringify(returned)} re-resolves off-site to ${landed.href}`); |
| 16 | } |
| 17 | |
| 18 | test("same-origin path passes through as an absolute same-origin URL", () => { |
| 19 | const got = safeNext("/account/settings?x=1#y", ORIGIN); |
| 20 | assert.equal(got, "https://reasonix.io/account/settings?x=1#y"); |
| 21 | assertLandsSomewhereSafe(got); |
| 22 | }); |
| 23 | |
| 24 | test("allowed reasonix.io subdomain passes through", () => { |
| 25 | const got = safeNext("https://crash.reasonix.io/x", ORIGIN); |
| 26 | assert.equal(got, "https://crash.reasonix.io/x"); |
| 27 | assertLandsSomewhereSafe(got); |
| 28 | }); |
| 29 | |
| 30 | test("empty/missing next", () => { |
| 31 | assert.equal(safeNext("", ORIGIN), null); |
| 32 | assert.equal(safeNext(null, ORIGIN), null); |
| 33 | }); |
| 34 | |
| 35 | test("rejects a plain protocol-relative redirect", () => { |
| 36 | assert.equal(safeNext("//evil.example", ORIGIN), null); |
| 37 | }); |
| 38 | |
| 39 | test("rejects a backslash-prefixed redirect", () => { |
| 40 | assert.equal(safeNext("/\\evil.example", ORIGIN), null); |
| 41 | assert.equal(safeNext("/\\/evil.example", ORIGIN), null); |
| 42 | }); |
| 43 | |
| 44 | test("rejects control characters (as decoded by URLSearchParams) smuggling a protocol-relative redirect", () => { |
| 45 | // URLSearchParams.get() decodes %09/%0A/%0D before this function ever sees |
| 46 | // the value, e.g. "?next=/%09/evil.example" arrives as "/\t/evil.example". |
| 47 | for (const c of ["\t", "\n", "\r"]) { |
| 48 | assert.equal(safeNext(`/${c}/evil.example`, ORIGIN), null, JSON.stringify(c)); |
| 49 | } |
| 50 | }); |
| 51 | |
| 52 | test("dot-segment inputs that normalize to a //host pathname do not escape", () => { |
| 53 | // These resolve to a same-origin URL whose pathname is "//evil.example". |
| 54 | // Returning the bare pathname would re-parse as a protocol-relative redirect; |
| 55 | // returning the absolute href keeps them on reasonix.io. Assert end to end. |
| 56 | for (const p of ["/.//evil.example", "/a/..//evil.example", "/..//evil.example", "/%2e//evil.example"]) { |
| 57 | const got = safeNext(decodeURIComponent(p), ORIGIN); |
| 58 | assert.notEqual(got, null, p); |
| 59 | assert.equal(new URL(got, ORIGIN).origin, ORIGIN, `${p} -> ${got}`); |
| 60 | assertLandsSomewhereSafe(got); |
| 61 | } |
| 62 | }); |
| 63 | |
| 64 | test("rejects an unrelated https host and non-https schemes", () => { |
| 65 | assert.equal(safeNext("https://evil.example/", ORIGIN), null); |
| 66 | assert.equal(safeNext("javascript:alert(1)", ORIGIN), null); |
| 67 | }); |
| 68 | |
| 69 | test("rejects a reasonix.io-lookalike host", () => { |
| 70 | assert.equal(safeNext("https://reasonix.io.evil.example/", ORIGIN), null); |
| 71 | }); |
| 72 |