#19

Resolve each hop once by reusing the preflight DNS answer at connect time

Draftcramforcewants to merge intofrom

guardedFetch now hands the preflight's validated DNS answer to the connect-time lookup instead of resolving the same host again, halving DNS queries per hop.

Stacked on #18.

Why

Every hop resolved its host twice: once in assertUrlIsSafeToFetch and again in the guarded dispatcher's connect.lookup. Both answers come from the URL owner's nameserver, so each request cost twice the DNS work it needed. A slow or hostile nameserver got two chances per hop to stall it, up to 12 with the default 5 redirects. The second resolution also gave a rebinding nameserver a second answer to vary, which the connect-time check exists to catch.

Approach

The connector only sees { hostname, port, ... }, with no per-request context. Passing the validated answer through AsyncLocalStorage around the fetch call should let the connect-time lookup reuse it without changing undici's connector contract.

  • validateFetchTarget (internal) runs the same checks as assertUrlIsSafeToFetch and also returns the resolved addresses. The public function's return shape is unchanged.
  • executeFetch runs the fetch inside withPinnedAddresses(hostname, addresses). Each redirect hop pins its own preflight answer.
  • createGuardedLookup uses the pinned answer when one exists for that hostname, narrowed to the requested family. It still runs every connect-time check on it, and falls back to resolving when nothing usable is pinned.

Why this is safe:

  • The socket connects only to addresses that passed both checks, which is stricter than today, where the connect-time answer could differ from the preflight answer.
  • If undici opens the connection in a different async context, the lookup either resolves on its own (today's behavior) or sees another request's pin. That pin can only be for the same hostname, since pools are per origin, and was validated too.
  • Direct users of createGuardedLookup or the guarded dispatchers are unaffected: nothing is pinned, so they resolve as before.

Validation

  • New src/resolve-once.test.ts runs real HTTP through a fresh guarded dispatcher, with resolveHost mocked and loopback treated as public:
    • one fetch → one resolution (was two)
    • a redirect chain → one resolution per hop
    • plain undici fetch through the same dispatcher still resolves at connect time
    • a pinned private address is still rejected with hostname_unsafe, without resolving
    • unit tests for family narrowing and hostname matching
  • The three behavioral tests fail with the pinned lookup disabled.
  • pnpm validate: typecheck, lint, and format clean; 285 tests passing.

Activity

2
dvoytenkoapproved changes11d ago

This pull request is a draft

Merging is unavailable until it is marked ready for review.