105 lines
5.5 KiB
TypeScript
105 lines
5.5 KiB
TypeScript
import { afterAll, beforeAll, expect, test } from "vitest";
|
|
import { createLocalAuthFixture, type LocalAuthFixture, localPublicUrl } from "./auth-test-fixtures.js";
|
|
|
|
interface StateChangingRoute {
|
|
family: string;
|
|
method: "POST" | "PUT" | "DELETE";
|
|
url: string;
|
|
}
|
|
|
|
const stateChangingRoutes: readonly StateChangingRoute[] = [
|
|
{ family: "auth logout", method: "POST", url: "/auth/logout" },
|
|
{ family: "runtime prewarm", method: "POST", url: "/runtime/prewarm" },
|
|
{ family: "session create", method: "POST", url: "/sessions" },
|
|
{ family: "session mutation", method: "POST", url: "/sessions/session-1/response" },
|
|
{ family: "session mutation", method: "POST", url: "/sessions/session-1/steer" },
|
|
{ family: "session mutation", method: "POST", url: "/sessions/session-1/resume" },
|
|
{ family: "session mutation", method: "POST", url: "/sessions/session-1/close" },
|
|
{ family: "session mutation", method: "POST", url: "/sessions/session-1/rename" },
|
|
{ family: "session mutation", method: "POST", url: "/sessions/session-1/group" },
|
|
{ family: "session archive", method: "POST", url: "/sessions/session-1/archive" },
|
|
{ family: "session archive", method: "POST", url: "/sessions/session-1/unarchive" },
|
|
{ family: "session delete", method: "DELETE", url: "/sessions/session-1" },
|
|
{ family: "SQL preview", method: "POST", url: "/sessions/session-1/sql/preview" },
|
|
{ family: "SQL export", method: "POST", url: "/sessions/session-1/sql/export" },
|
|
{ family: "workspace registry", method: "POST", url: "/workspace-registry/pull" },
|
|
{ family: "workspace validation", method: "POST", url: "/workspaces/validate" },
|
|
{ family: "workspace secrets", method: "PUT", url: "/workspaces/workspace-1/secrets" },
|
|
{ family: "workspace secrets", method: "DELETE", url: "/workspaces/workspace-1/secrets/secret-1" },
|
|
{ family: "workspace diagnostics", method: "POST", url: "/workspaces/workspace-1/test" },
|
|
{ family: "settings", method: "PUT", url: "/settings" },
|
|
{ family: "Pi management", method: "PUT", url: "/pi-management/config" },
|
|
{ family: "Pi management", method: "POST", url: "/pi-management/test" },
|
|
{ family: "maintenance", method: "POST", url: "/internal/maintenance/activate" },
|
|
{ family: "maintenance", method: "POST", url: "/internal/maintenance/deactivate" },
|
|
];
|
|
|
|
let fixture: LocalAuthFixture;
|
|
|
|
beforeAll(async () => {
|
|
fixture = await createLocalAuthFixture();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await fixture.close();
|
|
});
|
|
|
|
test.each(stateChangingRoutes)(
|
|
"$family $method $url rejects every production CSRF/session-boundary failure before downstream code",
|
|
async ({ method, url }) => {
|
|
const failures: Array<{ expectedStatus: number; headers: Record<string, string> }> = [
|
|
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "x-thothii-csrf": undefined }) },
|
|
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "x-thothii-csrf": "malformed" }) },
|
|
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "x-thothii-csrf": `${fixture.csrfToken}, ${fixture.csrfToken}` }) },
|
|
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "x-thothii-csrf": "x".repeat(4097) }) },
|
|
{ expectedStatus: 401, headers: fixture.sessionHeaders({ cookie: undefined }) },
|
|
{ expectedStatus: 401, headers: fixture.sessionHeaders({ cookie: "thothii_session=not-a-token" }) },
|
|
{ expectedStatus: 401, headers: fixture.sessionHeaders({ cookie: `${fixture.cookie}; ${fixture.cookie}` }) },
|
|
{ expectedStatus: 401, headers: fixture.sessionHeaders({ cookie: `${fixture.cookie}; padding=${"x".repeat(4097)}` }) },
|
|
{ expectedStatus: 403, headers: fixture.sessionHeaders({ origin: undefined }) },
|
|
{ expectedStatus: 403, headers: fixture.sessionHeaders({ origin: "http://wrong.example.test" }) },
|
|
{ expectedStatus: 403, headers: fixture.sessionHeaders({ origin: `${localPublicUrl}, ${localPublicUrl}` }) },
|
|
{ expectedStatus: 403, headers: fixture.sessionHeaders({ origin: `${localPublicUrl}${"x".repeat(4097)}` }) },
|
|
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "sec-fetch-site": "cross-site" }) },
|
|
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "sec-fetch-site": "same-origin, same-origin" }) },
|
|
];
|
|
|
|
for (const { expectedStatus, headers } of failures) {
|
|
fixture.resetDownstreamHits();
|
|
const response = await fixture.app.inject({ method, url, headers, payload: {} });
|
|
expect(response.statusCode).toBe(expectedStatus);
|
|
expect(response.json()).toEqual(expectedStatus === 403
|
|
? { code: "csrf_failed", error: "Request origin validation failed" }
|
|
: { code: "authentication_required", error: "Authentication is required" });
|
|
expect(fixture.downstreamHits()).toBe(0);
|
|
}
|
|
},
|
|
);
|
|
|
|
test("a valid real local session cookie and derived CSRF token cross the same production boundary", async () => {
|
|
fixture.resetDownstreamHits();
|
|
const response = await fixture.app.inject({
|
|
method: "PUT",
|
|
url: "/settings",
|
|
headers: fixture.sessionHeaders(),
|
|
payload: {},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(fixture.downstreamHits()).toBe(1);
|
|
expect(localPublicUrl).toBe("http://127.0.0.1:8787");
|
|
});
|
|
|
|
test("a valid control may omit optional Fetch Metadata while retaining its exact Origin and CSRF pair", async () => {
|
|
fixture.resetDownstreamHits();
|
|
const response = await fixture.app.inject({
|
|
method: "PUT",
|
|
url: "/settings",
|
|
headers: fixture.sessionHeaders({ "sec-fetch-site": undefined }),
|
|
payload: {},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(fixture.downstreamHits()).toBe(1);
|
|
});
|