fix(probe-endpoint): resolve hostname and check for private addresses - #19
fix(probe-endpoint): resolve hostname and check for private addresses#19ygd58 wants to merge 1 commit into
Conversation
Fixes ProjectOpenSea#13. isPrivateHostname() is a lexical guard — it only inspects the hostname string itself. A hostname that looks public but resolves to a private/internal address (misconfiguration, or an attacker pointing a DNS record at 127.0.0.1 / 169.254.169.254 / an internal service) sailed straight through the check, and the subsequent fetch() connected to whatever the resolver actually returned. Adds isPrivateResolvedAddress(), which resolves the hostname via node:dns/promises and checks every returned A/AAAA record against the same private-range logic already used for literal IPs. probeEndpoint() now applies both checks — the lexical guard and the resolve-and-check — before ever issuing a request. DNS resolution failures are not treated as private; they fall through so fetch() can surface the real network error. A residual TOCTOU window remains between this check and the actual fetch() connect (classic DNS-rebinding), since fetch re-resolves independently. Closing that fully needs a connect-time-pinned request (resolve once, dial the validated IP directly) — left as further hardening, consistent with the comment already in this file. Six regression tests added: single and multi-address private resolution, cloud-metadata address among multiple A/AAAA records, resolution-failure fallthrough, and direct isPrivateResolvedAddress() coverage.
|
Thanks. Recreated in our internal monorepo as PR 639 (private repo, so that number will not resolve from here) and merged, with an extension.
One thing the patch missed. You wired the check into The two paths ended up differing in one respect, from a later review round. The rebinding window in your "Known residual" section is still open, and we are not claiming otherwise. A resolve-and-check does not close it. It still needs the connect-time-pinned dial you and #13 both described. The change syncs out to this mirror on the next release. |
Fixes #13.
Root cause
isPrivateHostname()is a lexical guard: it only inspects the hostname string as written in the URL. A hostname that looks public but resolves to a private/internal address (misconfiguration, or an attacker pointing a DNS record inward, e.g. at169.254.169.254) passes the check, andprobeEndpoint()thenfetch()es whatever the resolver actually returns.Fix
Adds
isPrivateResolvedAddress(), which resolves the hostname vianode:dns/promises(lookup(..., { all: true })) and checks every returned A/AAAA record against the same private-range logic already used for IP literals.probeEndpoint()now applies both the lexical guard and the resolve-and-check before ever issuing a request. DNS resolution failures are not treated as private — they fall through sofetch()surfaces the real network error.Known residual
A narrow TOCTOU window remains between this check and the actual
fetch()connect, sincefetchre-resolves independently (classic DNS rebinding). Fully closing it needs a connect-time-pinned request (resolve once, dial the validated IP directly) — left as further hardening, consistent with the comment already in this file before this change.Testing
6 new regression tests: single/multi-address private resolution, a cloud-metadata address mixed in with a public one, resolution-failure fallthrough, and direct
isPrivateResolvedAddress()coverage.probe-endpoint.test.ts+verify-probe.test.ts: 50/50 passing.Note: this local environment runs Node 18.20.8, under which several unrelated test files (
x402-scheme.test.ts,eip3009-auth.test.ts, etc.) fail withcrypto is not defined— global WebCrypto isn't exposed until Node 20+. Confirmed this is pre-existing and unrelated to this change (same failures occur on a clean checkout ofmainwithout these commits, in files this PR does not touch).