Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"file-type": "^21.1.1",
"glob": "^10.3.10",
"highlight.js": "^11.11.1",
"ipaddr.js": "^1.9.1",
"isbinaryfile": "^5.0.4",
"markdown-it": "^14.1.0",
"md-to-pdf": "^5.2.5",
Expand Down
18 changes: 12 additions & 6 deletions src/tools/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { getFileHandler, TextFileHandler } from '../utils/files/index.js';
import type { ReadOptions, FileResult, PdfPageItem } from '../utils/files/base.js';
import { isPdfFile } from "./mime-types.js";
import { parsePdfToMarkdown, editPdf, PdfOperations, PdfMetadata, parseMarkdownToPdf } from './pdf/index.js';
import { fetchUrlValidated } from '../utils/urlSafety.js';

export { assertUrlIsFetchable } from '../utils/urlSafety.js';
import { isBinaryFile } from 'isbinaryfile';

// CONSTANTS SECTION - Consolidate all timeouts and thresholds
Expand Down Expand Up @@ -364,9 +367,9 @@ export async function readFileFromUrl(url: string): Promise<FileResult> {
const timeoutId = setTimeout(() => controller.abort(), FILE_OPERATION_TIMEOUTS.URL_FETCH);

try {
const response = await fetch(url, {
signal: controller.signal
});
// SSRF guard: validates the URL (and every redirect hop) before any
// request leaves the process.
const { response, finalUrl: currentUrl } = await fetchUrlValidated(url, controller.signal);

// Clear the timeout since fetch completed
clearTimeout(timeoutId);
Expand All @@ -378,12 +381,15 @@ export async function readFileFromUrl(url: string): Promise<FileResult> {
// Get MIME type from Content-Type header or infer from URL
const contentType = response.headers.get('content-type') || 'text/plain';
const isImage = isImageFile(contentType);
const isPdf = isPdfFile(contentType) || url.toLowerCase().endsWith('.pdf');
const isPdf = isPdfFile(contentType) || currentUrl.toLowerCase().endsWith('.pdf');

// NEW: Add PDF handling before image check
if (isPdf) {
// Use URL directly - pdfreader handles URL downloads internally
const pdfResult = await parsePdfToMarkdown(url);
// Parse the bytes from the request we already validated. Handing the URL
// to the parser would download it a second time, resolving DNS again and
// bypassing the checks above.
const pdfBytes = new Uint8Array(await response.arrayBuffer());
const pdfResult = await parsePdfToMarkdown(pdfBytes);

return {
content: "",
Expand Down
17 changes: 14 additions & 3 deletions src/tools/pdf/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { mdToPdf } from 'md-to-pdf';
import type { PageRange } from './lib/pdf2md.js';
import { PdfParseResult, pdf2md } from './lib/pdf2md.js';
import { CONFIG_FILE } from '../../config.js';
import { fetchUrlValidated } from '../../utils/urlSafety.js';

const isUrl = (source: string): boolean =>
source.startsWith('http://') || source.startsWith('https://');
Expand Down Expand Up @@ -259,9 +260,14 @@ export function ensureChromeAvailable(): void {
});
}

async function loadPdfToBuffer(source: string): Promise<Buffer | ArrayBuffer> {
async function loadPdfToBuffer(source: string | Uint8Array): Promise<Buffer | ArrayBuffer | Uint8Array> {
if (typeof source !== 'string') {
return source;
}
if (isUrl(source)) {
const response = await fetch(source);
// Same SSRF guard as read_file's URL path — this parser must not be a
// way to fetch URLs that the guard would refuse.
const { response } = await fetchUrlValidated(source);
return await response.arrayBuffer();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
return await fs.readFile(source);
Expand All @@ -270,8 +276,13 @@ async function loadPdfToBuffer(source: string): Promise<Buffer | ArrayBuffer> {

/**
* Convert PDF to Markdown using @opendocsg/pdf2md
*
* Callers that already downloaded the PDF should pass the bytes rather than the
* URL — it avoids a second fetch, and the original request's validation stays
* authoritative. URL sources are fetched through the same SSRF guard as
* read_file.
*/
export async function parsePdfToMarkdown(source: string, pageNumbers: number[] | PageRange = []): Promise<PdfParseResult> {
export async function parsePdfToMarkdown(source: string | Uint8Array, pageNumbers: number[] | PageRange = []): Promise<PdfParseResult> {
try {
const data = await loadPdfToBuffer(source);

Expand Down
118 changes: 118 additions & 0 deletions src/utils/urlSafety.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import dns from 'dns/promises';
import ipaddr from 'ipaddr.js';
import fetch from 'cross-fetch';

// Follow at most this many redirects when reading a URL, re-validating each hop.
const MAX_URL_REDIRECTS = 5;

/**
* Returns true if an IP address is not publicly routable — loopback, private,
* link-local (including the 169.254.169.254 cloud metadata endpoint), unique
* local, CGNAT, multicast, or otherwise reserved. Anything that isn't a valid
* IP is treated as blocked so a malformed value can't slip through.
*
* Uses ipaddr.js for CIDR-correct classification across IPv4 and IPv6: only the
* `unicast` range is publicly routable, everything else is refused. IPv4-mapped
* IPv6 (e.g. `::ffff:127.0.0.1` and its hex form `::ffff:7f00:1`) is unwrapped
* to its IPv4 address first so the underlying range is what gets checked.
*/
function isBlockedAddress(ip: string): boolean {
let addr: ipaddr.IPv4 | ipaddr.IPv6;
try {
addr = ipaddr.parse(ip.split('%')[0]); // drop any IPv6 zone id
} catch {
return true;
}
if (addr.kind() === 'ipv6' && (addr as ipaddr.IPv6).isIPv4MappedAddress()) {
addr = (addr as ipaddr.IPv6).toIPv4Address();
}
return addr.range() !== 'unicast';
}

/**
* Guards against SSRF before a URL is fetched. Allows only http(s), and rejects
* hosts that are — or that resolve to — non-public addresses. Every resolved
* address is checked, so a hostname pointing at an internal IP is blocked too.
*
* @param rawUrl The URL about to be fetched
* @throws Error if the URL is not safe to fetch
*/
export async function assertUrlIsFetchable(rawUrl: string): Promise<void> {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error(`Invalid URL: ${rawUrl}`);
}

if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`URL protocol not allowed: ${parsed.protocol} — only http and https can be read`);
}

const hostname = parsed.hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets

if (ipaddr.isValid(hostname)) {
if (isBlockedAddress(hostname)) {
throw new Error(`Refusing to fetch a URL that targets a non-public address: ${hostname}`);
}
return;
}

let resolved: Array<{ address: string }>;
try {
resolved = await dns.lookup(hostname, { all: true });
} catch {
throw new Error(`Could not resolve host: ${hostname}`);
}
if (resolved.length === 0) {
throw new Error(`Could not resolve host: ${hostname}`);
}
for (const { address } of resolved) {
if (isBlockedAddress(address)) {
throw new Error(
`Refusing to fetch a URL that resolves to a non-public address: ${hostname} -> ${address}`
);
}
}
}

/**
* Fetches a URL with the SSRF guard applied to the initial request and to every
* redirect hop — a public URL must not be able to redirect us onto an internal
* address. All code that downloads a user-supplied URL must go through this
* (or pass already-downloaded bytes onward) rather than calling fetch directly.
*
* @returns The final response and the URL it actually came from
*/
export async function fetchUrlValidated(
url: string,
signal?: AbortSignal
): Promise<{ response: Awaited<ReturnType<typeof fetch>>; finalUrl: string }> {
await assertUrlIsFetchable(url);

let currentUrl = url;
let response!: Awaited<ReturnType<typeof fetch>>;
for (let hop = 0; ; hop++) {
response = await fetch(currentUrl, {
signal,
redirect: 'manual'
});

if (response.status < 300 || response.status >= 400) {
break;
}

const location = response.headers.get('location');
if (!location) {
break; // redirect status without a target — treat as final
}
if (hop >= MAX_URL_REDIRECTS) {
throw new Error(`Too many redirects while fetching URL: ${url}`);
}
const nextUrl = new URL(location, currentUrl).toString();
await assertUrlIsFetchable(nextUrl);
currentUrl = nextUrl;
}

return { response, finalUrl: currentUrl };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
84 changes: 84 additions & 0 deletions test/test-read-url-ssrf.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import assert from 'assert';
import { assertUrlIsFetchable, readFileFromUrl } from '../dist/tools/filesystem.js';
import { parsePdfToMarkdown } from '../dist/tools/pdf/index.js';

/**
* Regression tests for #587 / #560: read_file with isUrl fetched arbitrary URLs
* with no SSRF protection, so a URL like http://169.254.169.254/… could read
* cloud instance metadata or reach internal services.
*
* assertUrlIsFetchable() now rejects non-http(s) schemes and any host that is
* (or resolves to) a non-public address. These cases short-circuit before any
* network access, so the test is hermetic — no outbound requests are made.
*/

let passed = 0;
const ok = (msg) => { passed++; console.log(`✓ ${msg}`); };

// A minimal single-page PDF, inlined so the PDF check needs no fixture or network.
const MINIMAL_PDF_BASE64 =
'JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoK' +
'PDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUg' +
'L1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCAyMDAgMjAwXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8' +
'IC9GMSA0IDAgUiA+PiA+PiAvQ29udGVudHMgNSAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL1R5cGUgL0ZvbnQg' +
'L1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGgg' +
'MzMgPj4Kc3RyZWFtCkJUIC9GMSAyNCBUZiAyMCAxMDAgVGQgKEhpKSBUaiBFVAplbmRzdHJlYW0KZW5kb2JqCnhy' +
'ZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4g' +
'CjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDI0MSAwMDAwMCBuIAowMDAwMDAwMzExIDAwMDAwIG4gCnRyYWls' +
'ZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMzk0CiUlRU9GCg==';

const BLOCKED_URLS = [
'http://169.254.169.254/latest/meta-data/iam/security-credentials/', // AWS metadata
'http://[fd00::1]/', // IPv6 unique-local
'http://127.0.0.1:8080/admin', // loopback
'http://localhost/internal', // resolves to loopback
'http://10.0.0.1/', // private
'http://192.168.1.1/', // private
'http://172.16.5.4/', // private
'http://[::1]/', // IPv6 loopback
'http://[fe90::1]/', // IPv6 link-local (fe80::/10, not just fe80::)
'http://[::ffff:7f00:1]/', // IPv4-mapped loopback in hex form (127.0.0.1)
'file:///etc/passwd', // non-http scheme
'ftp://example.com/secret', // non-http scheme
];
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function run() {
for (const url of BLOCKED_URLS) {
await assert.rejects(
() => assertUrlIsFetchable(url),
(err) => err instanceof Error && /not allowed|non-public|Invalid URL|resolve/i.test(err.message),
`assertUrlIsFetchable should reject ${url}`
);
}
ok(`assertUrlIsFetchable rejects all ${BLOCKED_URLS.length} SSRF / non-http URLs`);

// readFile(isUrl) routes through the same guard, so the metadata PoC is
// blocked before any request leaves the process.
await assert.rejects(
() => readFileFromUrl('http://169.254.169.254/latest/meta-data/'),
/non-public/i,
'readFileFromUrl should refuse the cloud metadata endpoint'
);
ok('readFileFromUrl refuses the cloud metadata endpoint');

// A public address with a valid scheme passes validation. An IP literal is
// used so the check needs no outbound DNS and stays hermetic; no request is
// made — we only assert the guard itself does not reject it.
await assert.doesNotReject(
() => assertUrlIsFetchable('https://8.8.8.8/file.txt'),
'a public https URL must pass the guard'
);
ok('a public https address passes the guard');
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// readFileFromUrl hands the PDF parser the bytes it already fetched through the
// guard; passing the URL made the parser download it a second time, re-resolving
// DNS outside these checks. That requires the parser to accept bytes — it used
// to treat every source as a path or URL.
const parsed = await parsePdfToMarkdown(Buffer.from(MINIMAL_PDF_BASE64, 'base64'));
assert.strictEqual(parsed.metadata.totalPages, 1, 'byte input should parse as a PDF');
ok('parsePdfToMarkdown accepts already-fetched bytes');
}

run()
.then(() => { console.log(`\nPASS (${passed}/4)`); process.exit(0); })
.catch((e) => { console.error(`\nFAIL: ${e.message}`); process.exit(1); });