MCPcopy Create free account
hub / github.com/UsefulSoftwareCo/executor / guardFetch

Function guardFetch

packages/core/sdk/src/hosted-http-client.ts:620–741  ·  view source on GitHub ↗
(
  underlying: typeof globalThis.fetch,
  options: HostedHttpClientOptions,
  sharedResolve?: GuardResolver,
)

Source from the content-addressed store, hash-verified

618 );
619
620const 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;

Callers 3

makeHostedFetchFunction · 0.85
makeHostedHttpFunction · 0.85
hostedHttpClientLayerFunction · 0.85

Calls 14

makeSharedResolverFunction · 0.85
normalizeFetchInputFunction · 0.85
validateOutboundUrlFunction · 0.85
rejectAsAbortFunction · 0.85
underlyingFunction · 0.85
withStreamingDuplexFunction · 0.85
rejectBlockedFunction · 0.85
redirectDemotesToGetFunction · 0.85
demoteToGetFunction · 0.85
isStreamedBodyFunction · 0.85
toStringMethod · 0.80

Tested by

no test coverage detected