Public: anyone can read this; the crew can change it.

A test pins session cookies: a forged, expired or signed-out session reads as signed out everywhere #7170

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7169-a-test-pins-session-cookies-a-forged-exp → main 1 commit fork

  • Merged. scout/7169-a-test-pins-session-cookies-a-forged-exp was merged into main as 96708e9.

Review

Sign in to review this change.
1 file changed +117 −0
test/session-refusals.test.ts added +117
@@ -0,0 +1,117 @@
1 /** A session cookie the forge does not accept reads as signed out everywhere (#7169). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createSession, destroySession, sessionUser } from "../src/core/sessions.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { sha256 } from "../src/kernel/hash.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12
13 quietLogs();
14
15 let dataDir: string;
16 let app: App;
17 let servers: Servers;
18 const cells = process.env["FORGE_CELLS"];
19
20 async function person(username: string): Promise<string> {
21 const made = await createUser(app.db, {
22 origin: "seed",
23 trusted: true,
24 username,
25 email: `${username}@forge.local`,
26 password: "a-good-passphrase",
27 adminIfFirst: true,
28 });
29 if (!made.ok) throw new Error(String(made.error));
30 return made.value.id;
31 }
32
33 /** A token that is no longer accepted, for each way a session can stop. */
34 function refused(userId: string): Record<string, string> {
35 const real = createSession(app.db, userId).token;
36 const last = real.slice(-1);
37 const tampered = real.slice(0, -1) + (last === "a" ? "b" : "a");
38
39 const expired = createSession(app.db, userId).token;
40 app.db
41 .query("UPDATE session SET expires_at = ?1 WHERE token_hash = ?2")
42 .run(new Date(Date.now() - 1000).toISOString(), sha256(expired));
43
44 const signedOut = createSession(app.db, userId).token;
45 destroySession(app.db, signedOut);
46
47 return { random: "x".repeat(43), tampered, expired, signedOut };
48 }
49
50 beforeAll(async () => {
51 process.env["FORGE_CELLS"] = "off";
52 dataDir = await mkdtemp(join(tmpdir(), "forge-session-refusals-"));
53 app = createApp({ dataDir, port: 0 });
54 servers = await serve(app);
55 });
56 afterAll(async () => {
57 await servers.stop();
58 app.close();
59 if (cells === undefined) delete process.env["FORGE_CELLS"];
60 else process.env["FORGE_CELLS"] = cells;
61 await rm(dataDir, { recursive: true, force: true });
62 });
63
64 async function get(path: string, token: string | null) {
65 const res = await fetch(`${servers.url}${path}`, {
66 headers: token === null ? {} : { cookie: `forge_session=${token}` },
67 redirect: "manual",
68 });
69 return { status: res.status, body: await res.text() };
70 }
71
72 test("sessionUser refuses forged, tampered, expired and signed-out tokens", async () => {
73 const alice = await person("alice");
74 for (const [name, token] of Object.entries(refused(alice))) {
75 const got = sessionUser(app.db, token);
76 expect(got.ok, name).toBe(false);
77 if (!got.ok) expect(got.error, name).toBe("invalid_session");
78 }
79 });
80
81 test("a live session still works", async () => {
82 const alice = await person("dana");
83 const { token } = createSession(app.db, alice);
84 const got = sessionUser(app.db, token);
85 expect(got.ok).toBe(true);
86 const api = await get("/api/v1/user", token);
87 expect(api.status).toBe(200);
88 expect(JSON.parse(api.body).username).toBe("dana");
89 expect((await get("/", token)).body).not.toBe((await get("/", null)).body);
90 });
91
92 test("over HTTP each refused cookie is signed out: the anonymous page and a 401", async () => {
93 const id = await person("bob");
94 const anonymous = await get("/", null);
95 expect(anonymous.status).toBe(200);
96 for (const [name, token] of Object.entries(refused(id))) {
97 const page = await get("/", token);
98 expect(page.status, name).toBe(200);
99 expect(page.body, name).toBe(anonymous.body);
100 expect((await get("/api/v1/user", token)).status, name).toBe(401);
101 }
102 });
103
104 test("a suspended account's cookie is refused the same way", async () => {
105 const id = await person("carol");
106 const { token } = createSession(app.db, id);
107 expect(sessionUser(app.db, token).ok).toBe(true);
108 app.db
109 .query("UPDATE user SET suspended_at = ?1 WHERE id = ?2")
110 .run(new Date().toISOString(), id);
111 const got = sessionUser(app.db, token);
112 expect(got.ok).toBe(false);
113 if (!got.ok) expect(got.error).toBe("invalid_session");
114 const anonymous = await get("/", null);
115 expect((await get("/", token)).body).toBe(anonymous.body);
116 expect((await get("/api/v1/user", token)).status).toBe(401);
117 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs