import { assertPublicHttpUrl, isRedirectStatus, resolveRedirectUrl, } from './outbound-url-policy'; export type SafeFetchImplementation = ( input: string | URL, init?: RequestInit, ) => Promise; export type SafeFetchOptions = { fetchImpl?: SafeFetchImplementation; maxRedirects?: number; sensitiveHeaders?: Iterable; /** Drop every caller-supplied header when a redirect changes origin. */ stripHeadersOnCrossOriginRedirect?: boolean; }; function removeHeader(headers: Headers, name: string) { if (headers.has(name)) headers.delete(name); } function cancelResponseBodyBestEffort(response: Response): void { void response.body?.cancel().catch(() => undefined); } function requestInitForRedirect( init: RequestInit, from: URL, to: URL, status: number, sensitiveHeaders: Iterable, stripHeadersOnCrossOriginRedirect: boolean, ): RequestInit { let headers = new Headers(init.headers); let method = String(init.method ?? 'GET').toUpperCase(); let body = init.body ?? undefined; if ( status === 303 || ((status === 301 || status === 302) && method === 'POST') ) { method = 'GET'; body = undefined; removeHeader(headers, 'content-length'); removeHeader(headers, 'content-type'); } if (from.origin !== to.origin) { if (body !== undefined) { throw new Error( 'Cross-origin redirect blocked because it would replay a request body.', ); } if (stripHeadersOnCrossOriginRedirect) { // Callers that can construct credentials from plaintext secrets cannot // reliably identify every transformed value. Do not forward any caller // header across an origin boundary in that trust model. headers = new Headers(); } else { removeHeader(headers, 'authorization'); removeHeader(headers, 'cookie'); removeHeader(headers, 'proxy-authorization'); for (const header of sensitiveHeaders) { removeHeader(headers, header); } } } return { ...init, method, body, headers, redirect: 'manual', }; } export async function safePublicFetch( input: string | URL, init: RequestInit = {}, options: SafeFetchOptions = {}, ): Promise { const redirectMode = init.redirect ?? 'follow'; const maxRedirects = options.maxRedirects ?? 10; const fetchImpl = options.fetchImpl ?? fetch; const sensitiveHeaders = options.sensitiveHeaders ?? []; const stripHeadersOnCrossOriginRedirect = options.stripHeadersOnCrossOriginRedirect === true; let currentUrl = assertPublicHttpUrl(input); let currentInit: RequestInit = { ...init, redirect: 'manual', }; for ( let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1 ) { const response = await fetchImpl(currentUrl, currentInit); if (!isRedirectStatus(response.status)) { return response; } if (redirectMode === 'error') { cancelResponseBodyBestEffort(response); throw new Error( `Redirect blocked while fetching ${currentUrl.toString()}.`, ); } if (redirectMode !== 'follow') { return response; } const location = response.headers.get('location'); if (!location) { return response; } if (redirectCount === maxRedirects) { cancelResponseBodyBestEffort(response); throw new Error( `Too many redirects while fetching ${currentUrl.toString()}.`, ); } // A redirect response can stream an unbounded body. The next request must // not leave that body/socket flowing in the background. cancelResponseBodyBestEffort(response); const nextUrl = resolveRedirectUrl(location, currentUrl); currentInit = requestInitForRedirect( currentInit, currentUrl, nextUrl, response.status, sensitiveHeaders, stripHeadersOnCrossOriginRedirect, ); currentUrl = nextUrl; } throw new Error( `Too many redirects while fetching ${currentUrl.toString()}.`, ); }