SafeFetch.server.ts documents four protections in its header, the fourth being:
- 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:
- 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.
- 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.
SafeFetch.server.tsdocuments four protections in its header, the fourth being:Under Bun that protection does nothing. Bun resolves
undicito its own built-in compatibility shim, whoseAgentis an inert stub: thedispatcheroption passed toundiciFetchis 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/secships a#!/usr/bin/env bunCLI.Reproducer
Pin
connect.lookupat a black-hole address. If the dispatcher were honored the request could not reach127.0.0.1.What Bun's
Agentactually is:An EventEmitter with a constructor and nothing else — no
close, nodispatch, no connection pool.Related: the
closeAgentcomment is wrong in the same wayUnder Bun
closeis not merely sometimes absent, it is always absent, socloseAgentis 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:heapUsedflat at 1–2MB across the run and after a forced GC.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:
dispatcheris honored (the probe above is cheap and deterministic) and, when it is not, either fall back to a re-validating lookup or refuse to registerserverSafeFetchas providing rebinding protection — the important part being that the failure is stated rather than silent.Host/SNI preserved, which does not depend on dispatcher support.Happy to take this if you want a direction picked.