fix(server): local-fast-path for invite snapshot — skip HTTP self-reach

POST /api/dm/space-invite was hanging 5s and returning invite_invalid
for any local-space invite. fetchSpaceInviteSnapshot was being called
against our own public domain from inside the backspace container, which
fails (Docker NAT loopback) and aborts on timeout.

Add getLocalInviteSnapshot — reads the snapshot directly from the DB —
and branch in dm.ts so local invites bypass the HTTP roundtrip entirely.
Cross-instance invites still go through fetchSpaceInviteSnapshot with
its existing SSRF guard.

Also refactor the GET /api/spaces/invite/:code/preview handler to use
the same helper, keeping the snapshot shape in one place.

Tests assert fetchSpaceInviteSnapshot is NOT called for the local case
(critical regression guard) and that the cross-instance path still hits
the HTTP fetch.
This commit is contained in:
Jannis Braun
2026-04-29 22:29:44 +02:00
parent 5481eb9e7e
commit 44e9a4234c
5 changed files with 260 additions and 33 deletions
+8 -3
View File
@@ -22,7 +22,7 @@ import {
type SpaceInviteResponse,
type SpaceInviteSystemPayload,
} from '@backspace/shared';
import { fetchSpaceInviteSnapshot } from '../utils/spaceInviteSnapshot.js';
import { fetchSpaceInviteSnapshot, getLocalInviteSnapshot } from '../utils/spaceInviteSnapshot.js';
import { sanitizeUser } from '../utils/sanitize.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import { fetchDmEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
@@ -1731,10 +1731,15 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'not_a_friend', statusCode: 400 });
}
// 3. Server-to-server snapshot fetch from the space's home instance.
// 3. Snapshot lookup. For local spaces, read the DB directly — fetching
// our own /preview endpoint over HTTPS fails inside Docker (NAT loopback).
// Cross-instance previews still go through the SSRF-validated HTTP path.
const ourOrigin = getOurOrigin();
const spaceOrigin = body.spaceInstanceOrigin || ourOrigin;
const snapshot = await fetchSpaceInviteSnapshot(spaceOrigin, body.inviteCode);
const isLocal = !body.spaceInstanceOrigin || body.spaceInstanceOrigin === ourOrigin;
const snapshot = isLocal
? getLocalInviteSnapshot(body.inviteCode)
: await fetchSpaceInviteSnapshot(spaceOrigin, body.inviteCode);
if (!snapshot) {
return reply.code(400).send({ error: 'invite_invalid', statusCode: 400 });
}