@@ -905,6 +905,7 @@ export interface SecureFetchOptions {
905905 headers ?: Record < string , string >
906906 body ?: string
907907 timeout ?: number
908+ maxRedirects ?: number
908909}
909910
910911export class SecureFetchHeaders {
@@ -941,15 +942,33 @@ export interface SecureFetchResponse {
941942 arrayBuffer : ( ) => Promise < ArrayBuffer >
942943}
943944
945+ const DEFAULT_MAX_REDIRECTS = 5
946+
947+ function isRedirectStatus ( status : number ) : boolean {
948+ return status >= 300 && status < 400 && status !== 304
949+ }
950+
951+ function resolveRedirectUrl ( baseUrl : string , location : string ) : string {
952+ try {
953+ return new URL ( location , baseUrl ) . toString ( )
954+ } catch {
955+ throw new Error ( `Invalid redirect location: ${ location } ` )
956+ }
957+ }
958+
944959/**
945960 * Performs a fetch with IP pinning to prevent DNS rebinding attacks.
946961 * Uses the pre-resolved IP address while preserving the original hostname for TLS SNI.
962+ * Follows redirects securely by validating each redirect target.
947963 */
948- export function secureFetchWithPinnedIP (
964+ export async function secureFetchWithPinnedIP (
949965 url : string ,
950966 resolvedIP : string ,
951- options : SecureFetchOptions = { }
967+ options : SecureFetchOptions = { } ,
968+ redirectCount = 0
952969) : Promise < SecureFetchResponse > {
970+ const maxRedirects = options . maxRedirects ?? DEFAULT_MAX_REDIRECTS
971+
953972 return new Promise ( ( resolve , reject ) => {
954973 const parsed = new URL ( url )
955974 const isHttps = parsed . protocol === 'https:'
@@ -985,6 +1004,39 @@ export function secureFetchWithPinnedIP(
9851004
9861005 const protocol = isHttps ? https : http
9871006 const req = protocol . request ( requestOptions , ( res ) => {
1007+ const statusCode = res . statusCode || 0
1008+ const location = res . headers . location
1009+
1010+ if ( isRedirectStatus ( statusCode ) && location && redirectCount < maxRedirects ) {
1011+ res . resume ( )
1012+ const redirectUrl = resolveRedirectUrl ( url , location )
1013+
1014+ validateUrlWithDNS ( redirectUrl , 'redirectUrl' )
1015+ . then ( ( validation ) => {
1016+ if ( ! validation . isValid ) {
1017+ reject ( new Error ( `Redirect blocked: ${ validation . error } ` ) )
1018+ return
1019+ }
1020+ return secureFetchWithPinnedIP (
1021+ redirectUrl ,
1022+ validation . resolvedIP ! ,
1023+ options ,
1024+ redirectCount + 1
1025+ )
1026+ } )
1027+ . then ( ( response ) => {
1028+ if ( response ) resolve ( response )
1029+ } )
1030+ . catch ( reject )
1031+ return
1032+ }
1033+
1034+ if ( isRedirectStatus ( statusCode ) && location && redirectCount >= maxRedirects ) {
1035+ res . resume ( )
1036+ reject ( new Error ( `Too many redirects (max: ${ maxRedirects } )` ) )
1037+ return
1038+ }
1039+
9881040 const chunks : Buffer [ ] = [ ]
9891041
9901042 res . on ( 'data' , ( chunk : Buffer ) => chunks . push ( chunk ) )
@@ -1006,8 +1058,8 @@ export function secureFetchWithPinnedIP(
10061058 }
10071059
10081060 resolve ( {
1009- ok : res . statusCode !== undefined && res . statusCode >= 200 && res . statusCode < 300 ,
1010- status : res . statusCode || 0 ,
1061+ ok : statusCode >= 200 && statusCode < 300 ,
1062+ status : statusCode ,
10111063 statusText : res . statusMessage || '' ,
10121064 headers : new SecureFetchHeaders ( headersRecord ) ,
10131065 text : async ( ) => body ,
0 commit comments