@@ -139,12 +139,14 @@ function isLocalhostHostname(hostname: string): boolean {
139139 * URLs with env var references in the hostname are skipped — they will be
140140 * validated after resolution at execution time.
141141 *
142- * Returns the resolved IP address when DNS resolution was performed (so the
143- * caller can pin subsequent connections to that IP and prevent DNS-rebinding
144- * TOCTOU attacks). Returns null in cases where pinning is unnecessary or
142+ * Returns the IP address to pin subsequent connections to (the resolved IP for
143+ * hostnames, or the literal itself for public IP-literal URLs) so the caller can
144+ * prevent DNS-rebinding TOCTOU attacks and stop redirects from escaping to
145+ * internal hosts. Pinning matters for IP literals too: without it the transport
146+ * uses the default fetch, which follows an attacker-controlled 3xx redirect to a
147+ * private/metadata address. Returns null only when pinning is unnecessary or
145148 * impossible: no URL, allowlist-only mode, env-var hostnames (validated later),
146- * IP literals (no DNS to rebind), and localhost on self-hosted (no rebinding
147- * risk against a fixed loopback).
149+ * and localhost on self-hosted (no rebinding risk against a fixed loopback).
148150 *
149151 * @throws McpSsrfError if the URL resolves to a blocked IP address
150152 */
@@ -174,7 +176,11 @@ export async function validateMcpServerSsrf(url: string | undefined): Promise<st
174176 if ( isPrivateOrReservedIP ( cleanHostname ) ) {
175177 throw new McpSsrfError ( 'MCP server URL cannot point to a private or reserved IP address' )
176178 }
177- return null
179+ // Public IP literal: pin to this exact address so the caller's pinned fetch
180+ // (createPinnedFetch) keeps every redirect hop on it. Returning null here
181+ // would fall back to the default fetch, which follows a 3xx redirect to a
182+ // private/metadata host and escapes SSRF controls.
183+ return cleanHostname
178184 }
179185
180186 let address : string
0 commit comments