From 27b6a3f91204b8c2f64595d1d4cd07be8d3b18ae Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 4 May 2026 00:23:02 +0200 Subject: [PATCH] test(federation-identity): #13 attribution guard rejects mismatched origin --- .../test/federation-identity-deletion.test.ts | 43 +++++++++++++++++++ packages/server/test/helpers/hmacSign.ts | 24 +++++++++-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/server/test/federation-identity-deletion.test.ts b/packages/server/test/federation-identity-deletion.test.ts index 449087af..16b55a9f 100644 --- a/packages/server/test/federation-identity-deletion.test.ts +++ b/packages/server/test/federation-identity-deletion.test.ts @@ -519,6 +519,49 @@ describe('Federation identity deletion — server suite', () => { const body = await res.json(); expect(body.results[ghostOrigin].error).toBe('no_active_peer'); }); + + it('#13 attribution guard: peer cannot delete users whose homeInstance differs', async () => { + const { createFederatedUser } = await import('./helpers/testUsers.js'); + const { buildHeadersForOrigin } = await import('./helpers/hmacSign.js'); + const { openInspector } = await import('./helpers/dbInspect.js'); + const { remoteUser } = await createFederatedUser(harness.home, harness.remote, 't13'); + + // Seed an "evil" peer row on the remote with a known secret so HMAC verification + // passes — we want to test the attribution guard, not HMAC failure. + // The evil peer claims to be `http://evil.test` but the user's homeInstance is + // `home.test.local`, so extractDomain('http://evil.test') !== extractDomain(homeInstance) + // and the attribution guard fires with 403. + const evilSecret = 'a'.repeat(64); + const seedRes = await fetch(`${harness.remote.origin}/api/admin/test/seed-peer`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + origin: 'http://evil.test', + hmacSecret: evilSecret, + status: 'active', + }), + }); + expect(seedRes.ok).toBe(true); + + const body = JSON.stringify({ + homeUserId: remoteUser.homeUserId, + homeInstance: harness.home.domain, + mode: 'full', + }); + const headers = buildHeadersForOrigin(body, evilSecret, 'http://evil.test'); + + const res = await fetch(`${harness.remote.origin}/api/federation/identity`, { + method: 'DELETE', + headers, + body, + }); + expect(res.status).toBe(403); + + const inspect = openInspector(harness.remote); + expect(inspect.user(remoteUser.id)!.isDeleted).toBe(0); + inspect.close(); + }); + }); let multiHarness: MultiRemoteHarness; diff --git a/packages/server/test/helpers/hmacSign.ts b/packages/server/test/helpers/hmacSign.ts index 7a855b0a..32292952 100644 --- a/packages/server/test/helpers/hmacSign.ts +++ b/packages/server/test/helpers/hmacSign.ts @@ -1,9 +1,25 @@ -import { buildFederationHeaders } from '../../src/utils/federationAuth.js'; +import { createHmac, randomUUID } from 'node:crypto'; /** - * Wrapper exposed for the malicious-peer test (#13). Builds the same headers - * production code uses, but lets the test pass an arbitrary X-Federation-Origin. + * Build federation headers for a raw S2S request. Replicates production + * `buildFederationHeaders` from src/utils/federationAuth.ts inline — avoids + * importing that module directly (it pulls in config.ts which requires + * JWT_SECRET at module-load time, breaking the test process). + * + * Used by tests that bypass the home endpoint and hit DELETE /api/federation/identity + * on the remote directly (#13 attribution guard, #14 idempotency). */ export function buildHeadersForOrigin(body: string, secret: string, claimedOrigin: string): Record { - return buildFederationHeaders(body, secret, claimedOrigin); + const timestamp = Date.now(); + const nonce = randomUUID(); + const payload = `${timestamp}.${nonce}.${body}`; + const sig = createHmac('sha256', secret).update(payload).digest('hex'); + + return { + 'X-Federation-Signature': `sha256=${sig}`, + 'X-Federation-Origin': claimedOrigin, + 'X-Federation-Timestamp': String(timestamp), + 'X-Federation-Nonce': nonce, + 'Content-Type': 'application/json', + }; }