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
+2 -1
View File
@@ -613,10 +613,11 @@ Trigger (API/WS handler)
#### Terminal rejection reasons
As of 2026-04-25, `processOutboxTick` recognizes a configurable set of receiver-acknowledged **terminal rejection reasons** (constant `TERMINAL_REJECTION_REASONS` in `federationWorker.ts`): `duplicate`, `recipient_not_found`, `attribution_mismatch`, `unknown_event_type`. Outbox entries with these reasons are deleted with no retry.
`processOutboxTick` recognizes a configurable set of receiver-acknowledged **terminal rejection reasons** (constant `TERMINAL_REJECTION_REASONS` in `federationWorker.ts`): `duplicate`, `recipient_not_found`, `attribution_mismatch`, `unknown_event_type`, `self_target_invalid`. Outbox entries with these reasons are deleted with no retry.
- `duplicate` — the receiving instance already has the row (same `(sourceInstance, sourceMessageId)`); retrying will fail identically until TTL.
- `recipient_not_found`, `attribution_mismatch`, `unknown_event_type` — structural mismatches that cannot be resolved by retrying.
- `self_target_invalid` — emitted by `processFriendRequestCreateEvent` when an inbound `friend_request_create`'s `from`-identity equals its `to`-identity (after origin normalization). Defense-in-depth: the sender's local `cannot_friend_self` check should catch this, but the receiver does not trust upstream validation. Retrying will not change the payload. The friend-create rollback callback maps this to client-facing `peer_rejected`.
For non-`duplicate` terminals, the worker invokes a registered permanent-failure callback via `invokePermanentFailureCallback(eventType, messageId, reason)` from `utils/federationRollback.ts`. Currently registered: `friend_request_create``rollbackFriendRequestCreate` (deletes the local `friend_requests` row by `relay_message_id` and emits WS `friend_request_relay_failed` to the sender). Other event types may register their own callbacks. See `social.md` §6 "Failure Handling" for the friend-specific rollback contract.
+14 -9
View File
@@ -326,18 +326,23 @@ The wire format of the queued event is identical to the pre-2026-04-25 flow; onl
4. On success: deletes outbox entries. On failure: exponential backoff retry.
**Inbound (receiving instance -- `federation.ts:processFriendRequestCreateEvent`):**
1. **Validate:** `event.friendship` must exist, `from.homeInstance === sourceInstance` (authority check)
2. **Resolve sender:** `resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance)` -- creates stub if needed
3. **Hydrate sender profile:** `hydrateReplicatedUserProfile(fromUser, event.friendship.fromProfile)` -- updates stub fields
4. **Resolve recipient:** `resolveLocalUser(to.homeUserId)` -- must be a native user on this instance (returns `undefined` if not found -> reject)
5. **Idempotency checks:** If already friends -> accept as no-op. If pending request already exists from same sender -> accept as no-op.
6. **Create request:** Insert `friend_requests` row with local IDs
7. **WS broadcast:** `friend_request_received` sent to local recipient with sender's sanitized profile
8. Push `event.messageId` to accepted array
1. **Validate:** `event.friendship` must exist, `from.homeInstance === sourceInstance` (authority check).
2. **Self-target guard (defense-in-depth):** if `from.homeUserId === to.homeUserId` and `normalizeOriginForCompare(from.homeInstance) === normalizeOriginForCompare(to.homeInstance)`, reject with `self_target_invalid`. Runs before any side effects (no stub creation). The sender's local `cannot_friend_self` check should catch this, but the receiver must not trust upstream validation.
3. **Resolve sender:** `resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance)` -- creates stub if needed.
4. **Hydrate sender profile:** `hydrateReplicatedUserProfile(fromUser, event.friendship.fromProfile)` -- updates stub fields.
5. **Resolve recipient:** `resolveLocalUser(to.homeUserId)` -- must be a native user on this instance (returns `undefined` if not found -> reject `recipient_not_found`).
6. **Idempotency checks:**
- **Already friends (either direction):** accept as no-op.
- **Pending request in EITHER direction:** accept as no-op. Forward (from→to) covers redelivery; reverse (to→from) covers the cross-fire race where alice@A and bob@B click "add friend" near-simultaneously and each sender's local both-direction check passes before either event reaches the wire. Mirrors the sender-side `incoming_request_exists` both-direction check (step 8 above) to keep the receiver and sender contracts symmetric.
7. **Create request:** Insert `friend_requests` row with local IDs.
8. **WS broadcast:** `friend_request_received` sent to local recipient with sender's sanitized profile.
9. Push `event.messageId` to accepted array.
> **Race outcome.** Under the cross-fire scenario both instances converge on a single pending row (whichever event materialized first). The redundant outbound on the other side becomes harmless dead state — the local user already sees the pending request via existing UI. Auto-promotion to mutual friendship when both directions exist is not implemented; it is a product/design conversation, not a correctness fix.
### Failure Handling: Async Rollback
When the outbox worker receives a relay response from the remote instance, it classifies each rejected entry. As of 2026-04-25, a configurable set of **terminal rejection reasons** (`TERMINAL_REJECTION_REASONS` in `federationWorker.ts`) causes an outbox entry to be deleted with no retry: `duplicate`, `recipient_not_found`, `attribution_mismatch`, `unknown_event_type`.
When the outbox worker receives a relay response from the remote instance, it classifies each rejected entry. A configurable set of **terminal rejection reasons** (`TERMINAL_REJECTION_REASONS` in `federationWorker.ts`) causes an outbox entry to be deleted with no retry: `duplicate`, `recipient_not_found`, `attribution_mismatch`, `unknown_event_type`, `self_target_invalid`.
For non-`duplicate` terminals, the worker invokes the registered permanent-failure callback via `invokePermanentFailureCallback(eventType, messageId, reason)` from `utils/federationRollback.ts`. For `friend_request_create`, this is **`rollbackFriendRequestCreate`**: