@@ -543,7 +543,7 @@ const MAX_GUARDED_REDIRECTS = 5
543543 * a 3xx to `http://169.254.169.254/` would otherwise connect directly. Hostname
544544 * targets are covered by {@link createSsrfGuardedLookup} at connect time.
545545 */
546- function assertGuardedRedirectTarget ( url : URL ) : void {
546+ function assertGuardedRedirectTarget ( url : URL , allowedPinnedIp ?: string ) : void {
547547 if ( url . protocol !== 'http:' && url . protocol !== 'https:' ) {
548548 throw new Error ( `Blocked by SSRF policy: redirect to unsupported protocol ${ url . protocol } ` )
549549 }
@@ -552,6 +552,16 @@ function assertGuardedRedirectTarget(url: URL): void {
552552 ? url . hostname . slice ( 1 , - 1 )
553553 : url . hostname
554554 if ( ipaddr . isValid ( host ) && isPrivateOrReservedIP ( host ) ) {
555+ // The pinned-private carve-out permits exactly its own validated IP as a target (a
556+ // self-hosted MCP on a private IP, or a same-host redirect that stays on it) — but nothing
557+ // else private (a redirect to e.g. the cloud metadata IP is still blocked).
558+ if (
559+ allowedPinnedIp &&
560+ ipaddr . isValid ( allowedPinnedIp ) &&
561+ ipaddr . process ( host ) . toString ( ) === ipaddr . process ( allowedPinnedIp ) . toString ( )
562+ ) {
563+ return
564+ }
555565 throw new Error ( 'Blocked by SSRF policy: redirect to a private or reserved address' )
556566 }
557567}
@@ -568,15 +578,14 @@ export async function followRedirectsGuarded(
568578 rawFetch : ( url : string , init : UndiciRequestInit ) => Promise < Response > ,
569579 input : string ,
570580 init : UndiciRequestInit ,
571- options ?: { validateInitialTarget ?: boolean }
581+ options ?: { allowRedirectToIp ?: string }
572582) : Promise < Response > {
573583 let currentUrl = new URL ( input )
574- // The initial URL gets the same IP-literal check as redirect hops, so the exported
575- // guard is self-contained even when a caller skips its own up-front validation. The
576- // pinned-private MCP carve-out opts out (`validateInitialTarget: false`): its caller has
577- // already validated the URL and legitimately targets a private IP (a self-hosted server
578- // configured with an IP-literal URL). Redirect HOPS below are always validated regardless.
579- if ( options ?. validateInitialTarget !== false ) assertGuardedRedirectTarget ( currentUrl )
584+ // The initial URL gets the same IP-literal check as redirect hops, so the exported guard is
585+ // self-contained even when a caller skips its own up-front validation. `allowRedirectToIp`
586+ // (the pinned-private MCP carve-out's validated IP) permits that one private target — both the
587+ // initial URL and any hop that stays on it — while everything else private stays blocked.
588+ assertGuardedRedirectTarget ( currentUrl , options ?. allowRedirectToIp )
580589 let method = ( init . method ?? 'GET' ) . toUpperCase ( )
581590 let body = init . body
582591 let headers = init . headers
@@ -604,7 +613,7 @@ export async function followRedirectsGuarded(
604613 throw new Error ( `Blocked by SSRF policy: more than ${ MAX_GUARDED_REDIRECTS } redirects` )
605614 }
606615 const nextUrl = new URL ( location , currentUrl )
607- assertGuardedRedirectTarget ( nextUrl )
616+ assertGuardedRedirectTarget ( nextUrl , options ?. allowRedirectToIp )
608617 // Per the fetch spec: 303 (and 301/302 on POST) switch to a bodyless GET, dropping
609618 // the entity headers that described the removed body (a retained Content-Length /
610619 // Content-Type on a bodyless GET is malformed and undici rejects it).
@@ -1033,11 +1042,11 @@ export function createPinnedFetchWithDispatcher(
10331042 }
10341043 return response
10351044 }
1036- // The pinned fetch's caller has already validated the target (and the private carve-out
1037- // legitimately pins to a private IP), so skip re-validating the initial URL — otherwise a
1038- // self-hosted MCP configured with a private IP-literal URL would be blocked by its own
1039- // transport. Redirect hops are still validated inside the follower .
1040- return followRedirectsGuarded ( rawFetch , target , undiciInit , { validateInitialTarget : false } )
1045+ // Permit this pinned IP as a redirect/initial target even when it's private (the
1046+ // self-hosted MCP carve-out on a private/loopback IP, and same-host redirects that stay on
1047+ // it) — otherwise the guarded policy would block a self-hosted server reaching itself. Any
1048+ // OTHER private target (e.g. a redirect to the cloud metadata IP) is still blocked .
1049+ return followRedirectsGuarded ( rawFetch , target , undiciInit , { allowRedirectToIp : resolvedIP } )
10411050 }
10421051
10431052 return { fetch : pinned , dispatcher }
0 commit comments