fix(federation): harden processFriendRequestCreateEvent receiver-side

Two correctness/defense fixes plus regression tests in the existing
in-memory drizzle test file.

1. Reverse-direction idempotency. The sender-side path in social.ts
   checks BOTH directions of friend_requests and returns 409
   incoming_request_exists when an opposite-direction row exists. The
   receiver only matched from->to, so cross-fire (alice@A and bob@B both
   click "add friend" near-simultaneously) produced two opposite
   pending rows on each instance. The receiver now silent-accepts when
   either direction matches a pending row, mirroring the sender's
   both-direction check.

2. Self-target guard (defense-in-depth). Reject events whose
   from-identity equals to-identity (after normalizeOriginForCompare)
   with a new receiver-acknowledged 4xx code self_target_invalid.
   Sender's local cannot_friend_self should catch this, but the
   receiver does not trust upstream validation. Added to
   TERMINAL_REJECTION_REASONS so the standard rollback fires
   (mapped client-side to peer_rejected). Logged at console.warn.

Spec updates: social.md inbound contract now documents both-direction
idempotency and the self-target guard; federation.md and the
s2s-friend-add design spec list the new terminal rejection reason.
This commit is contained in:
Jannis Braun
2026-04-27 00:07:52 +02:00
parent 8cad964809
commit b698ded47d
5 changed files with 198 additions and 14 deletions
+24 -4
View File
@@ -6,7 +6,7 @@ import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
import { eq, and, or, isNull, inArray, sql, desc } from 'drizzle-orm';
import { authenticate, requireAdmin } from '../utils/auth.js';
import { generateHmacSecret, getOurOrigin, parseFederationHeaders, verifySignature, verifyPeerSignature, buildFederationHeaders } from '../utils/federationAuth.js';
import { generateHmacSecret, getOurOrigin, parseFederationHeaders, verifySignature, verifyPeerSignature, buildFederationHeaders, normalizeOriginForCompare } from '../utils/federationAuth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { getDb, getRawDb, schema } from '../db/index.js';
import { config } from '../config.js';
@@ -4529,6 +4529,17 @@ function processFriendRequestCreateEvent(
return;
}
// Self-target guard (defense-in-depth): from-identity must not equal to-identity.
// Sender's local cannot_friend_self check should catch this, but the receiver must not trust it.
if (
from.homeUserId === to.homeUserId &&
normalizeOriginForCompare(from.homeInstance) === normalizeOriginForCompare(to.homeInstance)
) {
console.warn(`[federation] Self-target friend_request_create rejected: homeUserId=${from.homeUserId} homeInstance=${extractDomain(from.homeInstance)} source=${extractDomain(sourceInstance)}`);
rejected.push({ messageId: event.messageId, reason: 'self_target_invalid' });
return;
}
// Resolve the sender (create stub if needed — they're on a remote instance)
const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username });
if (!fromUserResolved) {
@@ -4562,14 +4573,23 @@ function processFriendRequestCreateEvent(
return;
}
// Idempotency: if a pending request already exists from this sender to this recipient, accept as no-op
// Idempotency: a pending request in EITHER direction makes this event a no-op.
// Forward (from→to): re-delivery of an event we've already processed.
// Reverse (to→from): the local user has already sent a request TO this remote sender.
// Race window: both sides click "add friend" near-simultaneously. Each sender's both-direction
// check passes locally (no rows yet anywhere). When the events cross, each receiver must
// treat the reverse-direction collision as idempotent — otherwise both instances end up
// with two opposite-direction pending rows for the same logical pair. Mirror the
// sender-side both-direction check (`incoming_request_exists` in social.ts).
const existingRequest = db
.select()
.from(schema.friendRequests)
.where(
and(
eq(schema.friendRequests.fromId, fromUser.id),
eq(schema.friendRequests.toId, toUser.id),
or(
and(eq(schema.friendRequests.fromId, fromUser.id), eq(schema.friendRequests.toId, toUser.id)),
and(eq(schema.friendRequests.fromId, toUser.id), eq(schema.friendRequests.toId, fromUser.id)),
),
eq(schema.friendRequests.status, 'pending'),
),
)