( underlying: typeof globalThis.fetch, options: HostedHttpClientOptions, sharedResolve?: GuardResolver, )
| 618 | ); |
| 619 | |
| 620 | const guardFetch = ( |
| 621 | underlying: typeof globalThis.fetch, |
| 622 | options: HostedHttpClientOptions, |
| 623 | sharedResolve?: GuardResolver, |
| 624 | ): typeof globalThis.fetch => { |
| 625 | const resolve = sharedResolve ?? makeSharedResolver(options); |
| 626 | const maxRedirects = Math.max(0, options.maxRedirects ?? 10); |
| 627 | // SAFETY: narrowed to the one thing a closure cannot express — Bun's fetch |
| 628 | // type carries a static `preconnect` property, which packages built against |
| 629 | // @types/bun require. The call signature is checked by the annotations, and |
| 630 | // the return type is `Promise<Response>` on every path. |
| 631 | return (async (input, init) => { |
| 632 | const normalized = normalizeFetchInput(input, init); |
| 633 | let currentUrl = normalized.url; |
| 634 | let currentInit = normalized.init; |
| 635 | const signal = currentInit?.signal ?? undefined; |
| 636 | // Read once, before the loop rewrites `currentInit` on each hop. |
| 637 | const redirectMode = currentInit?.redirect ?? "follow"; |
| 638 | for (let redirects = 0; ; redirects++) { |
| 639 | const guarded = await Effect.runPromiseExit( |
| 640 | validateOutboundUrl(currentUrl, options, resolve), |
| 641 | { signal }, |
| 642 | ); |
| 643 | if (Exit.isFailure(guarded)) { |
| 644 | // Both halves are required. The cause decides that this was an |
| 645 | // interrupt rather than a guard verdict — an aborted signal alone |
| 646 | // would bury a real SSRF block under a retryable AbortError. The |
| 647 | // signal's own state decides that the caller asked for it: an |
| 648 | // interrupt from anywhere else must not be reported as the caller's |
| 649 | // abort, which would fabricate a reason that never happened. |
| 650 | if (signal?.aborted && Cause.hasInterruptsOnly(guarded.cause)) rejectAsAbort(signal); |
| 651 | return await Effect.runPromise(Effect.failCause(guarded.cause)); |
| 652 | } |
| 653 | const response = await underlying(currentUrl, withStreamingDuplex(currentInit)); |
| 654 | const isRedirect = |
| 655 | response.status >= 300 && response.status < 400 && response.headers.has("location"); |
| 656 | // The guard pins the transport to `redirect: "manual"` so it sees every |
| 657 | // hop, which is a separate question from what the caller asked for. |
| 658 | // Following anyway would answer a caller who asked to inspect a 3xx with |
| 659 | // the followed response and no Location header — the OAuth authorize |
| 660 | // probe reads exactly that header — and would turn a requested rejection |
| 661 | // into a 200. Neither mode is a security concession: not following is |
| 662 | // strictly safer than following, so the caller's intent stands. |
| 663 | if (isRedirect && redirectMode === "error") { |
| 664 | return await rejectBlocked( |
| 665 | currentUrl, |
| 666 | "Redirect received while the caller requested redirect: error", |
| 667 | ); |
| 668 | } |
| 669 | if (isRedirect && redirectMode === "manual") return response; |
| 670 | if (isRedirect && redirects < maxRedirects) { |
| 671 | // The 3xx is abandoned here. Undici keeps a connection out of the |
| 672 | // pool until an unread body is consumed or cancelled, so a |
| 673 | // redirect-heavy integration would leak one connection per hop. A |
| 674 | // cancel that itself fails — a 3xx declaring Content-Length whose |
| 675 | // connection dies mid-body rejects with "terminated" — is connection |
| 676 | // hygiene failing, not the request: the redirect still proceeds. |
| 677 | const body = response.body; |
no test coverage detected