返回 DeepSeek-Reasonix
safe-next.test.mjs
根目录 / site / src / scripts / safe-next.test.mjs
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
72 lines Plain Text