diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts index 107dec94d..520fdf3d4 100644 --- a/server/src/utils/ssrfGuard.ts +++ b/server/src/utils/ssrfGuard.ts @@ -113,6 +113,10 @@ function isLinkLocal(ip: string): boolean { // AWS IMDSv6 sits in a fixed ULA slot; block it specifically while leaving the rest of // fc00::/7 reachable (a self-hosted model server may legitimately sit on a ULA address). if (addr === 'fd00:ec2::254' || addr.startsWith('fd00:ec2:')) return true; + // Alibaba Cloud ECS metadata (100.100.100.200, and .100) lives in CGNAT space + // (100.64.0.0/10), which safeFetchLlm otherwise allows for a LAN model server — + // so block the specific metadata IPs directly rather than the whole range. + if (addr === '100.100.100.200' || addr === '100.100.100.100') return true; return false; } @@ -121,33 +125,66 @@ function isLinkLocal(ip: string): boolean { * deliberately ALLOWS loopback and private/LAN targets — a local Ollama on * localhost or a model server on the LAN is the normal, supported config, so * blocking them would break a legitimate setup. It blocks only the link-local / - * cloud-metadata range (169.254.0.0/16, fe80::/10), which is never a real LLM - * host but is the credential-theft SSRF target. The connection is pinned to the - * resolved IP, so a hostname cannot rebind to the metadata address between the - * check and the request. + * cloud-metadata range (169.254.0.0/16, fe80::/10, the AWS/Alibaba metadata IPs), + * which is never a real LLM host but is the credential-theft SSRF target. + * + * Redirects are followed MANUALLY and re-validated on every hop. Checking only the + * initial URL is not enough: a validated public endpoint can 302 to + * `http://169.254.169.254/…`, and the DNS pin does NOT cover that hop — Node's + * net.connect skips the pinned `lookup` for an IP-literal host, so a redirect to a + * literal metadata IP would connect straight through. Following manually (like + * safeFetchFollow) means each hop is re-resolved, re-checked against isLinkLocal, + * and re-pinned. An http→https upgrade or a proxy redirect between LAN hosts still + * works because the check re-runs per hop rather than locking to the first IP. */ -export async function safeFetchLlm(url: string, init?: RequestInit): Promise { - let parsed: URL; - try { - parsed = new URL(url); - } catch { - throw new SsrfBlockedError('Invalid URL'); +export async function safeFetchLlm(url: string, init?: RequestInit, maxRedirects = 5): Promise { + let currentUrl = url; + + for (let hop = 0; ; hop++) { + let parsed: URL; + try { + parsed = new URL(currentUrl); + } catch { + throw new SsrfBlockedError('Invalid URL'); + } + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new SsrfBlockedError('Only HTTP and HTTPS URLs are allowed'); + } + let resolvedIp: string; + try { + resolvedIp = (await dns.lookup(parsed.hostname)).address; + } catch (error_) { + const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown'; + throw new SsrfBlockedError(`Could not resolve hostname (${code})`); + } + if (isLinkLocal(resolvedIp)) { + throw new SsrfBlockedError('Requests to link-local / cloud-metadata addresses are not allowed'); + } + + const dispatcher = createPinnedDispatcher(resolvedIp, true); + const response = await fetch(currentUrl, { ...init, redirect: 'manual', dispatcher } as any); + + // Only a 3xx WITH a Location header is a redirect we follow; anything else + // (2xx/4xx/5xx, or a 3xx with no Location) is the final response. + const status = typeof response.status === 'number' ? response.status : 0; + const location = status >= 300 && status < 400 ? (response.headers?.get('location') ?? null) : null; + if (!location) return response; + + if (hop >= maxRedirects) { + throw new SsrfBlockedError('Too many redirects'); + } + + let nextUrl: string; + try { + nextUrl = new URL(location, currentUrl).toString(); + } catch { + throw new SsrfBlockedError('Invalid redirect location'); + } + // Drain the redirect body so the connection can be reused/closed, then loop + // to re-resolve + re-check + re-pin the next hop. + void response.body?.cancel().catch(() => {}); + currentUrl = nextUrl; } - if (!['http:', 'https:'].includes(parsed.protocol)) { - throw new SsrfBlockedError('Only HTTP and HTTPS URLs are allowed'); - } - let resolvedIp: string; - try { - resolvedIp = (await dns.lookup(parsed.hostname)).address; - } catch (error_) { - const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown'; - throw new SsrfBlockedError(`Could not resolve hostname (${code})`); - } - if (isLinkLocal(resolvedIp)) { - throw new SsrfBlockedError('Requests to link-local / cloud-metadata addresses are not allowed'); - } - const dispatcher = createPinnedDispatcher(resolvedIp, true); - return fetch(url, { ...init, dispatcher } as any); } /** diff --git a/server/tests/unit/utils/ssrfGuard.test.ts b/server/tests/unit/utils/ssrfGuard.test.ts index d7744fe03..f7a6e3182 100644 --- a/server/tests/unit/utils/ssrfGuard.test.ts +++ b/server/tests/unit/utils/ssrfGuard.test.ts @@ -431,4 +431,75 @@ describe('safeFetchLlm', () => { await safeFetchLlm('http://192.168.1.50:8000/v1/chat/completions'); expect(mockFetch).toHaveBeenCalledTimes(1); }); + + // Regression (GHSA-fmq9-ggh3-647p follow-up): the guard must re-validate on EVERY + // redirect hop, not just the initial URL. A public endpoint that 302s to the + // metadata IP-literal would otherwise slip through — the DNS pin does not cover an + // IP-literal redirect hop (Node's net.connect skips the pinned lookup for a literal IP). + function llmResponse(opts: { status: number; location?: string }) { + return { + status: opts.status, + ok: opts.status >= 200 && opts.status < 300, + headers: { get: (h: string) => (h.toLowerCase() === 'location' ? (opts.location ?? null) : null) }, + body: { cancel: () => Promise.resolve() }, + }; + } + + it('requests each hop with redirect:manual so the platform never auto-follows', async () => { + mockIp('203.0.113.10'); + const mockFetch = vi.fn().mockResolvedValue(llmResponse({ status: 200 })); + vi.stubGlobal('fetch', mockFetch); + await safeFetchLlm('https://api.provider.example/v1/chat/completions'); + expect(mockFetch.mock.calls[0][1]).toMatchObject({ redirect: 'manual' }); + }); + + it('blocks a redirect from a public endpoint to the metadata IP-literal', async () => { + mockLookup + .mockResolvedValueOnce({ address: '203.0.113.10', family: 4 }) // configured endpoint (public) + .mockResolvedValue({ address: '169.254.169.254', family: 4 }); // redirect target → metadata + const mockFetch = vi.fn().mockResolvedValueOnce( + llmResponse({ status: 302, location: 'http://169.254.169.254/latest/meta-data/' }), + ); + vi.stubGlobal('fetch', mockFetch); + await expect( + safeFetchLlm('https://api.provider.example/v1/chat/completions', { method: 'POST' }), + ).rejects.toThrow(SsrfBlockedError); + // The metadata hop is refused BEFORE its fetch — only the initial hop ran. + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('follows a legitimate redirect between allowed hosts (http→https upgrade) to the final response', async () => { + mockLookup + .mockResolvedValueOnce({ address: '127.0.0.1', family: 4 }) // http Ollama + .mockResolvedValue({ address: '127.0.0.1', family: 4 }); // https upgrade, same host + const mockFetch = vi + .fn() + .mockResolvedValueOnce(llmResponse({ status: 301, location: 'https://localhost:11434/v1/chat/completions' })) + .mockResolvedValueOnce(llmResponse({ status: 200 })); + vi.stubGlobal('fetch', mockFetch); + const res = await safeFetchLlm('http://localhost:11434/v1/chat/completions', { method: 'POST' }); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(res.status).toBe(200); + }); + + it('blocks the Alibaba Cloud metadata IPs directly (CGNAT range is otherwise allowed for LAN)', async () => { + vi.stubGlobal('fetch', vi.fn()); + mockIp('100.100.100.200'); + await expect(safeFetchLlm('http://model.example/chat')).rejects.toThrow(SsrfBlockedError); + mockIp('100.100.100.100'); + await expect(safeFetchLlm('http://model.example/chat')).rejects.toThrow(SsrfBlockedError); + }); + + it('stops following after the redirect cap', async () => { + mockIp('203.0.113.10'); + const mockFetch = vi + .fn() + .mockResolvedValue(llmResponse({ status: 302, location: 'https://api.provider.example/next' })); + vi.stubGlobal('fetch', mockFetch); + await expect( + safeFetchLlm('https://api.provider.example/v1/chat/completions', undefined, 2), + ).rejects.toThrow(/Too many redirects/i); + // initial + 2 allowed hops = 3 fetches, then the 4th is refused before fetch. + expect(mockFetch).toHaveBeenCalledTimes(3); + }); });