Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
216 changes: 216 additions & 0 deletions src/levelcode/next.test.ts
Original file line number Diff line number Diff line change
@@ -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/<script>", "angle brackets"],
['/ai/"x"', "quotes"],
["/ai/a|b", "a pipe"],
["/ai/{x}", "braces"],
["/ai/%zz", "a broken escape"],
["/ai/%2", "a truncated escape"],
// Too long
["/ai/" + "a".repeat(NEXT_MAX - 3), "one character over the limit"],
];
for (const [raw, why] of refused) {
const shown = typeof raw === "string" ? JSON.stringify(raw.length > 40 ? raw.slice(0, 40) + "…" : raw) : String(raw);
test(`ignores ${shown}: ${why}`, () => {
assert.equal(safeNext(raw), null);
});
}
});

describe("loginRoute", () => {
/** The `next` the login page will read from a route loginRoute built. */
const nextOf = (route: string) => new URL(route, "https://levelcode.ai/ai/").searchParams.get("next");

test("a plain sign-in carries no next", () => {
assert.equal(loginRoute(), "/login");
assert.equal(loginRoute(null), "/login");
});

test("a page comes back to the login page exactly as it went in", () => {
for (const page of ["/ai/account", "/ai/pricing?plan=pro", "/ai/admin/referrals", "/ai/account?checkout=changed&x=1"]) {
const route = loginRoute(page);
assert.ok(route.startsWith("/login?next="), route);
assert.equal(nextOf(route), page);
}
});

test("a page that is not a valid next is dropped", () => {
for (const page of ["/ai/", "/ai//x", "/ai/%2F%2Fevil", "/ai/account#billing", "/web", ""]) {
assert.equal(loginRoute(page), "/login", page);
}
});
});

describe("signedInDestination", () => {
const EDITOR_URL = "https://app.levelcode.example/?signin=1";
const ON: WebEditorAnswer = { enabled: true, url: EDITOR_URL, loading: false };
const OFF: WebEditorAnswer = { enabled: false, url: null, loading: false };
const LOADING: WebEditorAnswer = { enabled: false, url: null, loading: true };

test("an editor signing in wins over next and the browser edition", () => {
for (const webEditor of [ON, OFF, LOADING]) {
for (const next of [null, "/ai/pricing"]) {
assert.deepEqual(signedInDestination({ editorSigningIn: true, next, webEditor }), { kind: "editor" });
}
}
});

test("next wins over the browser edition, as a route under the /ai basename", () => {
for (const webEditor of [ON, OFF]) {
assert.deepEqual(signedInDestination({ editorSigningIn: false, next: "/ai/pricing?plan=pro", webEditor }), {
kind: "route",
to: "/pricing?plan=pro",
});
}
});

test("next does not wait for the browser edition's answer", () => {
assert.deepEqual(signedInDestination({ editorSigningIn: false, next: "/ai/account", webEditor: LOADING }), {
kind: "route",
to: "/account",
});
});

test("with no next, it waits for the browser edition's answer", () => {
assert.deepEqual(signedInDestination({ editorSigningIn: false, next: null, webEditor: LOADING }), { kind: "wait" });
});

test("with no next and the browser edition on, it is the editor's address as given", () => {
assert.deepEqual(signedInDestination({ editorSigningIn: false, next: null, webEditor: ON }), {
kind: "href",
to: EDITOR_URL,
});
});

test("with no next and the browser edition off, it is the account page", () => {
assert.deepEqual(signedInDestination({ editorSigningIn: false, next: null, webEditor: OFF }), {
kind: "route",
to: "/account",
});
});

test("enabled with no address counts as off", () => {
const webEditor = { enabled: true, url: null, loading: false };
assert.deepEqual(signedInDestination({ editorSigningIn: false, next: null, webEditor }), {
kind: "route",
to: "/account",
});
});

test("a next that is not valid counts as absent", () => {
for (const next of ["//evil.example", "https://evil.example/", "/ai/%2F%2Fevil", "/other"]) {
assert.deepEqual(signedInDestination({ editorSigningIn: false, next, webEditor: ON }), { kind: "href", to: EDITOR_URL }, next);
assert.deepEqual(signedInDestination({ editorSigningIn: false, next, webEditor: OFF }), { kind: "route", to: "/account" }, next);
}
});
});
140 changes: 140 additions & 0 deletions src/levelcode/next.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Where a sign-in on this site lands, and `next`: the page of this site a visitor asked for before
// being sent to sign in.
//
// A sign-in that is not an editor's lands on (the backend decides, POST /ai/auth/verify and the
// OAuth callback; this file is the SPA's side of the same contract):
// 1. `next`, when it is a valid page of this site (safeNext);
// 2. else the browser edition, when it is switched on (useWebEditor's address, `?signin=1`);
// 3. else /ai/account.
// An editor signing in (redirect_uri + code_challenge) always wins, and `next` is not sent with it.
//
// This module imports nothing, so `node --test` can load it as it is (see next.test.ts).

const SITE = "https://levelcode.ai";
const PREFIX = "/ai/";

/** The longest `next` that is taken. The backend uses the same bound. */
export const NEXT_MAX = 512;

// The characters RFC 3986 allows in a path and a query, and nothing else: letters, digits,
// - . _ ~ ! $ & ' ( ) * + , ; = : @ / ? and a percent escape. That alone keeps out whitespace,
// control characters, a backslash, a fragment (#) and anything outside ASCII.
const PATH_AND_QUERY = /^(?:[A-Za-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9A-Fa-f]{2})+$/;

// A percent-encoded / or \. Its only use in a `next` is to get a separator past a check: any layer
// that decodes the value once would turn `/ai/%2F%2Fevil.example` into `/ai///evil.example`.
const ENCODED_SEPARATOR = /%2f|%5c/i;

// What the backend serves under /ai/ itself (thin.ly config/routes.rb, the "ai" scope): not pages to land on.
// Rails answers each with a format suffix (`.json`) and a trailing slash too, so a name is compared after
// decoding it, in lower case, and cut at the first character a route name does not have.
const SERVER_NAMES = new Set(["auth", "authorize_editor", "checkout", "billing", "signout", "csrf"]);

/**
* `raw` if it is a page of this site to land on after signing in, else null (treated as absent).
*
* Valid: a string of 5 to 512 characters that starts with `/ai/` and is a plain path, optionally
* with a query (`/ai/pricing?plan=pro`). Refused: anything else, including
* - another origin in any spelling: `https://…`, `//host`, `/\host`, `javascript:…`;
* - `//` anywhere, a backslash, a fragment (`#…`), whitespace or a control character;
* - a character RFC 3986 does not allow unescaped (`<`, `"`, `|`, non-ASCII, …) or a broken `%` escape;
* - a percent-encoded slash or backslash (`%2F`, `%5C`, any case);
* - a dot segment, plain or percent-encoded (`/ai/../x`, `/ai/%2e%2e/x`): a browser resolves both,
* and the page would no longer be the one under /ai/ that was checked;
* - the server's own endpoints (SERVER_NAMES), in any spelling its router answers: /ai/csrf, /ai/csrf.json,
* /ai/csrf/, /ai/%63srf, /ai/CSRF, /ai/auth/…: they are not pages (the backend refuses them too).
* A double-encoded value such as `/ai/%252F` is accepted: it is a literal path under /ai/, and since
* the value must begin with the literal `/ai/`, no decoding of what follows can make it start `//`.
*/
export function safeNext(raw: unknown): string | null {
if (typeof raw !== "string") return null;
if (raw.length <= PREFIX.length || raw.length > NEXT_MAX) return null;
if (!raw.startsWith(PREFIX)) return null;
if (!PATH_AND_QUERY.test(raw)) return null;
if (raw.includes("//") || ENCODED_SEPARATOR.test(raw)) return null;

const path = raw.split("?", 1)[0];
if (path.split("/").some((segment) => isDotSegment(segment))) return null;
if (isServerName(path.split("/")[2] ?? "")) return null;

// The contract's own words: parsed against this site, it stays on this site, under /ai/. The rules above
// already make that so (a literal /ai/ prefix, no //, backslash or dot segment); this guards them.
let url: URL;
try {
url = new URL(raw, SITE);
} catch {
return null;
}
if (url.origin !== SITE || !url.pathname.startsWith(PREFIX)) return null;

return raw;
}

/** Is this first segment after /ai/ one of the backend's own endpoints, as written or decoded? A name is read as
* the router would: in lower case, up to the first character that is not a letter, digit or underscore
* (`csrf.json` and `csrf;x` are `csrf`). Read both ways, `%63srf` and `checkout%A9` are endpoints too. */
function isServerName(segment: string): boolean {
const name = (s: string) => (/^[a-z0-9_]*/.exec(s.toLowerCase()) ?? [""])[0];
if (SERVER_NAMES.has(name(segment))) return true;
try {
return SERVER_NAMES.has(name(decodeURIComponent(segment)));
} catch {
return false; // not decodable: it is read as written, above
}
}

function isDotSegment(segment: string): boolean {
const s = segment.replace(/%2e/gi, ".");
return s === "." || s === "..";
}

/** The page the visitor is on now, as a `next` would name it (`/ai/account?checkout=changed`). */
export function currentPage(): string {
return window.location.pathname + window.location.search;
}

/**
* The sign-in page's route (relative to the router's /ai basename), bringing the visitor back to
* `page` afterwards. A `page` that is not a valid `next` is dropped, and the visitor lands wherever
* any sign-in does. Leave `page` out for a plain "Sign in".
*/
export function loginRoute(page?: string | null): string {
const next = safeNext(page);
return next ? `/login?${new URLSearchParams({ next })}` : "/login";
}

export type WebEditorAnswer = { enabled: boolean; url: string | null; loading: boolean };

export type SignedInDestination =
/** An editor is signing in: mint it a code (the login page's existing flow). */
| { kind: "editor" }
/** Not known yet: the browser edition's answer is still loading. */
| { kind: "wait" }
/** A page of this app, as a route relative to the /ai basename. */
| { kind: "route"; to: string }
/** The browser edition, on its own origin: a full-page navigation. */
| { kind: "href"; to: string };

/**
* Where the login page sends a visitor who is ALREADY signed in, in the same order as a sign-in
* lands (see the top of this file). `webEditor` is useWebEditor's answer, which has already refused
* any address it would not link to.
*/
export function signedInDestination({
editorSigningIn,
next,
webEditor,
}: {
editorSigningIn: boolean;
next: string | null;
webEditor: WebEditorAnswer;
}): SignedInDestination {
if (editorSigningIn) return { kind: "editor" };

const page = safeNext(next);
if (page) return { kind: "route", to: page.slice(PREFIX.length - 1) };

if (webEditor.loading) return { kind: "wait" };
if (webEditor.enabled && webEditor.url) return { kind: "href", to: webEditor.url };
return { kind: "route", to: "/account" };
}
8 changes: 5 additions & 3 deletions src/levelcode/pages/AccountPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { useSession } from "../auth";
import ClassicShell from "../components/classic/ClassicShell";
import ContributionsHeatmap from "./ContributionsHeatmap";
import { fmtCredits, fmtCreditAmount, toCredits, LOCALE } from "../credits";
import { currentPage, loginRoute } from "../next";
import { useWebEditor } from "../useWebEditor";

// Usage dashboard (SPEC §8 / §3 GET /account/usage). Session-gated: redirects to
// /ai/login when the Devise session probe comes back empty.
// /ai/login when the Devise session probe comes back empty, with `next` naming this
// page so the sign-in comes back here.
export default function AccountPage() {
const navigate = useNavigate();
const { loading: sessionLoading, profile } = useSession();
Expand All @@ -20,7 +22,7 @@ export default function AccountPage() {
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!sessionLoading && !profile) navigate("/login", { replace: true });
if (!sessionLoading && !profile) navigate(loginRoute(currentPage()), { replace: true });
}, [sessionLoading, profile, navigate]);

useEffect(() => {
Expand Down Expand Up @@ -85,7 +87,7 @@ export default function AccountPage() {
} catch {
/* ignore — clear locally regardless */
}
navigate("/login", { replace: true });
navigate("/login", { replace: true }); // a plain sign-in page: no `next` back to the account just left
}

// While the session resolves (or is absent, pre-redirect), keep the frame.
Expand Down
Loading