/** * Attack-vector tests for the /discover federation routes. * * These tests verify that the security fixes are working correctly. * Each describe block targets a vulnerability that was found during audit * and has now been patched. * * @author Tocka * These tests were generated by AI (Claude Opus 4.6). * I did review it and made some changes but it's still mostly AI generated so take this with a grain of salt. * * The AI did expose some actual vulnerabilities so it's not all bad. * After fixing the vulnerabilitie, the tests were updated to match the new behavior. * ----- */ import { encryptPayload, fingerprintKey } from "@/lib/federation/keytools" import { expect, test } from "@playwright/test" import http from "node:http" import { clearTables, generateEnvKeyPair, getBlacklistedServer, getChallengesByServerUrl, seedChallenge, seedServer, } from "./helpers/db" const BASE = "http://localhost:3000" function getOwnEncryptionPublicKey(): Uint8Array { return new Uint8Array(Buffer.from(process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!, "base64")) } function buildBadEnvelope() { return encryptPayload( JSON.stringify({ signingOldSignature: "wrong", signingNewSignature: "wrong", encryptionOldPlaintext: "wrong", encryptionNewPlaintext: "wrong", }), getOwnEncryptionPublicKey(), ) } function createTrapServer(fakePublicKey: string, fakeEncryptionPublicKey: string) { const hits: { method: string; url: string }[] = [] const server = http.createServer((req, res) => { hits.push({ method: req.method!, url: req.url! }) res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ publicKey: fakePublicKey, encryptionPublicKey: fakeEncryptionPublicKey })) }) return { hits, start: () => new Promise((resolve) => { server.listen(0, "127.0.0.1", () => { resolve((server.address() as { port: number }).port) }) }), stop: () => new Promise((resolve) => { server.close(() => resolve()) }), } } test.beforeEach(async () => { await clearTables() }) test.afterEach(async () => { await clearTables() }) // --------------------------------------------------------------------------- // 1. SSRF Protection — internal URLs are now blocked // --------------------------------------------------------------------------- test.describe("SSRF protection", () => { test("REGISTER rejects loopback URLs", async ({ request }) => { const keys = generateEnvKeyPair() const trap = createTrapServer(keys.signingPublicKey, keys.encryptionPublicKey) const port = await trap.start() try { const res = await request.post(`${BASE}/discover`, { data: { method: "REGISTER", url: `http://127.0.0.1:${port}`, publicKey: keys.signingPublicKey, encryptionPublicKey: keys.encryptionPublicKey, }, }) expect(res.status()).toBe(400) const body = await res.json() expect(body.error).toMatch(/blocked/i) expect(trap.hits.length).toBe(0) } finally { await trap.stop() } }) test("REGISTER rejects RFC-1918 and link-local URLs", async ({ request }) => { const internalUrls = [ "http://10.0.0.1:8080", "http://192.168.1.1:8080", "http://169.254.169.254", ] for (const url of internalUrls) { const keys = generateEnvKeyPair() const res = await request.post(`${BASE}/discover`, { data: { method: "REGISTER", url, publicKey: keys.signingPublicKey, encryptionPublicKey: keys.encryptionPublicKey, }, }) expect(res.status()).toBe(400) const body = await res.json() expect(body.error).toMatch(/blocked/i) } }) test("DISCOVER rejects stored internal URLs", async ({ request }) => { const keys = generateEnvKeyPair() await seedServer("http://127.0.0.1:9999", keys.signingPublicKey, keys.encryptionPublicKey) const envelopePayload = JSON.stringify({ publicKeyFingerprint: fingerprintKey(keys.signingPublicKey), encryptionPublicKeyFingerprint: fingerprintKey(keys.encryptionPublicKey), url: "http://127.0.0.1:9999", }) const envelope = encryptPayload(envelopePayload, getOwnEncryptionPublicKey()) const res = await request.post(`${BASE}/discover`, { data: { method: "DISCOVER", publicKey: keys.signingPublicKey, encryptionPublicKey: keys.encryptionPublicKey, envelope, }, }) expect(res.status()).toBe(400) const body = await res.json() expect(body.error).toMatch(/blocked/i) }) }) // --------------------------------------------------------------------------- // 2. Blacklist enforcement — blocks all federation routes // --------------------------------------------------------------------------- test.describe("Blacklist enforcement (fixed)", () => { async function blacklistServer(serverUrl: string, request: any) { await seedChallenge({ serverUrl, attemptsLeft: 1, expiresAt: new Date(Date.now() + 1000 * 60 * 5), }) // First attempt: mismatch, decrements to 0 await request.post(`${BASE}/discover/rotate/confirm`, { data: { serverUrl, envelope: buildBadEnvelope(), }, }) // Second attempt: attemptsLeft=0, triggers blacklist await request.post(`${BASE}/discover/rotate/confirm`, { data: { serverUrl, envelope: buildBadEnvelope(), }, }) const bl = await getBlacklistedServer(serverUrl) expect(bl).toBeDefined() } test("blacklisted server is rejected by rotate/init", async ({ request }) => { const oldKeys = generateEnvKeyPair() const serverUrl = "https://blacklisted-server.example" await seedServer(serverUrl, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey) await blacklistServer(serverUrl, request as any) const newKeys = generateEnvKeyPair() const initRes = await request.post(`${BASE}/discover/rotate/init`, { data: { url: serverUrl, newSigningPublicKey: newKeys.signingPublicKey, newEncryptionPublicKey: newKeys.encryptionPublicKey, }, }) expect(initRes.status()).toBe(403) const body = await initRes.json() expect(body.error).toMatch(/blacklisted/i) }) test("blacklisted server is rejected by rotate/confirm", async ({ request }) => { const serverUrl = "https://blacklisted-confirm.example" const keys = generateEnvKeyPair() await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey) await blacklistServer(serverUrl, request as any) const confirmRes = await request.post(`${BASE}/discover/rotate/confirm`, { data: { serverUrl, envelope: buildBadEnvelope(), }, }) expect(confirmRes.status()).toBe(403) const body = await confirmRes.json() expect(body.error).toMatch(/blacklisted/i) }) }) // --------------------------------------------------------------------------- // 3. Race condition fixed — transaction + FOR UPDATE // --------------------------------------------------------------------------- test.describe("Race condition fixed on rotate/confirm", () => { test("concurrent requests are serialised by the row lock", async () => { const serverUrl = "https://race-target.example" const keys = generateEnvKeyPair() await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey) await seedChallenge({ serverUrl, attemptsLeft: 1, expiresAt: new Date(Date.now() + 1000 * 60 * 5), }) const payload = JSON.stringify({ serverUrl, envelope: buildBadEnvelope(), }) const fire = () => fetch(`${BASE}/discover/rotate/confirm`, { method: "POST", headers: { "Content-Type": "application/json" }, body: payload, }) const results = await Promise.all(Array.from({ length: 15 }, fire)) const statuses = results.map((r) => r.status) const mismatch = statuses.filter((s) => s === 400).length const blacklisted = statuses.filter((s) => s === 403).length const notFound = statuses.filter((s) => s === 404).length expect(mismatch).toBeLessThanOrEqual(1) expect(mismatch + blacklisted + notFound).toBe(statuses.length) }) }) // --------------------------------------------------------------------------- // 4. Challenge deduplication — no flooding, no attempt reset // --------------------------------------------------------------------------- test.describe("Challenge deduplication (fixed)", () => { test("second init is rejected while a challenge is pending", async ({ request }) => { const serverUrl = "https://dedup-target.example" const keys = generateEnvKeyPair() await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey) const newKeys1 = generateEnvKeyPair() const newKeys2 = generateEnvKeyPair() const res1 = await request.post(`${BASE}/discover/rotate/init`, { data: { url: serverUrl, newSigningPublicKey: newKeys1.signingPublicKey, newEncryptionPublicKey: newKeys1.encryptionPublicKey, }, }) expect(res1.status()).toBe(200) const res2 = await request.post(`${BASE}/discover/rotate/init`, { data: { url: serverUrl, newSigningPublicKey: newKeys2.signingPublicKey, newEncryptionPublicKey: newKeys2.encryptionPublicKey, }, }) expect(res2.status()).toBe(409) const body = await res2.json() expect(body.error).toMatch(/already pending/i) const challenges = await getChallengesByServerUrl(serverUrl) expect(challenges.length).toBe(1) }) test("init succeeds after the previous challenge expires", async ({ request }) => { const serverUrl = "https://dedup-expire.example" const keys = generateEnvKeyPair() await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey) await seedChallenge({ serverUrl, expiresAt: new Date(Date.now() - 1000), }) const newKeys = generateEnvKeyPair() const res = await request.post(`${BASE}/discover/rotate/init`, { data: { url: serverUrl, newSigningPublicKey: newKeys.signingPublicKey, newEncryptionPublicKey: newKeys.encryptionPublicKey, }, }) expect(res.status()).toBe(200) const challenges = await getChallengesByServerUrl(serverUrl) expect(challenges.length).toBe(1) expect(challenges[0].newSigningPublicKey).toBe(newKeys.signingPublicKey) }) test("blacklisted server cannot reset attempts via new init", async ({ request }) => { const serverUrl = "https://reset-blocked.example" const keys = generateEnvKeyPair() await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey) await seedChallenge({ serverUrl, attemptsLeft: 1, expiresAt: new Date(Date.now() + 1000 * 60 * 5), }) await request.post(`${BASE}/discover/rotate/confirm`, { data: { serverUrl, envelope: buildBadEnvelope(), }, }) await request.post(`${BASE}/discover/rotate/confirm`, { data: { serverUrl, envelope: buildBadEnvelope(), }, }) const bl = await getBlacklistedServer(serverUrl) expect(bl).toBeDefined() const freshKeys = generateEnvKeyPair() const initRes = await request.post(`${BASE}/discover/rotate/init`, { data: { url: serverUrl, newSigningPublicKey: freshKeys.signingPublicKey, newEncryptionPublicKey: freshKeys.encryptionPublicKey, }, }) expect(initRes.status()).toBe(403) }) }) // --------------------------------------------------------------------------- // 5. Envelope validation — field values must match the request // --------------------------------------------------------------------------- test.describe("Envelope validation (fixed)", () => { test("envelope with mismatched publicKey fingerprint is rejected", async ({ request }) => { const keys = generateEnvKeyPair() await seedServer("https://sig-test.example", keys.signingPublicKey, keys.encryptionPublicKey) const badEnvelope = encryptPayload( JSON.stringify({ publicKeyFingerprint: "wrong-fingerprint", encryptionPublicKeyFingerprint: fingerprintKey(keys.encryptionPublicKey), url: "https://sig-test.example", }), getOwnEncryptionPublicKey(), ) const res = await request.post(`${BASE}/discover`, { data: { method: "DISCOVER", publicKey: keys.signingPublicKey, encryptionPublicKey: keys.encryptionPublicKey, envelope: badEnvelope, }, }) expect(res.status()).toBe(400) }) test("envelope with placeholder values is rejected", async ({ request }) => { const keys = generateEnvKeyPair() await seedServer("https://sig-test2.example", keys.signingPublicKey, keys.encryptionPublicKey) const forgeryEnvelope = encryptPayload( JSON.stringify({ publicKey: "x", url: "y" }), getOwnEncryptionPublicKey(), ) const res = await request.post(`${BASE}/discover`, { data: { method: "DISCOVER", publicKey: keys.signingPublicKey, encryptionPublicKey: keys.encryptionPublicKey, envelope: forgeryEnvelope, }, }) expect(res.status()).toBe(400) }) test("envelope with correct fingerprints passes validation", async ({ request }) => { const keys = generateEnvKeyPair() const trap = createTrapServer(keys.signingPublicKey, keys.encryptionPublicKey) const port = await trap.start() const peerUrl = `http://127.0.0.1:${port}` try { await seedServer(peerUrl, keys.signingPublicKey, keys.encryptionPublicKey) const validEnvelope = encryptPayload( JSON.stringify({ publicKeyFingerprint: fingerprintKey(keys.signingPublicKey), encryptionPublicKeyFingerprint: fingerprintKey(keys.encryptionPublicKey), url: peerUrl, }), getOwnEncryptionPublicKey(), ) const res = await request.post(`${BASE}/discover`, { data: { method: "DISCOVER", publicKey: keys.signingPublicKey, encryptionPublicKey: keys.encryptionPublicKey, envelope: validEnvelope, }, }) // Envelope is valid, but the stored URL is internal → blocked by SSRF guard expect(res.status()).toBe(400) const body = await res.json() expect(body.error).toMatch(/blocked/i) } finally { await trap.stop() } }) }) // --------------------------------------------------------------------------- // 6. Information disclosure: only url + isHealthy in peer list // --------------------------------------------------------------------------- test.describe("Information disclosure", () => { test("GET /discover only returns url and isHealthy for peers", async ({ request }) => { const keys1 = generateEnvKeyPair() const keys2 = generateEnvKeyPair() await seedServer("https://peer-one.example", keys1.signingPublicKey, keys1.encryptionPublicKey) await seedServer("https://peer-two.example", keys2.signingPublicKey, keys2.encryptionPublicKey) const res = await request.get(`${BASE}/discover`) expect(res.status()).toBe(200) const body = await res.json() expect(body.peers).toBeInstanceOf(Array) expect(body.peers.length).toBeGreaterThanOrEqual(2) for (const peer of body.peers) { expect(peer.url).toBeDefined() expect(peer.isHealthy).toBeDefined() expect(peer.id).toBeUndefined() expect(peer.createdAt).toBeUndefined() expect(peer.updatedAt).toBeUndefined() expect(peer.lastSeen).toBeUndefined() } }) })