SIGN IN SIGN UP

feat(network): replace bespoke secureFetch with guarded-fetch 0.1.3 (#380)

* feat(network): replace bespoke secureFetch with guarded-fetch 0.1.3

Replace just-bash's hand-rolled SSRF/DNS-rebinding/redirect fetch layer
with an adapter over the guarded-fetch package (0.1.3, Vercel, Apache-2.0,
backed by undici). The public createSecureFetch/SecureFetch/FetchResult
contract is preserved; SSRF, private-IP, DNS-rebinding, connect-time IP
pinning, protocol allow-listing, and redirect re-validation are delegated
to guarded-fetch. just-bash retains path-prefix allow-listing, firewall
header transforms, response-size limits, and GuardedFetchError->domain
error mapping.

Build: guarded-fetch externalized in lib (ESM/CJS) and browser bundles.
Tests: firewall header extraction updated for undici Headers; dns-pin-fetch
and dns-rebinding suites rewritten to the public surface; e2e/bypass
assertions updated for URL normalization. All 308 network/curl/worker-bridge
tests pass.

* fix(network): address review feedback on guarded-fetch migration

- Browser build: replace static import with __BROWSER__-folded eager
  dynamic import; browser bundle now contains zero guarded-fetch refs
- Resource leak: move preflight checks inside try/finally so denied
  requests clean up timeout timer and abort listeners
- Node engine floor: raise from >=20.18.1 to >=20.19 (guarded-fetch req)
- Defense-in-depth: guardedFetch runs inside runTrustedAsync (Agent/
  FinalizationRegistry creation is trusted); module loaded eagerly at
  init time so node:dns/promises import isn't blocked by loader hook
- Cross-origin redirects: strip user Authorization/Cookie, apply RFC 7231
  method/body rewriting (301/302/303 -> GET + drop body)
- Banned-pattern: add @banned-pattern-ignore for globalThis.fetch with
  justification (guarded-fetch supplies its guarded dispatcher via
  fetchInit; Node's fetch honors it, preserving IP pinning)
- Docstring/changeset: correct overclaims about delegated header
  sanitization and credential stripping (both intentionally disabled)
- Deprecated _dnsResolve/_createConnectionOwner on NetworkConfig

532/532 network/curl/worker-bridge tests pass.

* fix(network): fix banned-pattern lint errors in guarded-fetch adapter

- Replace empty object literal return {} with explicit undefined fields
  (avoids prototype pollution banned-pattern violation)
- Remove two unused @banned-pattern-ignore comments that didn't match
  any banned pattern (import() and fetchOptions.fetch= don't match the
  Raw fetch in secured network path regex)

* fix(network): preserve unguarded private-range opt-out

* chore: remove guarded-fetch release-age exemption

* style: format guarded-fetch host policy

* fix(network): use null dispatcher for explicit opt-out

* style: trim guarded-fetch migration comments

* test(wasm): handle normalized HTTP headers

* fix: make wasm header mock type-safe

* Fix: `guarded-fetch` is externalized by just-bash's bundle but missing from the website's `serverExternalPackages`, causing Turbopack to try to bundle it and fail on its `node:dns/promises` import.

This commit fixes the issue reported at examples/website/next.config.ts:24

## Bug

The PR adds `--external:guarded-fetch` to just-bash's `build:lib` and `build:lib:cjs` esbuild scripts (`packages/just-bash/package.json` lines 66–67). As a result, just-bash's node bundle (`dist/bundle/index.js` / `index.cjs`) no longer bundles `guarded-fetch`; it emits an external `import`/`require` of `"guarded-fetch"` instead.

`guarded-fetch@0.1.3` is a runtime dependency of just-bash and imports `node:dns/promises` (used for DNS-based SSRF/rebinding protection).

The website (`examples/website`) consumes the workspace-linked just-bash. Its `next.config.ts` `serverExternalPackages` array exists specifically to tell Turbopack **not** to bundle the packages that just-bash externalizes:

```ts
serverExternalPackages: [
  "just-bash",
  // just-bash externalizes these in its own bundle; mark them external
  // here so turbopack doesn't try to bundle the native binaries when it
  // resolves the workspace-linked just-bash.
  "@mongodb-js/zstd",
  "node-liblzma",
  "seek-bzip",
  "sql.js",
  "quickjs-emscripten",
],
```

`guarded-fetch` is a newly-externalized dependency but was **not** added to this list. Turbopack therefore attempts to bundle it while resolving just-bash, hits its `node:dns/promises` import, and fails the build with:

```
the chunking context (unknown) does not support external modules (request: node:dns/promises)
```

### Trigger
A Vercel/Next build of `examples/website` after this PR: Turbopack resolves the externalized `import("guarded-fetch")` from just-bash's bundle, tries to bundle `guarded-fetch`, and errors on the `node:dns/promises` builtin import.

## Fix

Add `"guarded-fetch"` to `serverExternalPackages` in `examples/website/next.config.ts`, matching the established pattern for the other packages just-bash externalizes. This makes Turbopack leave `guarded-fetch` as an external Node module (loaded at runtime), so it never tries to bundle its `node:dns/promises` import.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: caleb-vercel <caleb.an@vercel.com>

* fix(network): address guarded-fetch migration review findings

The browser build folded guarded-fetch out by assigning an already-rejected
module-scope promise. Importing the bundle could emit an unhandled rejection,
and every request awaited that promise, so browser networking was dead rather
than degraded — before the migration the unpinned branch called ambient fetch.
The browser now keeps that ambient path and fails closed with the old
"DNS pinning unavailable" message when denyPrivateRanges is requested.

Redirect method rewriting turned every non-HEAD 301/302 into GET, dropping the
method and body of PUT/PATCH/DELETE. Follow the fetch standard (and curl):
301/302 rewrite POST only, 303 rewrites all but GET/HEAD, 307/308 preserve
both. The rewritten GET is re-checked against allowedMethods so a POST-only
policy cannot silently issue it.

The transport no longer routes through globalThis.fetch when pinning is
promised: a host wrapper can rebuild the init object and drop guarded-fetch's
non-standard dispatcher, reopening the DNS-rebinding window with no error.
With denyPrivateRanges on, guarded-fetch's own undici fetch is used; with it
off, the ambient fetch stays. Tests inject the new internal _fetch instead.

_dnsResolve and _createConnectionOwner now throw rather than being accepted
and dropped, and a new banned pattern catches ambient-fetch references that
the raw-fetch rule could not see through the leading dot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(network): cover the guarded path, browser build, and redirect methods

The migration dropped the resolve-then-reject-private tests along with the
_dnsResolve hook they used, leaving the DNS-rebinding defense with no coverage
of its own wiring. dns-guarded-path.test.ts drives guarded-fetch's resolver by
mocking node:dns/promises (guarded-fetch is inlined so the mock reaches it) and
injecting the transport, so nothing leaves the process: private resolutions per
range, fail-closed on resolution failure and empty answers, allow-listed hosts
still resolved, per-hop re-resolution, and no resolution when enforcement is
off.

browser-build.test.ts bundles the module the way build:browser does and pins
the branch a Node test cannot reach: no guarded-fetch import, no unhandled
rejection on import, ambient fetch still serving requests, allow-list still
enforced, fail-closed under denyPrivateRanges.

The shared adapter's transport injection had become an identity function, so
suites that mock global fetch would have reached the real network on the
enforcing path; restore it via _fetch. The e2e full-internet case now guards
its public-host half behind a DNS-availability check instead of depending on a
third party's record, and the integration suite asserts the exact
resolution-failure message again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct the guarded-fetch changeset

It claimed a minimumReleaseAgeExclude entry in pnpm-workspace.yaml that no
longer exists, and described the pinning, redirect, browser, and deprecated-hook
behavior as it stood before the review fixes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: document the externals a consumer must mark when re-bundling

just-bash's lib bundles externalize six dependencies, but nothing told
consumers which. The knowledge lived only in a comment in the website's
next.config.ts, which is why externalizing guarded-fetch broke that build and
was fixed there rather than documented.

guarded-fetch is the first externalized dependency that imports node:
builtins, so it is the first that a bundler targeting a non-Node chunking
context cannot inline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test): apply the guarded-fetch inline setting to the unit config

test:unit runs its own vitest config, which still externalized guarded-fetch,
so the node:dns/promises mock never reached it and all nine guarded-path cases
resolved real hostnames instead — failing on NXDOMAIN rather than on the
behavior under test.

The file now probes once in beforeAll and throws a message naming the missing
setting, so a config that omits it fails loudly instead of quietly dropping
coverage of the DNS-rebinding defense.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(security): make a thrown host-runtime probe name itself

The probe loop aborted on the first throwing spawnSync, so a failure showed up
as six probes of nine and no MARKER line — a stdout diff that says nothing
about what went wrong. Each probe now reports `<name>:threw=<message>` and the
loop continues, and stderr/exitCode are asserted before stdout so a js-exec
level failure (deadline exceeded, worker torn down) names itself instead of
surfacing as truncated output.

Every existing assertion is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: condense the comments added by the review fixes

Comment share of the added code drops from 23% to 17%, in line with the
surrounding modules. fetch.ts was the outlier at 38%: several six- and
seven-line blocks restated what the code says. The reasons a reviewer needs —
why the pinned path avoids the ambient fetch, why the deprecated hooks throw,
why the browser fails closed — are kept, at two or three lines each.

The changeset drops from 113 lines to 63.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
C
Caleb An committed
63cd01319691db61d4f239335c58940257c1f864
Parent: a021f95
Committed by GitHub <noreply@github.com> on 8/25/2026, 1:51:07 PM