@@ -14,7 +14,6 @@ import {
1414 Agent ,
1515 type Dispatcher ,
1616 type RequestInit as UndiciRequestInit ,
17- interceptors as undiciInterceptors ,
1817 request as undiciRequest ,
1918} from 'undici'
2019import { isHosted , isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags'
@@ -587,7 +586,13 @@ export async function followRedirectsGuarded(
587586 } )
588587 const status = response . status
589588 const location = response . headers . get ( 'location' )
590- if ( ! [ 301 , 302 , 303 , 307 , 308 ] . includes ( status ) || ! location ) return response
589+ if ( ! [ 301 , 302 , 303 , 307 , 308 ] . includes ( status ) || ! location ) {
590+ // `response.url` is already the final hop's URL (set per-request by the raw fetch); flag
591+ // `redirected` too when at least one hop was followed, matching fetch semantics.
592+ if ( hop > 0 )
593+ Object . defineProperty ( response , 'redirected' , { value : true , configurable : true } )
594+ return response
595+ }
591596 // Cancel the redirect body up front so the throw paths below (hop cap, blocked
592597 // target) can't leave a socket checked out on the long-lived Agent.
593598 await response . body ?. cancel ( ) . catch ( ( ) => { } )
@@ -880,6 +885,33 @@ async function undiciRequestAsResponse(
880885 }
881886}
882887
888+ /**
889+ * Normalizes a `fetch(input, init)` call into a URL string + init. A `Request` input carries
890+ * its own method/headers/body/signal; lift them into the init (explicit init fields win, per
891+ * fetch semantics) so a manual redirect follower can't silently downgrade a POST Request to a
892+ * bare GET or lose its headers.
893+ */
894+ async function liftFetchArgs (
895+ input : RequestInfo | URL ,
896+ init ?: RequestInit
897+ ) : Promise < { target : string ; effectiveInit : RequestInit } > {
898+ const target = typeof input === 'string' ? input : input instanceof URL ? input . href : input . url
899+ if ( typeof Request !== 'undefined' && input instanceof Request ) {
900+ const bodyAllowed = input . method !== 'GET' && input . method !== 'HEAD'
901+ return {
902+ target,
903+ effectiveInit : {
904+ method : input . method ,
905+ headers : input . headers ,
906+ body : bodyAllowed ? await input . clone ( ) . arrayBuffer ( ) : undefined ,
907+ signal : input . signal ,
908+ ...init ,
909+ } ,
910+ }
911+ }
912+ return { target, effectiveInit : init ?? { } }
913+ }
914+
883915/**
884916 * SSRF-guarded `fetch` + its `Agent` for outbound requests to user-controlled
885917 * hosts: DNS resolves normally, and every socket connect validates the chosen
@@ -903,21 +935,7 @@ export function createSsrfGuardedFetchWithDispatcher(options?: { maxResponseSize
903935 undiciRequestAsResponse ( url , init as unknown as RequestInit , dispatcher )
904936
905937 const guarded = async ( input : RequestInfo | URL , init ?: RequestInit ) : Promise < Response > => {
906- const target = typeof input === 'string' ? input : input instanceof URL ? input . href : input . url
907- // A Request input carries its own method/headers/body/signal; lift them into the
908- // init (explicit init fields win, per fetch semantics) so the manual redirect
909- // follower doesn't silently downgrade a guarded POST Request to a bare GET.
910- let effectiveInit : RequestInit = init ?? { }
911- if ( typeof Request !== 'undefined' && input instanceof Request ) {
912- const bodyAllowed = input . method !== 'GET' && input . method !== 'HEAD'
913- effectiveInit = {
914- method : input . method ,
915- headers : input . headers ,
916- body : bodyAllowed ? await input . clone ( ) . arrayBuffer ( ) : undefined ,
917- signal : input . signal ,
918- ...init ,
919- }
920- }
938+ const { target, effectiveInit } = await liftFetchArgs ( input , init )
921939 // double-cast-allowed: DOM RequestInit and undici RequestInit are structurally compatible at runtime but the TS types differ
922940 return followRedirectsGuarded ( rawFetch , target , effectiveInit as unknown as UndiciRequestInit )
923941 }
@@ -977,21 +995,40 @@ export function createPinnedFetchWithDispatcher(
977995 ...( options ?. maxResponseSize !== undefined ? { maxResponseSize : options . maxResponseSize } : { } ) ,
978996 } )
979997
998+ const rawFetch = ( url : string , init : UndiciRequestInit ) : Promise < Response > =>
999+ // double-cast-allowed: DOM RequestInit and undici RequestInit differ in TS but match at runtime
1000+ undiciRequestAsResponse ( url , init as unknown as RequestInit , dispatcher )
1001+
9801002 // Requests go through `undici.request` (not `undici.fetch`) because fetch's streaming
9811003 // `response.body` never delivers under the Bun runtime the server runs on — the same bug
982- // {@link createSsrfGuardedFetchWithDispatcher } works around. Unlike the guarded builder, the
983- // pinned fetch is handed straight to provider/A2A SDKs with no `followRedirectsGuarded`
984- // wrapper, so redirects are followed here via undici's redirect interceptor. Every hop still
985- // dispatches through the pinned `Agent` (its `connect.lookup` forces `resolvedIP`), so a
986- // redirect can't escape to another address — matching the old fetch path's guarantee.
987- const redirecting = dispatcher . compose (
988- undiciInterceptors . redirect ( { maxRedirections : DEFAULT_MAX_REDIRECTS } )
989- )
990- const pinned = ( input : RequestInfo | URL , init ?: RequestInit ) : Promise < Response > =>
991- undiciRequestAsResponse ( input , init ?? { } , redirecting )
992-
993- // Return the base `Agent` (not the composed dispatcher) so callers `destroy()` the socket
994- // owner on close; the interceptor is stateless and re-dispatches through it.
1004+ // {@link createSsrfGuardedFetchWithDispatcher } works around. Redirects are handled here (not
1005+ // by a caller's wrapper — the pinned fetch is passed straight to provider/A2A SDKs), honoring
1006+ // the request's `redirect` mode: `manual`/`error` must NOT transparently follow (e.g.
1007+ // `detectMcpAuthType` inspects the 3xx to classify auth). The default `follow` uses
1008+ // {@link followRedirectsGuarded }, which drops headers on cross-origin hops (so a redirect
1009+ // can't disclose a provider `api-key` to another origin) and stamps the final `response.url`.
1010+ // Every hop still dispatches through the pinned `Agent` (its `connect.lookup` forces
1011+ // `resolvedIP`), so a redirect can't escape to another address.
1012+ const pinned = async ( input : RequestInfo | URL , init ?: RequestInit ) : Promise < Response > => {
1013+ const { target, effectiveInit } = await liftFetchArgs ( input , init )
1014+ const mode = effectiveInit . redirect ?? 'follow'
1015+ // double-cast-allowed: DOM RequestInit and undici RequestInit are structurally compatible at runtime but the TS types differ
1016+ const undiciInit = effectiveInit as unknown as UndiciRequestInit
1017+ if ( mode === 'manual' ) {
1018+ return rawFetch ( target , undiciInit )
1019+ }
1020+ if ( mode === 'error' ) {
1021+ const response = await rawFetch ( target , undiciInit )
1022+ const location = response . headers . get ( 'location' )
1023+ if ( response . status >= 300 && response . status < 400 && location ) {
1024+ await response . body ?. cancel ( ) . catch ( ( ) => { } )
1025+ throw new TypeError ( 'Pinned fetch received an unexpected redirect (redirect: "error")' )
1026+ }
1027+ return response
1028+ }
1029+ return followRedirectsGuarded ( rawFetch , target , undiciInit )
1030+ }
1031+
9951032 return { fetch : pinned , dispatcher }
9961033}
9971034
0 commit comments