import { checkServerIdentity as checkTlsServerIdentity } from 'node:tls'; import { ArticleDestinationBlockedError, type ArticleDnsResolver, type ResolvedArticleDestination, resolveArticleDestination, } from '../utils/article-network-policy'; import { cancelRateLimitedResponse, rateLimitedFetch } from '../utils/async'; const ARTICLE_REDIRECT_LIMIT = 10; const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy'] as const; // Bun snapshots environment-proxy routing at process startup. Capture the same // state when this module loads; deleting process.env later does not disable it. const STARTUP_ENVIRONMENT_PROXY = PROXY_ENV_KEYS.find((key) => Boolean(process.env[key]?.trim())); export interface ArticleRequestOptions { timeoutMs?: number; signal?: AbortSignal; allowPrivateNetwork?: boolean; /** Dependency seam for deterministic policy tests; production uses Bun DNS. */ resolver?: ArticleDnsResolver; } export interface ArticleRequestResult { response: Response; finalUrl: string; redirected: boolean; } function protectedRequestInit( destination: ResolvedArticleDestination, base: RequestInit, initialOrigin: string, ): RequestInit & { tls?: BunFetchRequestInitTLS } { const bunBase = base as RequestInit & { tls?: BunFetchRequestInitTLS }; const headers = new Headers(base.headers); headers.set('Host', destination.hostHeader); if (destination.logicalUrl.origin !== initialOrigin) { headers.delete('Authorization'); headers.delete('Cookie'); headers.delete('Proxy-Authorization'); } const hostname = destination.logicalUrl.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, ''); const tls = destination.logicalUrl.protocol === 'https:' ? { ...bunBase.tls, rejectUnauthorized: true, ...(destination.tlsServerName ? { serverName: destination.tlsServerName } : {}), checkServerIdentity: ( _transportHostname: string, certificate: Parameters[1], ) => checkTlsServerIdentity(hostname, certificate), } : undefined; return { ...base, headers, redirect: 'manual', ...(tls ? { tls } : {}), }; } function assertDirectTransport(init: RequestInit): void { if ('proxy' in init) { throw new ArticleDestinationBlockedError('Protected Article requests do not accept a caller-supplied proxy'); } if (STARTUP_ENVIRONMENT_PROXY) { throw new ArticleDestinationBlockedError( `Protected Article requests require a direct connection; restart with ${STARTUP_ENVIRONMENT_PROXY} unset`, ); } } function wholeRequestSignal( options: ArticleRequestOptions, initSignal: AbortSignal | null | undefined, ): AbortSignal | undefined { const signals: AbortSignal[] = []; if (options.signal) signals.push(options.signal); if (initSignal) signals.push(initSignal); if (options.timeoutMs !== undefined) { if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 0) { throw new RangeError('Article request timeout must be a non-negative safe integer'); } signals.push(AbortSignal.timeout(options.timeoutMs)); } if (signals.length === 0) return undefined; return signals.length === 1 ? signals[0] : AbortSignal.any(signals); } /** * Fetch publisher-controlled Article traffic through a public-network policy. * Every redirect is independently resolved and the checked address is pinned * into the transport URL, eliminating a second application DNS lookup. */ export async function fetchArticleRequest( url: string, init: RequestInit = {}, options: ArticleRequestOptions = {}, ): Promise { assertDirectTransport(init); let logicalUrl = new URL(url); const initialOrigin = logicalUrl.origin; let redirected = false; const signal = wholeRequestSignal(options, init.signal); for (let redirectCount = 0; ; redirectCount++) { signal?.throwIfAborted(); const destination = await resolveArticleDestination(logicalUrl, { allowPrivateNetwork: options.allowPrivateNetwork, resolver: options.resolver, signal, }); const response = await rateLimitedFetch( destination.transportUrl.toString(), protectedRequestInit(destination, init, initialOrigin), { signal, rateLimitHostname: destination.logicalUrl.hostname, }, ); const location = response.headers.get('location'); if (!REDIRECT_STATUSES.has(response.status) || !location) { return { response, finalUrl: logicalUrl.toString(), redirected }; } await cancelRateLimitedResponse(response); if (redirectCount >= ARTICLE_REDIRECT_LIMIT) { throw new Error(`Article redirect limit exceeded (${ARTICLE_REDIRECT_LIMIT})`); } logicalUrl = new URL(location, logicalUrl); redirected = true; } }