@@ -8,7 +8,6 @@ import { createLogger } from '@sim/logger'
88import { preferIpv4 , resolveHostAddresses } from '@sim/security/dns'
99import { isLoopbackIp , isPrivateIp , isPrivateIpHost , unwrapIpv6Brackets } from '@sim/security/ssrf'
1010import { toError } from '@sim/utils/errors'
11- import { omit } from '@sim/utils/object'
1211import { HttpProxyAgent } from 'http-proxy-agent'
1312import { HttpsProxyAgent } from 'https-proxy-agent'
1413import * as ipaddr from 'ipaddr.js'
@@ -365,8 +364,18 @@ export interface SecureFetchOptions {
365364 */
366365 maxResponseBytes ?: number
367366 signal ?: AbortSignal
368- /** Drop the Authorization header when following a redirect, so it is not sent to the redirect target's origin. */
367+ /**
368+ * Drop the Authorization header when following a redirect. A cross-origin hop already
369+ * drops every caller header unconditionally; this narrows same-origin hops too, for
370+ * endpoints that redirect to a target carrying its own signed URL.
371+ */
369372 stripAuthOnRedirect ?: boolean
373+ /**
374+ * Refuse a redirect that would resend the request body, because for this
375+ * caller a second delivery is unsafe (`once`/`billable`). Redirects that drop
376+ * the body still follow — they cannot repeat the write.
377+ */
378+ refuseBodyPreservingRedirect ?: boolean
370379 /**
371380 * Pre-validated, IP-pinned `http://` proxy URL (see {@link validateAndPinProxyUrl}).
372381 * When set, the connection routes through this proxy and target-IP pinning is
@@ -526,6 +535,87 @@ function assertGuardedRedirectTarget(url: URL, allowedPinnedIp?: string): void {
526535 }
527536}
528537
538+ /** Headers that describe a request body and must not outlive it. */
539+ const ENTITY_HEADERS = [
540+ 'content-length' ,
541+ 'content-type' ,
542+ 'content-encoding' ,
543+ 'transfer-encoding' ,
544+ ] as const
545+
546+ /** Case-insensitive header removal — callers supply arbitrary casing. */
547+ function stripHeaders (
548+ headers : Record < string , string > ,
549+ remove : readonly string [ ]
550+ ) : Record < string , string > {
551+ const drop = new Set ( remove . map ( ( name ) => name . toLowerCase ( ) ) )
552+ const kept : Record < string , string > = { }
553+ for ( const [ name , value ] of Object . entries ( headers ) ) {
554+ if ( ! drop . has ( name . toLowerCase ( ) ) ) kept [ name ] = value
555+ }
556+ return kept
557+ }
558+
559+ interface RedirectHopPolicy {
560+ /** Method for the next hop. */
561+ method : string
562+ /** Whether the body — and the entity headers describing it — must be dropped. */
563+ dropBody : boolean
564+ /** Whether every caller-supplied header must be dropped (cross-origin hop). */
565+ dropHeaders : boolean
566+ }
567+
568+ /**
569+ * Decides how a request may be replayed on a redirect target, per RFC 9110 section 15.4.
570+ *
571+ * Both redirect followers in this file route through here so they cannot drift. They had
572+ * drifted: {@link secureFetchWithPinnedIP} replayed the original method and body on
573+ * 301/302/303 — delivering a non-idempotent write twice — and forwarded `Authorization`
574+ * and every other caller header to whatever origin the upstream named, while
575+ * {@link followRedirectsGuarded} implemented the correct rules a hundred lines above it.
576+ *
577+ * Throws when a faithful replay would forward a body across an origin boundary: the
578+ * redirect target is chosen by the peer, so a preserved 307/308 body would hand the
579+ * caller's payload — and any credential inside it — to an open-redirect destination.
580+ */
581+ function resolveRedirectHop ( args : {
582+ status : number
583+ method : string
584+ hasBody : boolean
585+ sameOrigin : boolean
586+ /**
587+ * Set by a delivery whose duplicate is unsafe (`once`, `billable`). Only a
588+ * body-PRESERVING hop can deliver twice inside one attempt, so only that is
589+ * refused — a 303, or a 301/302 on POST, becomes a bodyless GET and cannot
590+ * repeat the write, which is why those still follow. Refusing every redirect
591+ * instead would fail the upload-and-parse endpoints that legitimately answer
592+ * a POST with a redirect to storage.
593+ */
594+ refuseBodyPreservingRedirect ?: boolean
595+ } ) : RedirectHopPolicy {
596+ const method = args . method . toUpperCase ( )
597+ // 303 always, and 301/302 on POST by long-standing client convention, degrade to a
598+ // bodyless GET. A retained Content-Length/Content-Type on a bodyless GET is malformed.
599+ const dropBody =
600+ args . status === 303 || ( ( args . status === 301 || args . status === 302 ) && method === 'POST' )
601+ const preservesBody = args . hasBody && ! dropBody
602+ if ( ! args . sameOrigin && preservesBody ) {
603+ throw new Error ( 'Blocked by SSRF policy: cross-origin redirect would forward a request body' )
604+ }
605+ if ( args . refuseBodyPreservingRedirect && preservesBody ) {
606+ throw new Error (
607+ `Redirect would resend the request body to the redirect target, which for this delivery could ` +
608+ `deliver it twice. HTTP ${ args . status } preserves the method and body; only a redirect that ` +
609+ `drops the body is followed for a delivery whose duplicate is unsafe.`
610+ )
611+ }
612+ return {
613+ method : dropBody ? 'GET' : method ,
614+ dropBody,
615+ dropHeaders : ! args . sameOrigin ,
616+ }
617+ }
618+
529619/**
530620 * Manual, revalidating redirect follower used by the guarded fetch. Auto-follow
531621 * is unsafe here on two counts the connect-time lookup cannot cover: IP-literal
@@ -574,32 +664,21 @@ export async function followRedirectsGuarded(
574664 }
575665 const nextUrl = new URL ( location , currentUrl )
576666 assertGuardedRedirectTarget ( nextUrl , options ?. allowRedirectToIp )
577- // Per the fetch spec: 303 (and 301/302 on POST) switch to a bodyless GET, dropping
578- // the entity headers that described the removed body (a retained Content-Length /
579- // Content-Type on a bodyless GET is malformed and undici rejects it).
580- if ( status === 303 || ( ( status === 301 || status === 302 ) && method === 'POST' ) ) {
581- method = 'GET'
582- body = undefined
583- if ( headers !== undefined ) {
584- const sanitized = new Headers ( headers as HeadersInit )
585- sanitized . delete ( 'content-length' )
586- sanitized . delete ( 'content-type' )
587- sanitized . delete ( 'content-encoding' )
588- sanitized . delete ( 'transfer-encoding' )
589- // double-cast-allowed: Headers is a valid undici HeadersInit at runtime but the DOM/undici types differ
590- headers = sanitized as unknown as UndiciRequestInit [ 'headers' ]
591- }
592- }
593- if ( nextUrl . origin !== currentUrl . origin ) {
667+ const hopPolicy = resolveRedirectHop ( {
668+ status,
669+ method,
670+ hasBody : body !== undefined && body !== null ,
671+ sameOrigin : nextUrl . origin === currentUrl . origin ,
672+ } )
673+ method = hopPolicy . method
674+ if ( hopPolicy . dropBody ) body = undefined
675+ if ( hopPolicy . dropHeaders ) {
594676 headers = undefined
595- // 307/308 preserve method+body; forwarding a body cross-origin can hand OAuth
596- // client secrets / tokens to an open-redirect target now that redirects really
597- // dial the new origin. No legitimate MCP/OAuth flow does this — refuse it.
598- if ( body !== undefined && body !== null ) {
599- throw new Error (
600- 'Blocked by SSRF policy: cross-origin redirect would forward a request body'
601- )
602- }
677+ } else if ( hopPolicy . dropBody && headers !== undefined ) {
678+ const sanitized = new Headers ( headers as HeadersInit )
679+ for ( const name of ENTITY_HEADERS ) sanitized . delete ( name )
680+ // double-cast-allowed: Headers is a valid undici HeadersInit at runtime but the DOM/undici types differ
681+ headers = sanitized as unknown as UndiciRequestInit [ 'headers' ]
603682 }
604683 currentUrl = nextUrl
605684 }
@@ -1018,12 +1097,28 @@ export async function secureFetchWithPinnedIP(
10181097 settledReject ( new Error ( `Redirect blocked: ${ validation . error } ` ) )
10191098 return
10201099 }
1021- const redirectOptions = options . stripAuthOnRedirect
1022- ? {
1023- ...options ,
1024- headers : omit ( options . headers ?? { } , [ 'Authorization' , 'authorization' ] ) ,
1025- }
1026- : options
1100+ const hop = resolveRedirectHop ( {
1101+ status : statusCode ,
1102+ method : options . method ?? 'GET' ,
1103+ hasBody : options . body !== undefined && options . body !== null ,
1104+ sameOrigin : new URL ( redirectUrl ) . origin === parsed . origin ,
1105+ refuseBodyPreservingRedirect : options . refuseBodyPreservingRedirect ,
1106+ } )
1107+ let redirectHeaders = hop . dropHeaders ? undefined : options . headers
1108+ if ( redirectHeaders && hop . dropBody ) {
1109+ redirectHeaders = stripHeaders ( redirectHeaders , ENTITY_HEADERS )
1110+ }
1111+ // A cross-origin hop already dropped every header; this keeps the opt-in
1112+ // promise on the same-origin hops it still applies to.
1113+ if ( redirectHeaders && options . stripAuthOnRedirect ) {
1114+ redirectHeaders = stripHeaders ( redirectHeaders , [ 'authorization' ] )
1115+ }
1116+ const redirectOptions : SecureFetchOptions & { allowHttp ?: boolean } = {
1117+ ...options ,
1118+ method : hop . method ,
1119+ body : hop . dropBody ? undefined : options . body ,
1120+ headers : redirectHeaders ,
1121+ }
10271122 return secureFetchWithPinnedIP (
10281123 redirectUrl ,
10291124 validation . resolvedIP ! ,
0 commit comments