Repository navigation
fix: resolve SSRF vulnerability in web-fetch.ts by using async DNS resolution #28557
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
dc915cc
a84e0dc
b46a01b
340b7c3
83daa14
c7c3b6c
991031c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The application is vulnerable to DNS Rebinding because it performs a check-then-fetch pattern (Time-of-Check to Time-of-Use / TOCTOU).
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., 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 |
||
|
|
@@ -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.`, | ||
|
|
@@ -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, | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performing DNS resolution sequentially in a We can optimize this by performing the DNS lookups in parallel using 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}`,
); |
||
|
|
@@ -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}`, | ||
|
|
@@ -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: | ||
|
|
@@ -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) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
isBlockedHostmethod is vulnerable to Server-Side Request Forgery (SSRF) because it fails to block IPv6 loopback addresses like::1or[::1]. The current logic checks forlocalhostand127.0.0.1, but then relies onisPrivateIpAsync. However,isPrivateIpAsyncexplicitly returnsfalsefor 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.