feat(federation): receiver guard — never create replicated stubs homed at our own domain (dead-incarnation spec §3.1)
This commit is contained in:
@@ -24,6 +24,11 @@ vi.mock('../utils/snowflake.js', () => ({
|
|||||||
setWorkerId: vi.fn(),
|
setWorkerId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/federationAuth.js', async (importActual) => {
|
||||||
|
const actual = await importActual<typeof import('../utils/federationAuth.js')>();
|
||||||
|
return { ...actual, getOurOrigin: () => 'https://home.test' };
|
||||||
|
});
|
||||||
|
|
||||||
// federation.ts also imports connectionManager/ws — stub minimal surface so
|
// 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.
|
// the route module loads at test time. The function under test doesn't touch any of these.
|
||||||
vi.mock('../ws/handler.js', () => ({
|
vi.mock('../ws/handler.js', () => ({
|
||||||
@@ -84,3 +89,46 @@ describe('resolveOrCreateReplicatedUser — stub username', () => {
|
|||||||
expect(created!.username).toBe('310002371434024960@orbit.ddns.net');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.
|
* 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
|
// 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.
|
// already hides the deleted row, so we must query without that filter here.
|
||||||
const domain = extractDomain(homeInstance);
|
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
|
const deletedMatch = db
|
||||||
.select({ id: schema.users.id, isDeleted: schema.users.isDeleted })
|
.select({ id: schema.users.id, isDeleted: schema.users.isDeleted })
|
||||||
.from(schema.users)
|
.from(schema.users)
|
||||||
|
|||||||
Reference in New Issue
Block a user