diff --git a/package.json b/package.json index 84430d3..c83f57d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "prebuild": "npm run gen:llms", "build": "tsc -b && vite build", "lint": "eslint .", + "test": "node --test \"src/**/*.test.ts\"", "preview": "vite preview", "sync:thinly": "node scripts/sync-to-thinly.mjs", "build:thinly": "npm run build && npm run sync:thinly" diff --git a/src/levelcode/next.test.ts b/src/levelcode/next.test.ts new file mode 100644 index 0000000..d73733b --- /dev/null +++ b/src/levelcode/next.test.ts @@ -0,0 +1,216 @@ +// node --test (npm test). `next`: which pages a sign-in may land on, and which it ignores. +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { loginRoute, NEXT_MAX, safeNext, signedInDestination, type WebEditorAnswer } from "./next.ts"; + +describe("safeNext", () => { + const accepted: Array<[string, string]> = [ + ["/ai/account", "an auth-gated page"], + ["/ai/admin/referrals", "a nested page"], + ["/ai/pricing?plan=pro", "a query is kept"], + ["/ai/account?checkout=changed&x=1", "a query with several keys"], + ["/ai/docs/cloud", "a docs page"], + ["/ai/x", "the shortest: one character after /ai/"], + ["/ai/docs/a.b", "a dot inside a name is not a dot segment"], + ["/ai/docs/...", "three dots are a name, not a dot segment"], + ["/ai/docs?q=a%20b", "a percent escape that is not a separator"], + ["/ai/pricing?next=/ai/account", "a single slash inside the query"], + ["/ai/%252F%252Fevil.example", "double-encoded: a literal path under /ai/; nothing after /ai/ can make it start //"], + ["/ai/" + "a".repeat(NEXT_MAX - 4), "exactly the longest"], + ["/ai/csrfx", "a name that starts like an endpoint's is not that endpoint"], + ["/ai/docs/auth", "an endpoint's name further down is a page's"], + ]; + for (const [raw, why] of accepted) { + test(`takes ${JSON.stringify(raw.length > 40 ? raw.slice(0, 40) + "…" : raw)}: ${why}`, () => { + assert.equal(safeNext(raw), raw); + // What a browser makes of it: a page of this site, under /ai/. + const url = new URL(raw, "https://levelcode.ai"); + assert.equal(url.origin, "https://levelcode.ai"); + assert.ok(url.pathname.startsWith("/ai/"), url.pathname); + }); + } + + const refused: Array<[unknown, string]> = [ + // Not a string, or nothing + [null, "absent"], + [undefined, "absent"], + [42, "a number"], + [["/ai/account"], "an array (a repeated query key)"], + [{ toString: () => "/ai/account" }, "an object that stringifies to a page"], + ["", "empty"], + // Not under /ai/ + ["/ai/", "the prefix alone"], + ["/ai", "the landing without its slash"], + ["/ai?x=1", "the landing's query"], + ["/aisle", "a lookalike prefix"], + ["/other/path", "another part of the site"], + ["/", "the root"], + ["ai/account", "relative"], + ["account", "relative"], + // Another origin, in any spelling + ["//evil.example", "protocol-relative"], + ["//evil.example/ai/account", "protocol-relative, with an /ai/ path"], + ["https://evil.example/ai/account", "absolute, another host"], + ["https://levelcode.ai/ai/account", "absolute, even this host: a next is a path"], + ["javascript:alert(1)", "a script URL"], + ["/\\evil.example", "a backslash is a slash to a browser"], + ["/ai/\\evil.example", "a backslash after the prefix"], + ["/ai\\account", "a backslash in place of the slash"], + // Doubled slashes anywhere + ["/ai//x", "doubled after the prefix"], + ["/ai/x//y", "doubled further in"], + ["/ai/pricing?u=//evil.example", "doubled in the query"], + // Percent-encoded separators + ["/ai/%2F%2Fevil", "encoded slashes"], + ["/ai/%2f%2fevil", "encoded slashes, lower case"], + ["/ai/%2Fx", "one encoded slash"], + ["/ai/x?u=%2F%2Fevil", "encoded slashes in the query"], + ["/ai/%5Cevil", "an encoded backslash"], + ["/ai/%5cevil", "an encoded backslash, lower case"], + // Dot segments, plain or encoded: a browser resolves them + ["/ai/../account", "a parent segment"], + ["/ai/./account", "a current segment"], + ["/ai/docs/..", "a trailing parent segment"], + ["/ai/docs/../../x", "climbing out of /ai/"], + ["/ai/%2e%2e/account", "an encoded parent segment"], + ["/ai/%2E%2E/account", "an encoded parent segment, upper case"], + ["/ai/.%2e/account", "half encoded"], + ["/ai/%2e/account", "an encoded current segment"], + // The server's own endpoints: not pages — in every spelling its router answers + ["/ai/auth/handoff?code=x", "the editor's account hand-off"], + ["/ai/auth/oauth/github", "an OAuth start"], + ["/ai/csrf", "the CSRF token endpoint"], + ["/ai/csrf.json", "the endpoint with a format suffix (Rails routes take one)"], + ["/ai/csrf/", "the endpoint with a trailing slash"], + ["/ai/csrf?x=1", "the endpoint with a query"], + ["/ai/%63srf", "the endpoint with an encoded letter"], + ["/ai/CSRF", "the endpoint in capitals"], + ["/ai/auth.json/handoff", "auth with a format suffix"], + ["/ai/%61uth/handoff?code=x", "auth with an encoded letter"], + ["/ai/checkout%A9", "an endpoint's name before an escape that does not decode"], + ["/ai/authorize_editor", "a POST endpoint"], + ["/ai/checkout", "a POST endpoint"], + ["/ai/billing", "a POST endpoint"], + ["/ai/signout", "a POST endpoint"], + // Fragments + ["/ai/account#billing", "a fragment"], + ["/ai/account#", "an empty fragment"], + // Whitespace and control characters + ["/ai/acc ount", "a space"], + ["/ai/account\t", "a tab"], + ["/ai/account\n", "a newline"], + ["/ai/account\r\nSet-Cookie: x=1", "a header-injection attempt"], + ["/ai/a\u0000", "NUL"], + ["/ai/a\u007f", "DEL"], + ["/ai/a\u0085", "a C1 control"], + ["/ai/a\u00a0", "a no-break space"], + ["/ai/a\u2028", "a line separator"], + ["/ai/a\u200b", "a zero-width space"], + ["/ai/a\u202e", "a right-to-left override"], + [" /ai/account", "leading whitespace"], + // Characters RFC 3986 does not allow unescaped, and broken escapes + ["/ai/\u00e9", "non-ASCII"], + ["/ai/