fix(security): re-validate LLM endpoint fetch redirects per hop (GHSA-fmq9)

safeFetchLlm left undici's default redirect:'follow', so a configured LLM
endpoint could 302 to http://169.254.169.254/ and reach cloud-metadata
credentials — the DNS pin does not cover an IP-literal redirect hop, since
net.connect skips the pinned lookup for a literal IP. Follow redirects
manually now, re-resolving/re-checking/re-pinning each hop (allowing LAN/
localhost as before). Also block the Alibaba metadata IPs directly.
This commit is contained in:
jubnl
2026-07-11 21:57:59 +02:00
parent 30d09a019c
commit 4ad8146dbf
2 changed files with 133 additions and 25 deletions
+62 -25
View File
@@ -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<Response> {
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<Response> {
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);
}
/**
+71
View File
@@ -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);
});
});