fix(server): SSRF guard on cross-instance invite preview fetch
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user