From 5481eb9e7e7835e3afb64e50e8511f4dfe815422 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:19:10 +0200 Subject: [PATCH] fix(server): SSRF guard on cross-instance invite preview fetch --- .../src/utils/spaceInviteSnapshot.test.ts | 20 +++++++++++++++++++ .../server/src/utils/spaceInviteSnapshot.ts | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/packages/server/src/utils/spaceInviteSnapshot.test.ts b/packages/server/src/utils/spaceInviteSnapshot.test.ts index efe1e620..4c0d4098 100644 --- a/packages/server/src/utils/spaceInviteSnapshot.test.ts +++ b/packages/server/src/utils/spaceInviteSnapshot.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { fetchSpaceInviteSnapshot } from './spaceInviteSnapshot'; +// Mock the ssrf module so tests don't need real DNS resolution. +// Default: validateExternalUrl resolves (allow). Individual tests override as needed. +vi.mock('./ssrf.js', () => ({ + validateExternalUrl: vi.fn().mockResolvedValue(undefined), + isPrivateIp: vi.fn().mockReturnValue(false), +})); + describe('fetchSpaceInviteSnapshot', () => { const originalFetch = global.fetch; beforeEach(() => { global.fetch = vi.fn() as any; }); @@ -53,4 +60,17 @@ describe('fetchSpaceInviteSnapshot', () => { const snap = await fetchSpaceInviteSnapshot('https://z.example', 'abc', 50); expect(snap).toBeNull(); }); + + it('returns null when SSRF validator rejects the origin', async () => { + // Override the module-level mock to reject for this test only. + const ssrf = await import('./ssrf.js'); + const spy = vi.spyOn(ssrf, 'validateExternalUrl').mockRejectedValueOnce(new Error('blocked')); + const fetchSpy = global.fetch as any; + + const snap = await fetchSpaceInviteSnapshot('http://127.0.0.1:9200', 'abc'); + expect(snap).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); // CRITICAL — the fetch must NOT happen + + spy.mockRestore(); + }); }); diff --git a/packages/server/src/utils/spaceInviteSnapshot.ts b/packages/server/src/utils/spaceInviteSnapshot.ts index 99d3aa3a..046fdb75 100644 --- a/packages/server/src/utils/spaceInviteSnapshot.ts +++ b/packages/server/src/utils/spaceInviteSnapshot.ts @@ -1,4 +1,5 @@ import type { AvatarColor } from '@backspace/shared'; +import { validateExternalUrl } from './ssrf.js'; export interface SpaceInviteSnapshot { spaceId: string; @@ -24,6 +25,11 @@ export async function fetchSpaceInviteSnapshot( timeoutMs = 5000, ): Promise { const url = `${spaceInstanceOrigin}/api/spaces/invite/${encodeURIComponent(inviteCode)}/preview`; + try { + await validateExternalUrl(url); + } catch { + return null; + } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try {