Skip to content
Open
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
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ it into magic.
Run `npm run check` and `npm run build`. A changed stage must remain runnable,
and journey assertions and generated contracts must agree with the example.

## Changelog

Any change to the `version` field in `package.json`, whether a release,
prerelease, or patch bump, must include a matching `## <version>` section in
`CHANGELOG.md` in the same commit or pull request. Date the section and list
breaking changes (with migration notes), deprecations, additions, and fixes.
Move entries from `Unreleased` into the new version section rather than leaving
them there. If the repository has no `CHANGELOG.md` yet, create one (Keep a
Changelog style) at the next version bump. Do not publish or tag a version whose
changelog section is missing. This package is private and not published to npm,
but the rule still applies whenever its `version` changes.

## Optimization Gate

A benchmark number is only half of an optimization's success criterion. The
Expand Down
8 changes: 6 additions & 2 deletions api-ssr/src/features/session/login-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createMutation } from "@askrjs/askr/data";
import type { AuthContext } from "@askrjs/auth";
import { Button, Card, CardContent, CardHeader, CardTitle, Stack } from "@askrjs/themes/components";
import { OperationsLayout } from "../../application/layout.js";
import { safeNextPath } from "./redirect.js";
import { responseJson } from "../shared/transport.js";

export function LoginPage() {
Expand All @@ -22,8 +23,11 @@ export function LoginPage() {
const next =
typeof window === "undefined"
? "/workspace"
: (new URLSearchParams(window.location.search).get("next") ?? "/workspace");
window.location.assign(next.startsWith("/") ? next : "/workspace");
: safeNextPath(
new URLSearchParams(window.location.search).get("next"),
window.location.origin,
);
window.location.assign(next);
};

return (
Expand Down
40 changes: 40 additions & 0 deletions api-ssr/src/features/session/redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const WORKSPACE_PATH = "/workspace";

export function safeNextPath(value: string | null | undefined, origin: string): string {
if (!value?.startsWith("/") || value.startsWith("//")) return WORKSPACE_PATH;

const queryIndex = value.indexOf("?");
const hashIndex = value.indexOf("#");
const pathEnd = Math.min(
queryIndex < 0 ? value.length : queryIndex,
hashIndex < 0 ? value.length : hashIndex,
);
const path = value.slice(0, pathEnd);
if (path.includes("\\")) return WORKSPACE_PATH;

try {
const decoded = decodeURIComponent(value);
const decodedPath = decodeURIComponent(path);
if (
decodedPath.includes("\\") ||
[...decoded].some((character) => {
const code = character.charCodeAt(0);
return code <= 31 || code === 127;
}) ||
decodedPath.startsWith("//") ||
/^(?:\/)*(?:[a-z][a-z\d+.-]*:)/i.test(decodedPath) ||
/(?:^|\/)\.\.(?:\/|$)/.test(decodedPath)
) {
return WORKSPACE_PATH;
}

const base = new URL(origin);
const target = new URL(value, base);
if (target.origin !== base.origin || target.pathname.startsWith("//")) {
return WORKSPACE_PATH;
}
return `${target.pathname}${target.search}${target.hash}`;
} catch {
return WORKSPACE_PATH;
}
}
5 changes: 2 additions & 3 deletions api-ssr/src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { security } from "@askrjs/server/openapi";
import type { AskrAppApi } from "@askrjs/server/askr";
import type { AppDependencies } from "../boot/dependencies.js";
import { SESSION_COOKIE } from "../domains/sessions/repository.js";
import { safeNextPath } from "../features/session/redirect.js";
import type { UserUpdate } from "../domains/users/repository.js";

function parseVersion(value: string | null): number | null {
Expand Down Expand Up @@ -86,9 +87,7 @@ export function registerApiRoutes(api: AskrAppApi<AppDependencies>, schemas: Api
documentation: { query: { next: {} } },
async handler(ctx, input, { sessions }) {
const session = await sessions.create();
const requested = input.query.next;
const next =
requested?.startsWith("/") && !requested.startsWith("//") ? requested : "/workspace";
const next = safeNextPath(input.query.next, ctx.url.origin);
const response = ctx.headers.get("accept")?.includes("text/html")
? ctx.redirect(next, 303)
: ctx.created({ authenticated: true });
Expand Down
59 changes: 59 additions & 0 deletions api-ssr/tests/api-ssr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,65 @@ describe("API and SSR culmination", () => {
expect(await after.json()).toMatchObject({ authenticated: false });
});

it.each([
["protocol-relative URLs", "//attacker.example"],
["backslash URL forms", "/\\\\attacker.example"],
["dot-segment traversal", "/workspace/../login"],
])("should fall back to the workspace for %s in native login redirects", async (_name, next) => {
const app = createApp(createDependencies());
const response = await app.fetch(
new Request(`http://example.test/api/session?next=${encodeURIComponent(next)}`, {
method: "POST",
headers: { accept: "text/html" },
}),
);

expect(response.status).toBe(303);
expect(response.headers.get("location")).toBe("/workspace");
});

it("should preserve same-origin login redirect query and fragment values", async () => {
const app = createApp(createDependencies());
const next = "/workspace/users/1?tab=recent#activity";
const response = await app.fetch(
new Request(`http://example.test/api/session?next=${encodeURIComponent(next)}`, {
method: "POST",
headers: { accept: "text/html" },
}),
);

expect(response.status).toBe(303);
expect(response.headers.get("location")).toBe(next);
});

it("should preserve traversal-like text inside native redirect query values", async () => {
const app = createApp(createDependencies());
const next = "/workspace?path=/../reports#activity";
const response = await app.fetch(
new Request(`http://example.test/api/session?next=${encodeURIComponent(next)}`, {
method: "POST",
headers: { accept: "text/html" },
}),
);

expect(response.status).toBe(303);
expect(response.headers.get("location")).toBe(next);
});

it("should preserve encoded backslashes inside native redirect query values", async () => {
const app = createApp(createDependencies());
const next = "/workspace?token=a%5Cb#activity";
const response = await app.fetch(
new Request(`http://example.test/api/session?next=${encodeURIComponent(next)}`, {
method: "POST",
headers: { accept: "text/html" },
}),
);

expect(response.status).toBe(303);
expect(response.headers.get("location")).toBe(next);
});

it("should SSR prefetch dashboard data into hydration state without a loading branch", async () => {
const app = createApp(createDependencies());
const cookie = await signIn(app);
Expand Down
91 changes: 91 additions & 0 deletions api-ssr/tests/queries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
activityQuery,
dashboardQuery,
policyQuery,
sessionQuery,
userQuery,
usersQuery,
} from "../src/domains/queries.js";

type FetchCall = { url: string; init: RequestInit | undefined };

function stubFetch(body: unknown = {}): FetchCall[] {
const calls: FetchCall[] = [];
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
calls.push({ url, init });
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
});
return calls;
}

describe("defineQuery fetchers", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

const collectionCases = [
["sessionQuery", sessionQuery, "/api/session"],
["dashboardQuery", dashboardQuery, "/api/dashboard"],
["activityQuery", activityQuery, "/api/activity"],
["usersQuery", usersQuery, "/api/users"],
] as const;

it.each(collectionCases)(
"%s should forward the context signal to fetch",
async (_name, query, path) => {
const calls = stubFetch();
const controller = new AbortController();

await query.fetch({}, { signal: controller.signal });

expect(calls).toHaveLength(1);
expect(calls[0]?.url).toBe(path);
expect(calls[0]?.init?.signal).toBe(controller.signal);
},
);

const detailCases = [
["userQuery", userQuery, "/api/users/a%2Fb"],
["policyQuery", policyQuery, "/api/policies/a%2Fb"],
] as const;

it.each(detailCases)(
"%s should read the id from input and forward the context signal",
async (_name, query, path) => {
const calls = stubFetch();
const controller = new AbortController();

await query.fetch({ id: "a/b" }, { signal: controller.signal });

expect(calls).toHaveLength(1);
expect(calls[0]?.url).toBe(path);
expect(calls[0]?.init?.signal).toBe(controller.signal);
},
);

it("should cancel an in-flight request when the context signal aborts", async () => {
let started!: () => void;
const inFlight = new Promise<void>((resolve) => (started = resolve));
vi.stubGlobal("fetch", (_url: string, init?: RequestInit) => {
started();
// Never settles on its own: only an abort on the forwarded signal ends it.
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), {
once: true,
});
});
});
const controller = new AbortController();
const reason = new Error("navigated away");

const pending = userQuery.fetch({ id: "1" }, { signal: controller.signal });
await inFlight;
controller.abort(reason);

await expect(pending).rejects.toBe(reason);
});
});
35 changes: 35 additions & 0 deletions api-ssr/tests/redirect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { safeNextPath } from "../src/features/session/redirect.js";

describe("safeNextPath", () => {
it("should preserve a same-origin path, query, and fragment", () => {
expect(safeNextPath("/workspace/users/1?tab=recent#activity", "https://example.test")).toBe(
"/workspace/users/1?tab=recent#activity",
);
});

it("should preserve traversal-like text in query values", () => {
expect(safeNextPath("/workspace?path=/../reports#activity", "https://example.test")).toBe(
"/workspace?path=/../reports#activity",
);
});

it("should preserve encoded backslashes in query values", () => {
expect(safeNextPath("/workspace?token=a%5Cb#activity", "https://example.test")).toBe(
"/workspace?token=a%5Cb#activity",
);
});

it.each([
"//attacker.example",
"///attacker.example",
"/\\\\attacker.example",
"https://attacker.example/path",
"/workspace/../login",
"/workspace/%2e%2e/login",
"/%5c%5cattacker.example",
"workspace",
])("should fall back for unsafe or non-path values: %s", (next) => {
expect(safeNextPath(next, "https://example.test")).toBe("/workspace");
});
});
Loading