From 99c8361b75d72fb6d6329e5a5350a789524bc44b Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:37:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(federation):=20receiver=20guard=20?= =?UTF-8?q?=E2=80=94=20never=20create=20replicated=20stubs=20homed=20at=20?= =?UTF-8?q?our=20own=20domain=20(dead-incarnation=20spec=20=C2=A73.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routes/federation.resolveOrCreate.test.ts | 48 +++++++++++++++++++ packages/server/src/routes/federation.ts | 26 ++++++++++ 2 files changed, 74 insertions(+) diff --git a/packages/server/src/routes/federation.resolveOrCreate.test.ts b/packages/server/src/routes/federation.resolveOrCreate.test.ts index e6c550c0..fe83c714 100644 --- a/packages/server/src/routes/federation.resolveOrCreate.test.ts +++ b/packages/server/src/routes/federation.resolveOrCreate.test.ts @@ -24,6 +24,11 @@ vi.mock('../utils/snowflake.js', () => ({ setWorkerId: vi.fn(), })); +vi.mock('../utils/federationAuth.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, getOurOrigin: () => 'https://home.test' }; +}); + // federation.ts also imports connectionManager/ws — stub minimal surface so // the route module loads at test time. The function under test doesn't touch any of these. vi.mock('../ws/handler.js', () => ({ @@ -84,3 +89,46 @@ describe('resolveOrCreateReplicatedUser — stub username', () => { expect(created!.username).toBe('310002371434024960@orbit.ddns.net'); }); }); + +describe('resolveOrCreateReplicatedUser — self-homed identity guard', () => { + it('refuses to create a stub homed at our own domain (dead incarnation)', async () => { + const { resolveOrCreateReplicatedUser } = await import('./federation.js'); + const result = resolveOrCreateReplicatedUser( + 'dead-incarnation-id', + 'home.test', + testDb, + { username: 'youruser' }, + ); + expect(result).toBeNull(); + const rows = testDb.select().from(schema.users).all(); + expect(rows).toHaveLength(0); + }); + + it('refuses self-homed creation regardless of homeInstance URL shape', async () => { + const { resolveOrCreateReplicatedUser } = await import('./federation.js'); + expect(resolveOrCreateReplicatedUser('dead-1', 'https://home.test', testDb, { username: 'x' })).toBeNull(); + expect(resolveOrCreateReplicatedUser('dead-2', 'HOME.TEST', testDb, { username: 'x' })).toBeNull(); + expect(testDb.select().from(schema.users).all()).toHaveLength(0); + }); + + it('still resolves a LIVE native user referenced by self-domain identity (tier 1)', async () => { + testDb.insert(schema.users).values({ + id: 'native-1', + username: 'alice', + passwordHash: 'real-hash', + homeInstance: null, + createdAt: 1, + }).run(); + const { resolveOrCreateReplicatedUser } = await import('./federation.js'); + const result = resolveOrCreateReplicatedUser('native-1', 'https://home.test', testDb, { username: 'alice' }); + expect(result).not.toBeNull(); + expect(result!.id).toBe('native-1'); + }); + + it('still creates stubs for remote-domain identities (unchanged behavior)', async () => { + const { resolveOrCreateReplicatedUser } = await import('./federation.js'); + const result = resolveOrCreateReplicatedUser('remote-1', 'orbit.ddns.net', testDb, { username: 'bob' }); + expect(result).not.toBeNull(); + expect(result!.username).toBe('bob@orbit.ddns.net'); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index b297c1a2..4071b4d0 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -3432,6 +3432,20 @@ export function extractDomain(homeInstance: string): string { } } +/** + * The bare lowercase domain that constitutes this instance's federated + * identity authority. Derives from DOMAIN (identity), falling back to + * getOurOrigin() only when DOMAIN is unset (dev/tests). PUBLIC_ORIGIN is a + * transport override and deliberately NOT consulted first — identity + * comparisons must not shift when the transport origin is overridden. + */ +export function getOurIdentityDomain(): string | null { + if (config.domain) return config.domain.toLowerCase(); + const origin = getOurOrigin(); + if (!origin) return null; + return extractDomain(origin).toLowerCase(); +} + /** * Verify that an acting user's homeInstance is legitimate for this relay. * @@ -3599,6 +3613,18 @@ export function resolveOrCreateReplicatedUser( // user by creating a new stub. The isDeleted=0 filter in findFederatedUser // already hides the deleted row, so we must query without that filter here. const domain = extractDomain(homeInstance); + + // An instance never hosts a replicated stub homed at itself. A self-domain + // identity that is live resolves at tier 1 above (native id match); one + // that reaches the create path is a dead incarnation from before an + // instance reset (e.g. replayed by a peer's initial sync). Creating a row + // here is what produced the self-homed double-domain junk stubs. + const ourDomain = getOurIdentityDomain(); + if (ourDomain && domain.toLowerCase() === ourDomain) { + console.log(`[federation] Refusing self-homed stub for homeUserId=${homeUserId} (${domain}) — dead incarnation of this instance`); + return null; + } + const deletedMatch = db .select({ id: schema.users.id, isDeleted: schema.users.isDeleted }) .from(schema.users)