sipher/tests/attacks.test.ts
Nixyan 66ebebd105 refactor: modularize plugins with federation and encryption infrastructure
Major changes:
- Restructure plugin architecture: moved federation logic into a dedicated `federation` plugin with Better Auth integration, defining schemas for server registry, key rotation, and blacklist management
- Extract encryption layer: new `oven` plugin handles end-to-end encryption (E2EE) with OLM client/server implementations
- Reorganize social features: consolidated social endpoints (posts, follows, blocks, mutes) and removed legacy plugin patterns in favor of unified plugin structure
- Decentralized key management: refactored `keytools` and `keygen` to support federation key rotation with challenge tokens and health checks

Infrastructure updates:
- Upgrade dependencies: bump Better Auth to 1.6.9, React to 19.2.5, Next.js to 16.2.3, Tailwind to 4.2.4
- Add cryptographic libraries: @scure/bip39, @signalapp/libsignal-client, @matrix-org/matrix-sdk-crypto-wasm for enhanced federation security
- Add utilities: base58-js, uuid for federation identifier handling
- Update database schema with new federation tables (serverRegistry, rotateChallengeTokens, blacklistedServers)

Minor updates: test suite alignment, storage client cleanup, PostFederationSchema refinements

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 11:40:14 -03:00

466 lines
15 KiB
TypeScript

/**
* 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<number>((resolve) => {
server.listen(0, "127.0.0.1", () => {
resolve((server.address() as { port: number }).port)
})
}),
stop: () => new Promise<void>((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()
}
})
})