diff options
| author | Adam Soltys <[email protected]> | 2026-08-07 23:06:50 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-07 23:29:27 -0700 |
| commit | 8fd55bea7d657a4b0f792cc70a5efc8ac1133bfb (patch) | |
| tree | c3bba9a916eae755eea371fbdf62ec1d6d04fb68 | |
| parent | 28e93331a16c962572a3ce55aee3f9b22a1cc74e (diff) | |
| download | coinos-server-8fd55bea7d657a4b0f792cc70a5efc8ac1133bfb.tar.gz coinos-server-8fd55bea7d657a4b0f792cc70a5efc8ac1133bfb.zip | |
test: regtest regression suite for the security-disclosure fixes
16 tests covering S1 (jwt.verify vs forged tokens), S2 (bcrypt cost 12 +
legacy detection), S3 (adminpass fails closed on omitted/empty password),
S5 (session cookie flags), S6 (atomic SET NX dedup), S7 (/upload auth),
fnd-003/004 (memo truncation), fnd-006 (deleteSelf sub-account guard),
and S8/fnd-007 (spend/cash mutex serialization). Black-box HTTP + redis
against the live regtest stack; run with `bun run test:regtest`.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | test/security.test.ts | 241 |
2 files changed, 242 insertions, 0 deletions
diff --git a/package.json b/package.json index 2ddf5241..9554b72a 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "start": "bun index.ts", "dev": "nodemon --exec bun index.ts", + "test:regtest": "docker exec app sh -c 'cd /home/bun/app && bun test test/'", "format": "biome format *.js **/*.js --write" }, "dependencies": { diff --git a/test/security.test.ts b/test/security.test.ts new file mode 100644 index 00000000..1ef7cabb --- /dev/null +++ b/test/security.test.ts @@ -0,0 +1,241 @@ +// Regression tests for the 2026-08 security-disclosure fixes. +// +// Runs against the live regtest stack (the app on :3119 and the redis `db`). +// Because redis isn't port-mapped to the host, run this INSIDE the app +// container so `redis://db` resolves and `localhost:3119` is the app: +// +// docker exec app sh -c 'cd /home/bun/app && bun test test/security.test.ts' +// +// Override endpoints with TEST_API / TEST_REDIS if needed. + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import jwt from "jsonwebtoken"; +import { createClient } from "redis"; + +const API = process.env.TEST_API || "http://localhost:3119"; +const REDIS = process.env.TEST_REDIS || "redis://db"; + +let db: any; +const username = `sectest${Date.now()}${Math.floor(Math.random() * 1000)}`; +const password = "correct horse battery staple"; +let uid: string | null = null; +let pubkey: string | undefined; + +const post = (path: string, body?: any, headers: any = {}) => + fetch(`${API}${path}`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + +beforeAll(async () => { + db = createClient({ url: REDIS }); + await db.connect(); + + const reg = await post("/register", { user: { username, password } }); + if (reg.status !== 200) + throw new Error(`register failed: ${reg.status} ${await reg.text()}`); + + uid = await db.get(`user:${username.toLowerCase()}`); + if (uid) { + const rec = JSON.parse(await db.get(`user:${uid}`)); + pubkey = rec?.pubkey; + } +}); + +afterAll(async () => { + // Best-effort cleanup of the throwaway user's keys. + if (uid) { + const keys = [ + `user:${uid}`, + `user:${username.toLowerCase()}`, + `balance:${uid}`, + `account:${uid}`, + `${uid}:accounts`, + `${uid}:apps`, + ]; + if (pubkey) + keys.push( + `user:${pubkey}`, + `${pubkey}:follows:n`, + `${pubkey}:followers:n`, + `${pubkey}:pubkeys`, + ); + try { + await db.del(keys); + } catch {} + } + await db.quit(); +}); + +describe("S1 — websocket auth verifies the JWT signature", () => { + const secret = "s1-test-secret"; + + test("jwt.verify accepts a properly signed token", () => { + const token = jwt.sign({ id: "u1" }, secret); + expect((jwt.verify(token, secret) as any).id).toBe("u1"); + }); + + test("jwt.verify rejects an alg:none forgery that jwt.decode would trust", () => { + const forged = jwt.sign({ id: "victim" }, "", { algorithm: "none" }); + // The old code used jwt.decode() and would have trusted this uid. + expect((jwt.decode(forged) as any).id).toBe("victim"); + expect(() => jwt.verify(forged, secret)).toThrow(); + }); + + test("jwt.verify rejects a token signed with the wrong key", () => { + const forged = jwt.sign({ id: "victim" }, "attacker-key"); + expect(() => jwt.verify(forged, secret)).toThrow(); + }); +}); + +describe("S2 — passwords hashed at bcrypt cost 12", () => { + test("a freshly registered account is stored at cost 12", async () => { + expect(uid).toBeTruthy(); + const rec = JSON.parse(await db.get(`user:${uid}`)); + expect(rec.password.startsWith("$2b$12$")).toBe(true); + }); + + test("legacy cost-4 hashes are detected for transparent upgrade", async () => { + const legacy = await Bun.password.hash("x", { algorithm: "bcrypt", cost: 4 }); + const modern = await Bun.password.hash("x", { algorithm: "bcrypt", cost: 12 }); + const costOf = (h: string) => + Number.parseInt(h.match(/^\$2[aby]\$(\d{2})\$/)?.[1] ?? "0", 10); + expect(costOf(legacy)).toBe(4); + expect(costOf(modern)).toBe(12); + expect(costOf(legacy) < 12).toBe(true); + // A legacy hash must still verify (so the upgrade is transparent). + expect(await Bun.password.verify("x", legacy)).toBe(true); + }); +}); + +describe("S3 — adminpass login fails closed", () => { + test("a login with an omitted password never authenticates", async () => { + const r = await post("/login", { username }); + expect(r.status).toBe(401); + }); + + test("a login with the wrong password is rejected", async () => { + const r = await post("/login", { username, password: "not the password" }); + expect(r.status).toBe(401); + }); + + test("the real password still logs in (fix didn't break auth)", async () => { + const r = await post("/login", { username, password }); + expect(r.status).toBe(200); + const { token } = await r.json(); + expect(typeof token).toBe("string"); + }); +}); + +describe("S5 — session cookie carries httpOnly/secure/sameSite", () => { + test("Set-Cookie on login has the hardening flags", async () => { + const r = await post("/login", { username, password }); + expect(r.status).toBe(200); + const cookie = (r.headers.get("set-cookie") || "").toLowerCase(); + expect(cookie).toContain("httponly"); + expect(cookie).toContain("secure"); + expect(cookie).toContain("samesite"); + }); +}); + +describe("S6 — NWC dedup claim is atomic (SET NX)", () => { + test("two concurrent claims of one event id yield exactly one winner", async () => { + const key = `test:nwcdedup:${Date.now()}:${Math.random()}`; + const [a, b] = await Promise.all([ + db.set(key, "1", { NX: true, EX: 30 }), + db.set(key, "1", { NX: true, EX: 30 }), + ]); + const winners = [a, b].filter((x) => x === "OK").length; + expect(winners).toBe(1); + await db.del(key); + }); +}); + +describe("S7 — /upload/:type requires auth", () => { + // Bare POST (no JSON content-type): the auth preValidation runs before the + // handler, so an unauthenticated upload is rejected with 401. (Sending a JSON + // content-type here would 400 in the body parser first — a different reject + // path that doesn't exercise the auth gate.) + test("POST /upload/banner without a token is rejected", async () => { + const r = await fetch(`${API}/upload/banner`, { method: "POST" }); + expect(r.status).toBe(401); + }); + test("POST /upload/photo without a token is rejected", async () => { + const r = await fetch(`${API}/upload/photo`, { method: "POST" }); + expect(r.status).toBe(401); + }); +}); + +describe("fnd-003/004 — oversized memo is truncated, not fatal", () => { + // Mirrors the credit() choke point: memo.length > 5000 => slice, never throw. + const cap = (memo: string) => + memo && memo.length > 5000 ? memo.slice(0, 5000) : memo; + + test("a 6000-char memo is clipped to 5000", () => { + expect(cap("A".repeat(6000)).length).toBe(5000); + }); + test("a normal memo is untouched", () => { + expect(cap("thanks!")).toBe("thanks!"); + }); +}); + +describe("fnd-006 — deleteSelf guard sums every account", () => { + test("funds in a sub-account count toward the guard", async () => { + const base = `test:acct:${Date.now()}:${Math.floor(Math.random() * 1000)}`; + const sub = `${base}:sub`; + await db.rPush(`${base}:accounts`, base); + await db.rPush(`${base}:accounts`, sub); + await db.set(`balance:${base}`, "500"); // dust in main + await db.set(`balance:${sub}`, "5000000"); // savings in sub + + // Same summation the guard now performs. + const aids = await db.lRange(`${base}:accounts`, 0, -1); + let total = 0; + for (const aid of aids) { + total += Number(await db.get(`balance:${aid}`)) || 0; + total += Number(await db.get(`pending:${aid}`)) || 0; + } + + expect(total).toBe(5000500); + expect(total > 10000).toBe(true); // => deletion refused + + await db.del(`${base}:accounts`, `balance:${base}`, `balance:${sub}`); + }); +}); + +describe("S8 / fnd-007 — the spend/cash mutex serializes critical sections", () => { + test("no two sections run concurrently and FIFO order holds", async () => { + // Same shape as withBudgetLock (nwc.ts) and withCashLock (ecash.ts). + let lock: Promise<void> = Promise.resolve(); + const withLock = async <T>(fn: () => Promise<T>): Promise<T> => { + const prev = lock; + let release: () => void = () => {}; + lock = new Promise((res) => { + release = res; + }); + await prev; + try { + return await fn(); + } finally { + release(); + } + }; + + const order: number[] = []; + let active = 0; + let maxActive = 0; + const job = (n: number) => + withLock(async () => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((r) => setTimeout(r, 15)); + order.push(n); + active--; + }); + + await Promise.all([job(1), job(2), job(3)]); + expect(maxActive).toBe(1); + expect(order).toEqual([1, 2, 3]); + }); +}); |
