From 085670cff3744f3801919fd97fd44a4d11c2694a Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:31:02 +0200 Subject: [PATCH] fix(federation): handshake sourceOrigin honors PUBLIC_ORIGIN (align with S2S auth origin) --- .../federation.peerInitiateOutbound.test.ts | 40 ++++++++++++++++++- packages/server/src/routes/federation.ts | 31 +++++++------- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/packages/server/src/routes/federation.peerInitiateOutbound.test.ts b/packages/server/src/routes/federation.peerInitiateOutbound.test.ts index 328caa23..e23930b6 100644 --- a/packages/server/src/routes/federation.peerInitiateOutbound.test.ts +++ b/packages/server/src/routes/federation.peerInitiateOutbound.test.ts @@ -41,11 +41,16 @@ vi.mock('../utils/auth.js', () => ({ requireAdmin: async () => {}, })); +// getOurOrigin returns an origin DISTINCT from `https://${config.domain}` +// (config.domain === 'local.example'). This simulates PUBLIC_ORIGIN being set +// to something other than https://DOMAIN — the exact configuration that exposed +// the handshake/S2S-auth origin desync (BUG: resolveLocalOrigin used DOMAIN). +const PUBLIC_ORIGIN = 'https://public.example'; vi.mock('../utils/federationAuth.js', async () => { const actual = await vi.importActual('../utils/federationAuth.js'); return { ...actual, - getOurOrigin: () => 'https://local.example', + getOurOrigin: () => 'https://public.example', generateHmacSecret: () => 'mock-generated-secret', }; }); @@ -134,6 +139,39 @@ describe('POST /api/federation/peer/initiate — 202 token capture & 200 clear', expect(peer?.approvalToken).toBe(remoteToken); }); + it('handshake sourceOrigin equals getOurOrigin() (honors PUBLIC_ORIGIN), not https://DOMAIN', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ accepted: true, instanceName: 'Remote' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peer/initiate', + payload: { remoteOrigin: 'https://remote.example' }, + }); + + expect(response.statusCode).toBe(200); + + // Locate the outbound handshake call to the remote /peer/accept endpoint. + const acceptCall = fetchSpy.mock.calls.find(([url]) => + String(url) === 'https://remote.example/api/federation/peer/accept', + ); + expect(acceptCall).toBeDefined(); + + const init = acceptCall![1] as RequestInit; + const body = JSON.parse(String(init.body)) as { sourceOrigin: string }; + + // The handshake sourceOrigin MUST match the origin used for S2S auth + // (getOurOrigin / PUBLIC_ORIGIN), so the responder keys the peer row by the + // same value it will later see in X-Federation-Origin. + expect(body.sourceOrigin).toBe(PUBLIC_ORIGIN); + // And it must NOT fall back to https://DOMAIN when PUBLIC_ORIGIN differs. + expect(body.sourceOrigin).not.toBe('https://local.example'); + }); + it('on 200 from remote, activates and clears approvalToken', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 908e2f47..ddf4bb20 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -62,20 +62,17 @@ function sanitizePeer(row: typeof schema.federationPeers.$inferSelect): Sanitize } /** - * Determine this instance's public origin. - * Prefer the explicit DOMAIN env var; fall back to the request Host header. + * Determine this instance's public origin for the peering handshake. + * + * Delegates to `getOurOrigin()` so the handshake `sourceOrigin` is IDENTICAL to + * the `X-Federation-Origin` value used for authenticated S2S requests. This + * honors `PUBLIC_ORIGIN` (getOurOrigin's precedence: PUBLIC_ORIGIN → + * `https://${DOMAIN}` → `http://localhost:${PORT}`). Using DOMAIN directly here + * previously desynced the responder's peer-row key from the auth origin, + * causing permanent `403 Not peered` whenever PUBLIC_ORIGIN != https://DOMAIN. */ -function resolveLocalOrigin(request: { headers: Record }): string { - if (config.domain) { - return `https://${config.domain}`; - } - const hostHeader = request.headers.host; - const host = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader; - if (!host) { - throw new Error('Cannot determine local origin: no DOMAIN configured and no Host header'); - } - const protocol = (request as Record).protocol === 'https' ? 'https' : 'http'; - return `${protocol}://${host}`; +function resolveLocalOrigin(): string { + return getOurOrigin(); } /** @@ -861,7 +858,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { let localOrigin: string; try { - localOrigin = resolveLocalOrigin(request); + localOrigin = resolveLocalOrigin(); } catch { return reply.code(500).send({ error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.', @@ -1883,7 +1880,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { let localOrigin: string; try { - localOrigin = resolveLocalOrigin(request); + localOrigin = resolveLocalOrigin(); } catch { return reply.code(500).send({ error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.', @@ -2138,7 +2135,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { const newSecret = generateHmacSecret(); let localOrigin: string; try { - localOrigin = resolveLocalOrigin(request); + localOrigin = resolveLocalOrigin(); } catch { return reply.code(500).send({ error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.', @@ -3073,7 +3070,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { let localOrigin: string; try { - localOrigin = resolveLocalOrigin(request); + localOrigin = resolveLocalOrigin(); } catch { localOrigin = config.domain ? `https://${config.domain}` : ''; }