(
url: string,
options?: { headers?: Record<string, string>; maxRedirects?: number; method?: 'GET' | 'HEAD'; signal?: AbortSignal },
)
| 171 | * callers receive a Response with no tainted URL flowing to fetch(). |
| 172 | */ |
| 173 | export async function safeFetch( |
| 174 | url: string, |
| 175 | options?: { headers?: Record<string, string>; maxRedirects?: number; method?: 'GET' | 'HEAD'; signal?: AbortSignal }, |
| 176 | ): Promise<Response> { |
| 177 | const parsedUrl = new URL(url); |
| 178 | await validateFetchUrl(parsedUrl); |
| 179 | |
| 180 | const headers = options?.headers ?? {}; |
| 181 | const maxRedirects = options?.maxRedirects ?? 5; |
| 182 | const method = options?.method ?? 'GET'; |
| 183 | const signal = options?.signal; |
| 184 | |
| 185 | // URL is validated above by validateFetchUrl (rejects private IPs, link-local, etc). |
| 186 | let response = await fetch(sanitizeUrl(parsedUrl), { method, headers, redirect: 'manual', signal }); |
| 187 | |
| 188 | for (let i = 0; i < maxRedirects && [301, 302, 303, 307, 308].includes(response.status); i++) { |
| 189 | const location = response.headers.get('location'); |
| 190 | if (!location) throw new Error('Redirect with no Location header'); |
| 191 | // validateRedirectTarget re-validates the resolved hop against the same private-IP rules. |
| 192 | const redirectUrl = await validateRedirectTarget(location, parsedUrl); |
| 193 | response = await fetch(sanitizeUrl(redirectUrl), { method, headers, redirect: 'manual', signal }); |
| 194 | } |
| 195 | |
| 196 | return response; |
| 197 | } |
no test coverage detected