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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user