Skip to content

Commit 53699be

Browse files
committed
fix(security): prevent SSRF in read_file URL fetching
The readFileFromUrl() helper passed user-supplied URLs directly to fetch() without any validation, enabling Server-Side Request Forgery: an AI agent could be prompted to call read_file with an internal URL (e.g. http://169.254.169.254/latest/meta-data/) to exfiltrate cloud metadata, reach internal APIs, or probe the private network. Fixes: 1. Add validateFetchUrl() — called before every fetch — that rejects: - Non-HTTP(S) schemes (file://, ftp://, data://, etc.) - Loopback addresses (127.x.x.x, ::1) - Private IPv4 ranges (10.x, 172.16-31.x, 192.168.x) - Link-local / cloud metadata range (169.254.x.x) - Known internal hostnames (localhost, host.docker.internal, metadata.google.internal, *.local mDNS names) 2. Set redirect: 'error' on the fetch call to prevent open-redirect chains that could route to internal resources after an initial public URL is validated. 3. Improve error message when a redirect is blocked. Closes: wonderwhy-er#410
1 parent eb687cc commit 53699be

1 file changed

Lines changed: 82 additions & 3 deletions

File tree

src/tools/filesystem.ts

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,12 +310,84 @@ type PdfPayload = {
310310

311311
type FileResultPayloads = PdfPayload;
312312

313+
/**
314+
/**
315+
* Validate that a URL is safe to fetch, blocking SSRF attack vectors.
316+
*
317+
* Rejects:
318+
* - Non-HTTP(S) schemes (file://, ftp://, etc.)
319+
* - Private / loopback IPv4 ranges: 127.x, 10.x, 172.16-31.x, 192.168.x
320+
* - Link-local IPv4 (169.254.x.x) — used by AWS/GCP/Azure instance metadata
321+
* - IPv6 loopback (::1) and unspecified (::)
322+
* - Common internal hostnames (localhost, host.docker.internal, etc.)
323+
*
324+
* Note: does not perform DNS resolution, so DNS-rebinding attacks are not
325+
* blocked here; a network-layer proxy or egress firewall is required for that.
326+
*
327+
* @throws Error with a descriptive message if the URL is disallowed.
328+
*/
329+
function validateFetchUrl(rawUrl: string): void {
330+
let parsed: URL;
331+
try {
332+
parsed = new URL(rawUrl);
333+
} catch {
334+
throw new Error(`Invalid URL: ${rawUrl}`);
335+
}
336+
337+
// Only allow HTTP and HTTPS
338+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
339+
throw new Error(`Blocked URL scheme "${parsed.protocol}" — only http: and https: are allowed`);
340+
}
341+
342+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ''); // strip IPv6 brackets
343+
344+
// Block loopback and unspecified IPv6
345+
if (hostname === '::1' || hostname === '::' || hostname === '0:0:0:0:0:0:0:1') {
346+
throw new Error(`Blocked request to loopback address: ${hostname}`);
347+
}
348+
349+
// Block well-known internal hostnames
350+
const blockedHostnames = new Set([
351+
'localhost',
352+
'host.docker.internal',
353+
'metadata.google.internal',
354+
'metadata.goog',
355+
]);
356+
if (blockedHostnames.has(hostname)) {
357+
throw new Error(`Blocked request to internal hostname: ${hostname}`);
358+
}
359+
360+
// Block .local mDNS names
361+
if (hostname.endsWith('.local')) {
362+
throw new Error(`Blocked request to mDNS host: ${hostname}`);
363+
}
364+
365+
// Parse dotted-decimal IPv4 to check private/link-local ranges
366+
const ipv4Parts = hostname.split('.');
367+
if (ipv4Parts.length === 4 && ipv4Parts.every(p => /^\d+$/.test(p))) {
368+
const [a, b, c] = ipv4Parts.map(Number);
369+
if (
370+
a === 127 || // 127.0.0.0/8 loopback
371+
a === 10 || // 10.0.0.0/8 private
372+
(a === 172 && b >= 16 && b <= 31) || // 172.16-31.x private
373+
(a === 192 && b === 168) || // 192.168.0.0/16 private
374+
(a === 169 && b === 254) || // 169.254.0.0/16 link-local (cloud metadata)
375+
(a === 0) // 0.x.x.x unspecified
376+
) {
377+
throw new Error(`Blocked request to private/reserved IP address: ${hostname}`);
378+
}
379+
}
380+
}
381+
313382
/**
314383
* Read file content from a URL
315384
* @param url URL to fetch content from
316385
* @returns File content or file result with metadata
317386
*/
318387
export async function readFileFromUrl(url: string): Promise<FileResult> {
388+
// Validate URL before fetching to prevent SSRF attacks
389+
validateFetchUrl(url);
390+
319391
// Import the MIME type utilities
320392
const { isImageFile } = await import('./mime-types.js');
321393

@@ -325,7 +397,9 @@ export async function readFileFromUrl(url: string): Promise<FileResult> {
325397

326398
try {
327399
const response = await fetch(url, {
328-
signal: controller.signal
400+
signal: controller.signal,
401+
// Disable automatic redirect following to prevent open-redirect → SSRF chains
402+
redirect: 'error',
329403
});
330404

331405
// Clear the timeout since fetch completed
@@ -375,9 +449,14 @@ export async function readFileFromUrl(url: string): Promise<FileResult> {
375449
clearTimeout(timeoutId);
376450

377451
// Return error information instead of throwing
378-
const errorMessage = error instanceof DOMException && error.name === 'AbortError'
452+
const isTimeout = error instanceof DOMException && error.name === 'AbortError';
453+
const isRedirect = error instanceof TypeError
454+
&& (error.message.includes('redirect') || error.message.includes('Redirect'));
455+
const errorMessage = isTimeout
379456
? `URL fetch timed out after ${FILE_OPERATION_TIMEOUTS.URL_FETCH}ms: ${url}`
380-
: `Failed to fetch URL: ${error instanceof Error ? error.message : String(error)}`;
457+
: isRedirect
458+
? `Blocked redirect from ${url} — use the final destination URL directly`
459+
: `Failed to fetch URL: ${error instanceof Error ? error.message : String(error)}`;
381460

382461
throw new Error(errorMessage);
383462
}

0 commit comments

Comments
 (0)