Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
45 changes: 31 additions & 14 deletions packages/core/src/tools/web-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { ToolErrorType } from './tool-error.js';
import { getErrorMessage } from '../utils/errors.js';
import { getResponseText } from '../utils/partUtils.js';
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
//Updated references from fetchWithTimeout to the new fetchWithSafeDns function in both the fallback (executeFallbackForUrl) and experimental (executeExperimental) fetch code paths.
import { isPrivateIpAsync, fetchWithSafeDns } from '../utils/fetch.js';
import { truncateString, wrapUntrusted } from '../utils/textUtils.js';
import { convert } from 'html-to-text';
import {
Expand Down Expand Up @@ -267,14 +268,19 @@ class WebFetchToolInvocation extends BaseToolInvocation<
);
}

private isBlockedHost(urlStr: string): boolean {
private async isBlockedHost(urlStr: string): Promise<boolean> {
try {
const url = new URL(urlStr);
const hostname = url.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1') {
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '::ffff:127.0.0.1'
) {
return true;
}
return isPrivateIp(urlStr);
return await isPrivateIpAsync(urlStr);
} catch {
return true;
}
Comment on lines +271 to 286

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The isBlockedHost method is vulnerable to Server-Side Request Forgery (SSRF) because it fails to block IPv6 loopback addresses like ::1 or [::1]. The current logic checks for localhost and 127.0.0.1, but then relies on isPrivateIpAsync. However, isPrivateIpAsync explicitly returns false for loopback hosts, including IPv6 loopback addresses. This allows an attacker to bypass the host blocking and access internal services via [::1]. The suggested change explicitly blocks these IPv6 loopback addresses to prevent SSRF attacks.

  private async isBlockedHost(urlStr: string): Promise<boolean> {
    try {
      const url = new URL(urlStr);
      const hostname = url.hostname.toLowerCase();
      if (
        hostname === 'localhost' ||
        hostname === '127.0.0.1' ||
        hostname === '::1' ||
        hostname === '[::1]'
      ) {
        return true;
      }
      return await isPrivateIpAsync(urlStr);
    } catch {
      return true;
    }
  }

Comment on lines +271 to 286

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The application is vulnerable to DNS Rebinding because it performs a check-then-fetch pattern (Time-of-Check to Time-of-Use / TOCTOU).

  1. In isBlockedHost, the hostname is resolved to its IP addresses using isPrivateIpAsync (which calls dns.promises.lookup).
  2. If the resolved IP is public, isBlockedHost returns false (allowing the request).
  3. Then, fetchWithTimeout or fetch is called with the original URL, which triggers a second DNS resolution to establish the connection.

An attacker can configure a malicious DNS server with a very low TTL (e.g., 0 seconds) that returns a public IP address on the first query (the check) and a private IP address (e.g., 127.0.0.1 or 10.0.0.1) on the second query (the fetch). This completely bypasses the private IP check and allows the attacker to access internal resources.

To prevent DNS rebinding, the connection must be pinned to the validated IP address. Instead of fetching the original URL, resolve the hostname once, validate the resolved IP, and then perform the fetch directly using the validated IP address while passing the original hostname in the Host header (or using a custom DNS lookup agent/hook that only returns the pre-validated IP).

Expand All @@ -285,7 +291,8 @@ class WebFetchToolInvocation extends BaseToolInvocation<
signal: AbortSignal,
): Promise<string> {
const url = convertGithubUrlToRaw(urlStr);
if (this.isBlockedHost(url)) {

if (await this.isBlockedHost(url)) {
debugLogger.warn(`[WebFetchTool] Blocked access to host: ${url}`);
throw new Error(
`Access to blocked or private host ${url} is not allowed.`,
Expand All @@ -294,7 +301,7 @@ class WebFetchToolInvocation extends BaseToolInvocation<

const response = await retryWithBackoff(
async () => {
const res = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS, {
const res = await fetchWithSafeDns(url, URL_FETCH_TIMEOUT_MS, {
signal,
headers: {
'User-Agent': USER_AGENT,
Expand Down Expand Up @@ -350,16 +357,26 @@ class WebFetchToolInvocation extends BaseToolInvocation<
return textContent;
}

private filterAndValidateUrls(urls: string[]): {
private async filterAndValidateUrls(urls: string[]): Promise<{
toFetch: string[];
skipped: string[];
} {
}> {
const uniqueUrls = [...new Set(urls.map(normalizeUrl))];
const toFetch: string[] = [];
const skipped: string[] = [];

for (const url of uniqueUrls) {
if (this.isBlockedHost(url)) {
// Resolve blocked-host checks in parallel rather than sequentially,
// since each check can involve a DNS lookup and up to 20 URLs may
// be present in a single prompt.
const blockedChecks = await Promise.all(
uniqueUrls.map(async (url) => ({
url,
blocked: await this.isBlockedHost(url),
})),
);

for (const { url, blocked } of blockedChecks) {
if (blocked) {
debugLogger.warn(
`[WebFetchTool] Skipped private or local host: ${url}`,
);
Comment on lines +360 to 382

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Performing DNS resolution sequentially in a for...of loop for up to 20 URLs can introduce significant latency and block the agent loop, especially if some DNS queries are slow or time out.

We can optimize this by performing the DNS lookups in parallel using Promise.all before filtering the results.

  private async filterAndValidateUrls(urls: string[]): Promise<{
    toFetch: string[];
    skipped: string[];
  }> {
    const uniqueUrls = [...new Set(urls.map(normalizeUrl))];
    const validationResults = await Promise.all(
      uniqueUrls.map(async (url) => {
        const isBlocked = await this.isBlockedHost(url);
        return { url, isBlocked };
      })
    );

    const toFetch: string[] = [];
    const skipped: string[] = [];

    for (const { url, isBlocked } of validationResults) {
      if (isBlocked) {
        debugLogger.warn(
          `[WebFetchTool] Skipped private or local host: ${url}`,
        );

Expand Down Expand Up @@ -615,7 +632,7 @@ ${aggregatedContent}
// Convert GitHub blob URL to raw URL
url = convertGithubUrlToRaw(url);

if (this.isBlockedHost(url)) {
if (await this.isBlockedHost(url)) {
const errorMessage = `Access to blocked or private host ${url} is not allowed.`;
debugLogger.warn(
`[WebFetchTool] Blocked experimental fetch to host: ${url}`,
Expand All @@ -633,7 +650,7 @@ ${aggregatedContent}
try {
const response = await retryWithBackoff(
async () => {
const res = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS, {
const res = await fetchWithSafeDns(url, URL_FETCH_TIMEOUT_MS, {
signal,
headers: {
Accept:
Expand Down Expand Up @@ -769,7 +786,7 @@ Response: ${rawResponseText}`;
const userPrompt = this.params.prompt!;
const { validUrls } = parsePrompt(userPrompt);

const { toFetch, skipped } = this.filterAndValidateUrls(validUrls);
const { toFetch, skipped } = await this.filterAndValidateUrls(validUrls);

// If everything was skipped, fail early
if (toFetch.length === 0 && skipped.length > 0) {
Expand Down
120 changes: 120 additions & 0 deletions packages/core/src/utils/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { URL } from 'node:url';
import { Agent, EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
import ipaddr from 'ipaddr.js';
import { lookup } from 'node:dns/promises';
import dns from 'node:dns';

export class FetchError extends Error {
constructor(
Expand Down Expand Up @@ -40,6 +41,8 @@ setGlobalDispatcher(
}),
);

let safeDispatcher: Agent | EnvHttpProxyAgent | undefined = undefined;

export function updateGlobalFetchTimeouts(timeoutMs: number) {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new RangeError(
Expand All @@ -58,6 +61,7 @@ export function updateGlobalFetchTimeouts(timeoutMs: number) {
}),
);
}
safeDispatcher = undefined;
}

/**
Expand Down Expand Up @@ -246,4 +250,120 @@ export function setGlobalProxy(proxy: string) {
bodyTimeout: defaultBodyTimeout,
}),
);
/*
1. Created a secure lookup wrapper safeDnsLookup that resolves hostnames and enforces isAddressPrivate checks on the resolved addresses.

2. Introduced a function getSafeDispatcher() that creates/caches an undici.Agent or undici.EnvHttpProxyAgent configured with the connect.lookup option pointing to safeDnsLookup.

3. Exposed a fetchWithSafeDns() function that uses this safe dispatcher.

4. Updated setGlobalProxy to clear/recreate the safe dispatcher when proxy configurations are updated */
safeDispatcher = undefined;
}

export function safeDnsLookup(
hostname: string,
options: dns.LookupOptions,
callback: (
err: Error | null,
address: string | dns.LookupAddress[],
family?: number,
) => void,
): void {
dns.lookup(hostname, options, (err, address, family) => {
if (err) {
return callback(err, address, family);
}

if (Array.isArray(address)) {
const hasPrivate = address.some(
(addr) => addr && addr.address && isAddressPrivate(addr.address),
);
if (hasPrivate) {
return callback(new PrivateIpError(), []);
}
return callback(null, address);
}

if (typeof address === 'string' && isAddressPrivate(address)) {
return callback(new PrivateIpError(), '', family);
}

callback(null, address, family);
});
}
function getSafeDispatcher() {
if (safeDispatcher) {
return safeDispatcher;
}
/* [SECURITY NOTE]: When a proxy is configured via EnvHttpProxyAgent,
safeDnsLookup protects direct connections. Proxied CONNECT requests
resolve DNS on the proxy server itself. */
const connectOptions = {
lookup: safeDnsLookup,
};

if (currentProxy) {
const noProxy = (
process.env['NO_PROXY'] ??
process.env['no_proxy'] ??
''
)?.trim();
safeDispatcher = new EnvHttpProxyAgent({
httpProxy: currentProxy,
httpsProxy: currentProxy,
noProxy,
headersTimeout: defaultHeadersTimeout,
bodyTimeout: defaultBodyTimeout,
connect: connectOptions,
});
} else {
safeDispatcher = new Agent({
headersTimeout: defaultHeadersTimeout,
bodyTimeout: defaultBodyTimeout,
connect: connectOptions,
});
}

return safeDispatcher;
}

export async function fetchWithSafeDns(
url: string,
timeout: number,
options?: RequestInit,
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

if (options?.signal) {
if (options.signal.aborted) {
controller.abort();
} else {
options.signal.addEventListener('abort', () => controller.abort(), {
once: true,
});
}
}

try {
const dispatcher = getSafeDispatcher();
const response = await fetch(url, {
...options,
signal: controller.signal,
// @ts-expect-error dispatcher is supported by undici-backed fetch in Node.js
dispatcher,
});
return response;
} catch (error) {
if (isAbortError(error)) {
if (options?.signal?.aborted) {
throw options.signal.reason ?? error;
}
throw new FetchError(`Request timed out after ${timeout}ms`, 'ETIMEDOUT');
}
throw new FetchError(getErrorMessage(error), undefined, { cause: error });
} finally {
clearTimeout(timeoutId);
}
}
Loading