- Added new environment variables for MinIO configuration in .env.local.example. - Updated package.json and bun.lock to include new dependencies for key management and encryption. - Refactored server and route handling to support Ed25519 and X25519 key pairs for improved security during key rotation. - Implemented validation for public keys and enhanced error handling in the discovery routes. - Introduced new challenges for key rotation, ensuring secure communication between federations. - Updated README with additional instructions for the new key rotation process.
466 lines
14 KiB
TypeScript
466 lines
14 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,
|
|
generateKeypair,
|
|
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 = generateKeypair()
|
|
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 = generateKeypair()
|
|
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 = generateKeypair()
|
|
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 = generateKeypair()
|
|
const serverUrl = "https://blacklisted-server.example"
|
|
await seedServer(serverUrl, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
|
|
await blacklistServer(serverUrl, request as any)
|
|
|
|
const newKeys = generateKeypair()
|
|
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 = generateKeypair()
|
|
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 = generateKeypair()
|
|
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 = generateKeypair()
|
|
await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey)
|
|
|
|
const newKeys1 = generateKeypair()
|
|
const newKeys2 = generateKeypair()
|
|
|
|
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 = generateKeypair()
|
|
await seedServer(serverUrl, keys.signingPublicKey, keys.encryptionPublicKey)
|
|
|
|
await seedChallenge({
|
|
serverUrl,
|
|
expiresAt: new Date(Date.now() - 1000),
|
|
})
|
|
|
|
const newKeys = generateKeypair()
|
|
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 = generateKeypair()
|
|
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 = generateKeypair()
|
|
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 = generateKeypair()
|
|
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 = generateKeypair()
|
|
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 = generateKeypair()
|
|
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 = generateKeypair()
|
|
const keys2 = generateKeypair()
|
|
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()
|
|
}
|
|
})
|
|
})
|