Skip to content

SafeFetch's DNS-rebinding connection pin is a silent no-op under Bun #789

Description

@sroussey

SafeFetch.server.ts documents four protections in its header, the fourth being:

  1. Connection pinning via an undici Agent whose connect.lookup hook returns the pre-resolved IP — this prevents a second DNS lookup at connect time and defeats DNS rebinding (TOCTOU).

Under Bun that protection does nothing. Bun resolves undici to its own built-in compatibility shim, whose Agent is an inert stub: the dispatcher option passed to undiciFetch is ignored outright, so the request goes through Bun's ordinary fetch and the OS resolver runs again at connect time — exactly the second lookup the pin exists to prevent.

This matters because it is runtime-dependent and passes review either way: the protection is genuine under Node, and @workglow/sec ships a #!/usr/bin/env bun CLI.

Reproducer

Pin connect.lookup at a black-hole address. If the dispatcher were honored the request could not reach 127.0.0.1.

import { Agent, fetch as undiciFetch } from "undici";

const server = Bun.serve({ port: 0, fetch: () => new Response("served") });
const dispatcher = new Agent({
  connect: { lookup: (_h, _o, cb) => cb(null, "192.0.2.1", 4) },
});

const res = await undiciFetch(`http://localhost:${server.port}/`, {
  dispatcher,
  redirect: "manual",
  signal: AbortSignal.timeout(3000),
});
console.log(res.status, await res.text());
bun  (1.3.11): status: 200 body: served   => dispatcher IGNORED
node (22.22.2): failed => dispatcher honored: fetch failed

What Bun's Agent actually is:

own props:   ["_events","_eventsCount","_maxListeners"]
proto props: ["constructor"]

An EventEmitter with a constructor and nothing else — no close, no dispatch, no connection pool.

Related: the closeAgent comment is wrong in the same way

/**
 * Close an undici {@link Agent} if it exposes a `close` method.
 * Bun can load undici in a way where `Agent` instances lack `close` on the
 * prototype ...
 */
function closeAgent(dispatcher: Agent): void {
  void dispatcher.close?.().catch(() => {});
}

Under Bun close is not merely sometimes absent, it is always absent, so closeAgent is unconditionally a no-op there. That is currently harmless — the stub holds no sockets, so there is nothing to free — and I want to be explicit that this is not a leak, because it is easy to read it as one:

  • 5,000 un-closed Agents driven through the real fetch path on Bun 1.3.11: RSS 40MB → 57MB (the step is first-1000 warmup), heapUsed flat at 1–2MB across the run and after a forced GC.
  • Descriptor count tracks peak concurrent requests (~2 per in-flight request), is flat across repeated rounds at fixed concurrency, and returns to baseline after ~20s idle. That is Bun's own fetch pool, not the Agents.

So the bug here is the security no-op, not resource retention. The comment should say the Bun path never closes and never needs to, rather than implying a best-effort cleanup that sometimes succeeds.

Suggested direction

Not obviously a small fix, since the pin is the mechanism:

  1. Detect at load whether dispatcher is honored (the probe above is cheap and deterministic) and, when it is not, either fall back to a re-validating lookup or refuse to register serverSafeFetch as providing rebinding protection — the important part being that the failure is stated rather than silent.
  2. Alternatively resolve and connect to the pinned IP directly with Host/SNI preserved, which does not depend on dispatcher support.

Happy to take this if you want a direction picked.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions