Skip to content

fix(security): pin validated IP to close DNS-rebinding TOCTOU#56

Merged
arberx merged 1 commit into
arberx/sitemap-ssrf-fixfrom
arberx/dns-rebind-pin
Jun 25, 2026
Merged

fix(security): pin validated IP to close DNS-rebinding TOCTOU#56
arberx merged 1 commit into
arberx/sitemap-ssrf-fixfrom
arberx/dns-rebind-pin

Conversation

@arberx

@arberx arberx commented Jun 25, 2026

Copy link
Copy Markdown
Member

Stacked on #55 (base is arberx/sitemap-ssrf-fix, not main) — review/merge that first; GitHub will retarget this to main once it lands.

Closes the DNS-rebinding TOCTOU that survives #55. The guard resolved the host and checked its IPs, then handed the hostname to fetch(), which re-resolved at connect time — so a low-TTL record could answer public during validation and a private IP (127.0.0.1, 169.254.169.254) at connect, on the initial request or any redirect hop. Per-hop re-validation does not help; the validate-then-reconnect gap exists on every hop.

Outbound requests now go through an undici dispatcher whose connect-time DNS lookup returns only public IPs and connects to exactly that address, so the IP validated is the IP connected to. The hostname is still used for TLS SNI and the Host header, so HTTPS cert validation is unchanged; --allow-local keeps the default resolver for the single host you named. Because Node's global fetch rejects an external-undici dispatcher, production uses undici's own fetch; tests that stub globalThis.fetch are deferred to (pinning is a socket-layer concern they never reach) and dedicated tests exercise the real lookup over real sockets.

Adds undici as a dependency and test/fetch-page-dns-pin.test.ts. Verified end-to-end against live sites (HTTPS fetch, an http→https redirect chain, and a real sitemap audit). Bumps to 4.1.3 — no CLI, output, or scoring change.

🤖 Generated with Claude Code

validatePublicRequestTarget resolved the host and checked its IPs, then
handed the hostname to fetch(), which re-resolved at connect time. A
low-TTL DNS record could therefore answer public during validation and a
private IP (127.0.0.1, 169.254.169.254) at connect — on the initial
request or any redirect hop — defeating the guard. Per-hop re-validation
did not help: the same validate-then-reconnect gap exists on every hop.

Outbound requests now go through an undici dispatcher whose connect-time
DNS lookup (validatingLookup) returns ONLY public IPs and connects to
exactly that address, so the IP that was validated is the IP connected to.
The hostname is still used for TLS SNI and the Host header, so HTTPS
certificate validation is unchanged. --allow-local keeps the default
resolver for the single host the user named.

Node's global fetch rejects a userland-undici dispatcher, so production
requests use undici's own fetch; tests that stub globalThis.fetch are
deferred to (pinning is a socket-layer concern they never reach), and
dedicated pin tests exercise the real lookup over real sockets.

Adds undici dependency and test/fetch-page-dns-pin.test.ts. Bumps to
4.1.3. No CLI, output, or scoring change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@arberx arberx merged commit 92ec68d into arberx/sitemap-ssrf-fix Jun 25, 2026
arberx added a commit that referenced this pull request Jun 25, 2026
* fix(security): route sitemap-mode fetches through SSRF guard

In --sitemap mode the runner fetched the sitemap, robots.txt, and every
sitemap-index child <loc> via raw fetch() with auto-follow redirects and no
SSRF guard, letting a malicious target steer the auditing host onto internal
endpoints (cloud metadata at 169.254.169.254, internal services). Every
sitemap / robots / child-<loc> fetch now routes through
fetchWithValidatedRedirects: hostname blocklist + DNS->private-IP rejection
re-checked on every redirect hop, with redirects followed manually.
--allow-local continues to permit only the single host you named (host-only,
per-hop), so a redirect or <loc> to any other private host stays blocked.

Adds test/sitemap-ssrf.test.ts covering internal child <loc> rejection, 302
redirect-to-metadata blocking, and the allow-host opt-in. Bumps to 4.1.2. No
CLI, output, or scoring change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): bound sitemap-index child fan-out (DoS)

resolveSitemapUrls fetched every child <loc> of a sitemap index via
Promise.all with no concurrency limit; the --limit slice only applies after
all children are fetched. A malicious or misconfigured index listing tens of
thousands of children therefore fired that many simultaneous fetches on the
shared runner and accumulated entries unboundedly in memory.

Child sitemaps are now fetched with the existing mapWithConcurrency at
DEFAULT_CONCURRENCY (5) and capped at the first MAX_CHILD_SITEMAPS (1000) per
index. When the cap drops children, resolveSitemapUrls reports the skipped
count via SitemapAuditPlan.childSitemapsSkipped and the CLI prints a notice
(no silent truncation). 1000 children is far more than any audit consumes, so
legitimate sites are unaffected.

Adds test/sitemap-fanout.test.ts (concurrency stays <=5; >1000 children are
capped with the skipped count surfaced). No CLI, output, or scoring change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): pin validated IP to close DNS-rebinding TOCTOU (#56)

validatePublicRequestTarget resolved the host and checked its IPs, then
handed the hostname to fetch(), which re-resolved at connect time. A
low-TTL DNS record could therefore answer public during validation and a
private IP (127.0.0.1, 169.254.169.254) at connect — on the initial
request or any redirect hop — defeating the guard. Per-hop re-validation
did not help: the same validate-then-reconnect gap exists on every hop.

Outbound requests now go through an undici dispatcher whose connect-time
DNS lookup (validatingLookup) returns ONLY public IPs and connects to
exactly that address, so the IP that was validated is the IP connected to.
The hostname is still used for TLS SNI and the Host header, so HTTPS
certificate validation is unchanged. --allow-local keeps the default
resolver for the single host the user named.

Node's global fetch rejects a userland-undici dispatcher, so production
requests use undici's own fetch; tests that stub globalThis.fetch are
deferred to (pinning is a socket-layer concern they never reach), and
dedicated pin tests exercise the real lookup over real sockets.

Adds undici dependency and test/fetch-page-dns-pin.test.ts. Bumps to
4.1.3. No CLI, output, or scoring change.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ci): pin undici to v7 for Node 20 support

undici 8.x requires Node >=22.19.0, but the package targets Node >=20
(engines) and CI tests on Node 20. On Node 20, undici 8.5.0 crashes at
module load ("webidl.util.markAsUncloneable is not a function"), so every
file importing fetch-page failed and the test job went red. A published
4.1.3 would crash for Node 20 users the same way.

Downgrade to undici ^7.22.0 (engines: Node >=20.18.1), which exposes the
same fetch / Agent / connect.lookup API the SSRF DNS-pinning dispatcher
uses. Typecheck, the full vitest suite, and the real-socket pin tests pass.

Also fix test/e2e/cli.test.ts, which the undici crash was masking: it
imported the compiled CLI lazily inside the first test, AFTER installMockFetch
ran. fetch-page.ts captures globalThis.fetch as `builtinFetch` at module load
and only defers to a stub when the live global differs, so capturing the mock
made the guard fall through to a real pinned undici fetch (offline: error;
CI: real 1.1.1.1). Warm the module in a before() hook — before any mock — so
builtinFetch is the real fetch and the per-test mocks are honored. Kept as a
runtime dynamic import since dist/ is a gitignored artifact absent when
typecheck runs (a static import broke typecheck, which runs before build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant