fix(server): SSRF guard on cross-instance invite preview fetch

This commit is contained in:
Jannis Braun
2026-04-29 22:19:10 +02:00
parent 4476c55963
commit 5481eb9e7e
2 changed files with 26 additions and 0 deletions
@@ -1,6 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { fetchSpaceInviteSnapshot } from './spaceInviteSnapshot'; 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', () => { describe('fetchSpaceInviteSnapshot', () => {
const originalFetch = global.fetch; const originalFetch = global.fetch;
beforeEach(() => { global.fetch = vi.fn() as any; }); beforeEach(() => { global.fetch = vi.fn() as any; });
@@ -53,4 +60,17 @@ describe('fetchSpaceInviteSnapshot', () => {
const snap = await fetchSpaceInviteSnapshot('https://z.example', 'abc', 50); const snap = await fetchSpaceInviteSnapshot('https://z.example', 'abc', 50);
expect(snap).toBeNull(); 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();
});
}); });
@@ -1,4 +1,5 @@
import type { AvatarColor } from '@backspace/shared'; import type { AvatarColor } from '@backspace/shared';
import { validateExternalUrl } from './ssrf.js';
export interface SpaceInviteSnapshot { export interface SpaceInviteSnapshot {
spaceId: string; spaceId: string;
@@ -24,6 +25,11 @@ export async function fetchSpaceInviteSnapshot(
timeoutMs = 5000, timeoutMs = 5000,
): Promise<SpaceInviteSnapshot | null> { ): Promise<SpaceInviteSnapshot | null> {
const url = `${spaceInstanceOrigin}/api/spaces/invite/${encodeURIComponent(inviteCode)}/preview`; const url = `${spaceInstanceOrigin}/api/spaces/invite/${encodeURIComponent(inviteCode)}/preview`;
try {
await validateExternalUrl(url);
} catch {
return null;
}
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), timeoutMs);
try { try {