fix(federation): /peer/accept returns honest 409 instead of false 200 for existing peer (BUG-1a)
This commit is contained in:
@@ -185,7 +185,7 @@ describe('POST /api/federation/peer/accept — instance_name persistence', () =>
|
|||||||
expect(row?.status).toBe('active');
|
expect(row?.status).toBe('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does NOT overwrite instance_name on the active idempotent early-return path', async () => {
|
it('does NOT overwrite instance_name on the active idempotent early-return path (now 409)', async () => {
|
||||||
testDb.insert(schema.federationPeers).values({
|
testDb.insert(schema.federationPeers).values({
|
||||||
id: 'peer-active',
|
id: 'peer-active',
|
||||||
origin: 'https://remote.example',
|
origin: 'https://remote.example',
|
||||||
@@ -205,7 +205,9 @@ describe('POST /api/federation/peer/accept — instance_name persistence', () =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
// BUG-1a: existing active peer now returns an honest 409 refusal, not a
|
||||||
|
// false 200. Anti-hijack unchanged — name/secret must be untouched.
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
const row = testDb.select().from(schema.federationPeers)
|
const row = testDb.select().from(schema.federationPeers)
|
||||||
.where(eq(schema.federationPeers.id, 'peer-active')).get();
|
.where(eq(schema.federationPeers.id, 'peer-active')).get();
|
||||||
expect(row?.instanceName).toBe('Original Name');
|
expect(row?.instanceName).toBe('Original Name');
|
||||||
@@ -257,7 +259,7 @@ describe('POST /api/federation/peer/accept — response body carries instanceNam
|
|||||||
expect(body.instanceName).toBe('Local Backspace');
|
expect(body.instanceName).toBe('Local Backspace');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns our instanceName on the active idempotent path too', async () => {
|
it('returns our instanceName on the active refusal path too (409)', async () => {
|
||||||
testDb.insert(schema.federationPeers).values({
|
testDb.insert(schema.federationPeers).values({
|
||||||
id: 'peer-active',
|
id: 'peer-active',
|
||||||
origin: 'https://remote.example',
|
origin: 'https://remote.example',
|
||||||
@@ -277,7 +279,9 @@ describe('POST /api/federation/peer/accept — response body carries instanceNam
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
// BUG-1a: honest 409 refusal still carries our own instanceName so the
|
||||||
|
// initiator can surface a useful "reset required" message.
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
const body = response.json() as { accepted: boolean; instanceName?: string | null };
|
const body = response.json() as { accepted: boolean; instanceName?: string | null };
|
||||||
expect(body.instanceName).toBe('Local Backspace');
|
expect(body.instanceName).toBe('Local Backspace');
|
||||||
});
|
});
|
||||||
@@ -300,6 +304,160 @@ describe('POST /api/federation/peer/accept — response body carries instanceNam
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /api/federation/peer/accept — BUG-1a: honest 409 refusal for existing active/needs_attention peer', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
sqlite = new Database(':memory:');
|
||||||
|
testDb = drizzle(sqlite, { schema });
|
||||||
|
applyMigrations(sqlite);
|
||||||
|
seedInstanceSettings('Local Backspace');
|
||||||
|
app = await buildApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 409 PEER_EXISTS_RESET_REQUIRED and keeps S0 when an active peer receives a different secret (matching epoch)', async () => {
|
||||||
|
testDb.insert(schema.federationPeers).values({
|
||||||
|
id: 'peer-active',
|
||||||
|
origin: 'https://caller.example',
|
||||||
|
hmacSecret: 'S0',
|
||||||
|
status: 'active',
|
||||||
|
peerInstanceId: 'epoch-A',
|
||||||
|
instanceName: 'Caller',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/federation/peer/accept',
|
||||||
|
remoteAddress: '10.0.1.1', // isolate rate-limit bucket (endpoint is IP-keyed)
|
||||||
|
payload: {
|
||||||
|
sourceOrigin: 'https://caller.example',
|
||||||
|
hmacSecret: 'S1-different',
|
||||||
|
instanceId: 'epoch-A', // same epoch → no reset, pure idempotent refusal
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
const body = response.json() as {
|
||||||
|
accepted: boolean;
|
||||||
|
code: string;
|
||||||
|
error: string;
|
||||||
|
instanceName?: string | null;
|
||||||
|
instanceId?: string | null;
|
||||||
|
statusCode: number;
|
||||||
|
};
|
||||||
|
expect(body.accepted).toBe(false);
|
||||||
|
expect(body.code).toBe('PEER_EXISTS_RESET_REQUIRED');
|
||||||
|
expect(body.statusCode).toBe(409);
|
||||||
|
expect(body.instanceName).toBe('Local Backspace');
|
||||||
|
|
||||||
|
// Anti-hijack: stored secret unchanged, status unchanged (same epoch).
|
||||||
|
const row = testDb.select().from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, 'peer-active')).get();
|
||||||
|
expect(row?.hmacSecret).toBe('S0');
|
||||||
|
expect(row?.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 409 and keeps S0 when instanceId is omitted (legacy caller, no epoch)', async () => {
|
||||||
|
testDb.insert(schema.federationPeers).values({
|
||||||
|
id: 'peer-active',
|
||||||
|
origin: 'https://caller.example',
|
||||||
|
hmacSecret: 'S0',
|
||||||
|
status: 'active',
|
||||||
|
peerInstanceId: 'epoch-A',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/federation/peer/accept',
|
||||||
|
remoteAddress: '10.0.1.2', // isolate rate-limit bucket (endpoint is IP-keyed)
|
||||||
|
payload: {
|
||||||
|
sourceOrigin: 'https://caller.example',
|
||||||
|
hmacSecret: 'S1-different',
|
||||||
|
// no instanceId → epoch-mismatch guard cannot fire
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
const body = response.json() as { code: string };
|
||||||
|
expect(body.code).toBe('PEER_EXISTS_RESET_REQUIRED');
|
||||||
|
|
||||||
|
const row = testDb.select().from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, 'peer-active')).get();
|
||||||
|
expect(row?.hmacSecret).toBe('S0');
|
||||||
|
expect(row?.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('epoch MISMATCH: fires markPeerReset (row → needs_attention) AND still returns 409 with S0 unchanged', async () => {
|
||||||
|
testDb.insert(schema.federationPeers).values({
|
||||||
|
id: 'peer-active',
|
||||||
|
origin: 'https://caller.example',
|
||||||
|
hmacSecret: 'S0',
|
||||||
|
status: 'active',
|
||||||
|
peerInstanceId: 'epoch-A',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/federation/peer/accept',
|
||||||
|
remoteAddress: '10.0.1.3', // isolate rate-limit bucket (endpoint is IP-keyed)
|
||||||
|
payload: {
|
||||||
|
sourceOrigin: 'https://caller.example',
|
||||||
|
hmacSecret: 'S1-different',
|
||||||
|
instanceId: 'epoch-B-new-incarnation', // differs from stored epoch-A
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
const body = response.json() as { code: string };
|
||||||
|
expect(body.code).toBe('PEER_EXISTS_RESET_REQUIRED');
|
||||||
|
|
||||||
|
// Detection still fires: row routed to needs_attention, observed epoch recorded,
|
||||||
|
// but trusted baseline (peerInstanceId) and secret are NOT rekeyed.
|
||||||
|
const row = testDb.select().from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, 'peer-active')).get();
|
||||||
|
expect(row?.status).toBe('needs_attention');
|
||||||
|
expect(row?.needsAttentionReason).toBe('peer_reset_detected');
|
||||||
|
expect(row?.observedPeerInstanceId).toBe('epoch-B-new-incarnation');
|
||||||
|
expect(row?.peerInstanceId).toBe('epoch-A');
|
||||||
|
expect(row?.hmacSecret).toBe('S0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('existing needs_attention peer: same 409 refusal, secret unchanged', async () => {
|
||||||
|
testDb.insert(schema.federationPeers).values({
|
||||||
|
id: 'peer-na',
|
||||||
|
origin: 'https://caller.example',
|
||||||
|
hmacSecret: 'S0',
|
||||||
|
status: 'needs_attention',
|
||||||
|
needsAttentionReason: 'peer_reset_detected',
|
||||||
|
peerInstanceId: 'epoch-A',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/federation/peer/accept',
|
||||||
|
remoteAddress: '10.0.1.4', // isolate rate-limit bucket (endpoint is IP-keyed)
|
||||||
|
payload: {
|
||||||
|
sourceOrigin: 'https://caller.example',
|
||||||
|
hmacSecret: 'S1-different',
|
||||||
|
instanceId: 'epoch-A',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
const body = response.json() as { code: string };
|
||||||
|
expect(body.code).toBe('PEER_EXISTS_RESET_REQUIRED');
|
||||||
|
|
||||||
|
const row = testDb.select().from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.id, 'peer-na')).get();
|
||||||
|
expect(row?.hmacSecret).toBe('S0');
|
||||||
|
expect(row?.status).toBe('needs_attention');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('POST /api/federation/peer/initiate — persists remote instanceName from handshake response', () => {
|
describe('POST /api/federation/peer/initiate — persists remote instanceName from handshake response', () => {
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
|||||||
@@ -1130,7 +1130,19 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
if (reqInstanceId && existing.peerInstanceId && reqInstanceId !== existing.peerInstanceId) {
|
if (reqInstanceId && existing.peerInstanceId && reqInstanceId !== existing.peerInstanceId) {
|
||||||
markPeerReset(existing.id, sourceOrigin, existing.peerInstanceId, reqInstanceId);
|
markPeerReset(existing.id, sourceOrigin, existing.peerInstanceId, reqInstanceId);
|
||||||
}
|
}
|
||||||
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName, instanceId: ourInstanceId });
|
// Anti-hijack: we did NOT adopt the caller's secret. Report that
|
||||||
|
// honestly (409) instead of a false success (was 200 {accepted:true}),
|
||||||
|
// so the initiator does not false-activate into a permanent HMAC
|
||||||
|
// desync. Legacy initiators read only response.ok → they fail loudly
|
||||||
|
// (never a silent desync); new initiators special-case this code.
|
||||||
|
return reply.code(409).send({
|
||||||
|
accepted: false,
|
||||||
|
code: 'PEER_EXISTS_RESET_REQUIRED',
|
||||||
|
error: 'This instance already holds peering for you; its admin must reset that peering before a new handshake can be accepted.',
|
||||||
|
instanceName: ourInstanceName,
|
||||||
|
instanceId: ourInstanceId,
|
||||||
|
statusCode: 409,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (existing.status === 'revoked') {
|
if (existing.status === 'revoked') {
|
||||||
return reply.code(403).send({
|
return reply.code(403).send({
|
||||||
|
|||||||
Reference in New Issue
Block a user