fix(federation): refuse outbound handshake when inbound approval pending

Closes the auto-reconnect trust-bypass: any code path calling
ensurePeered(remote) on an instance with autoAcceptPeering=0 could
previously bypass the admin gate by initiating a fresh handshake to the
remote, which the remote then accepted against its existing
awaiting_approval row.

The trigger surfaced was stores/instanceStore.ts:1010 — the silent
.catch(() => {}) auto-reconnect that fires for any user with the
remote in their replicatedInstances (commonly: any admin). Anyone with
that profile reloading their session activated peering on both sides
without any admin approval action.

Surgical fix: ensurePeered now returns rejected when an unresolved
inbound peer_approval_requests row exists for the target origin. The
legitimate admin-approve flow (routes/federation.ts:1089) does not call
ensurePeered; it deletes the approval-request and does its own direct
fetch to /peer/accept, so this check does not block legitimate approvals.

The receiver-side trust assumption at routes/federation.ts:619-645
(awaiting_approval branch in /peer/accept) still has the same flaw
— an adversarial peer that knows the timing could re-handshake at the
right moment to flip the receiver to active. That deeper trust-model
rework is plan-grade work tracked at internal notes
2026-04-26-peer-handshake-trust-model.md.
This commit is contained in:
Jannis Braun
2026-04-26 00:12:42 +02:00
parent d5c9cd297e
commit 11c5a2bf06
3 changed files with 160 additions and 0 deletions
@@ -84,6 +84,28 @@ export async function ensurePeered(origin: string): Promise<EnsurePeeredResult>
}
}
// Pre-handshake gate: refuse if we have an unresolved inbound approval-request
// for this origin. The local admin must approve or deny it first. Without this
// check, any code path calling ensurePeered (e.g., the silent auto-reconnect
// in stores/instanceStore.ts) could bypass autoAcceptPeering=0 by initiating a
// fresh handshake to the remote, which the remote then accepts against its
// existing awaiting_approval row (routes/federation.ts /peer/accept branch).
// The legitimate approval flow (routes/federation.ts /approval-requests/:id/
// approve) does NOT call ensurePeered — it deletes the approval-request first
// and does its own fetch — so this guard does not block legitimate approvals.
const pendingInbound = db
.select({ id: schema.peerApprovalRequests.id })
.from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, normalized))
.get();
if (pendingInbound) {
return {
status: 'rejected',
error: 'Local admin must resolve pending peering approval before initiating',
};
}
// Deduplicate: if a handshake is already in flight, share the promise
const inflight = inFlightPeering.get(normalized);
if (inflight) {