UNPKG

@ai-sdk/provider-utils

Version:
153 lines (140 loc) 6.7 kB
import { cancelResponseBody } from './cancel-response-body'; import { DownloadError } from './download-error'; import type { FetchFunction } from './fetch-function'; import { isBrowserRuntime } from './is-browser-runtime'; import { isSameOrigin } from './is-same-origin'; import { sanitizeRequestHeaders } from './sanitize-request-headers'; import { validateDownloadUrl } from './validate-download-url'; const MAX_DOWNLOAD_REDIRECTS = 10; // Redirect status codes per the fetch spec (https://fetch.spec.whatwg.org/#redirect-status). // Notably 300 (Multiple Choices) and 304 (Not Modified) are NOT redirects, // even when a server attaches a Location header. const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]); /** * Fetches a URL while enforcing the download guard on every hop. * * Redirects are followed manually (`redirect: 'manual'`) so each hop is * validated with {@link validateDownloadUrl} *before* it is requested. Relying * on the default `redirect: 'follow'` would issue the request to a redirect * target (e.g. an internal address) before we ever see its URL, defeating the * guard. * * Request headers are also protected: {@link sanitizeRequestHeaders} strips * proxy/metadata/cookie/hop-by-hop headers before the first request, and all * caller headers except `User-Agent` are dropped on a cross-origin redirect. * The fetch spec only strips `Authorization` on cross-origin redirects because * in a browser, CORS preflighting protects custom headers; there is no CORS on * the server, so provider API keys carried in custom headers (e.g. `x-key`) * must be dropped here as well. * * A `redirect: 'manual'` request yields an unreadable opaque response in the * browser (and in other spec-compliant fetch implementations), so the redirect * target cannot be validated here. In a real browser this is safe to follow * natively because reaching an internal network is not possible (fetch is * constrained by CORS and cannot reach a server's internal network or * cloud-metadata). On any other runtime we cannot validate the hop, so we fail * closed rather than follow it blindly and bypass the guard. * * A hop that is same-origin with `trustedOrigin` (the developer-configured * provider endpoint) skips target validation: that origin is exactly what an * unvalidated, config-derived request would fetch anyway, and validating it * would break legitimate self-hosted / localhost deployments whose response * URLs point back at the configured host. Hops on any other origin are always * validated. * * The returned response is the final (non-redirect) response. The caller is * responsible for checking `response.ok` and reading the body. * * Not solved here: this does string/literal checks only and does not resolve * DNS, so a hostname that *resolves* to a private address, and DNS rebinding * (the resolved IP flipping between validation and connect), are not blocked. * Server deployments fetching untrusted URLs should constrain egress at the * network layer or inject a Node `fetch` that pins the resolved IP at connect * time — those need DNS/socket APIs not available on all target runtimes * (edge, browser, Bun), so they are intentionally not built in. * * @throws DownloadError if a hop is unsafe, the redirect limit is exceeded, or * a redirect cannot be validated on a non-browser runtime. */ export async function fetchWithValidatedRedirects({ url, headers, abortSignal, maxRedirects = MAX_DOWNLOAD_REDIRECTS, fetch = globalThis.fetch, trustedOrigin, }: { url: string; headers?: HeadersInit; abortSignal?: AbortSignal; maxRedirects?: number; fetch?: FetchFunction; /** * A developer-configured origin (e.g. the provider's `baseURL`) whose hops * skip target validation. Must never be derived from response data. */ trustedOrigin?: string; }): Promise<Response> { // Left undefined when no headers are provided (bare request); otherwise // sanitized once and replaced on a cross-origin hop (credential drop below). let currentHeaders = headers === undefined ? undefined : sanitizeRequestHeaders(headers); const perHopInit = (redirect: RequestRedirect): RequestInit => { const init: RequestInit = { signal: abortSignal, redirect }; if (currentHeaders !== undefined) { // Snapshot per hop: the platform fetch reads headers synchronously, but // an injected fetch may defer, and hops between credential drops would // otherwise share one instance. init.headers = new Headers(currentHeaders); } return init; }; let currentUrl = url; // The bound also acts as a backstop against an unterminated redirect chain. for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) { // The developer-configured origin is trusted by definition; validating it // would reject legitimate self-hosted / localhost deployments. if ( trustedOrigin === undefined || !isSameOrigin(currentUrl, trustedOrigin) ) { validateDownloadUrl(currentUrl); } const response = await fetch(currentUrl, perHopInit('manual')); if (response.type === 'opaqueredirect') { if (!isBrowserRuntime()) { throw new DownloadError({ url, message: `Redirect from ${currentUrl} could not be validated and was blocked`, }); } return await fetch(currentUrl, perHopInit('follow')); } const location = response.headers.get('location'); if (REDIRECT_STATUS_CODES.has(response.status) && location) { // Release the redirect response's connection before moving to the next // hop. Whether that hop is followed or rejected by the guard, an // unconsumed 3xx body would leak the underlying socket. await cancelResponseBody(response); const nextUrl = new URL(location, currentUrl).toString(); // Drop all caller headers except the user-agent before following a // redirect that crosses origin. Only stripping Authorization (as the // fetch spec does) is not enough on the server: providers authenticate // with custom headers too (e.g. `x-key`), and without CORS there is // nothing else stopping them from riding to a foreign host. if (currentHeaders !== undefined && !isSameOrigin(nextUrl, currentUrl)) { const userAgent = currentHeaders.get('user-agent'); currentHeaders = new Headers( userAgent == null ? undefined : { 'user-agent': userAgent }, ); } currentUrl = nextUrl; continue; } return response; } throw new DownloadError({ url, message: `Too many redirects (max ${maxRedirects})`, }); }