mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user