fix(security): P0-5 SSRF-safe webfetch — resolve-validate-pin transport, robots + streaming#104
Merged
Merged
Conversation
… post-fetch recheck (P0-5) Also wire PinnedTransport into get_or_create_fetcher (webfetch_tool.py) so the runtime factory passes the now-required transport kwarg to ContentFetcher. Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv
… test hygiene (P0-5 review) Whole-branch review: PinnedTransport.aclose() no longer tears down the shared inner pool (fixes concurrent web_fetch fragility); + no-DNS guard on validate() and a factory-test cache teardown. Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes P0-5 (Critical/High) from the 2026-07-10 comprehensive review: the
web_fetchpath was exposed to SSRF. The validator resolved a single IPv4 viagethostbynameand httpx re-resolved the hostname at connect time (a validate→connect TOCTOU / DNS-rebinding window); AAAA and several non-global ranges were unchecked; therobots.txtfetch was a completely unvalidated request; and response bodies were read whole before any size cap. This PR centralizes transport with resolve → validate → pin semantics so the address validated is the address connected to, and every request (initial fetch, each redirect hop, robots) is pinned and validated. 7 commits; offline suite1969 passed, 0 failed.What's fixed
Validator (
webfetch/validator.py).resolve_and_validate(host, port)resolves every A+AAAA viagetaddrinfoand requires each to be globally routable —ipaddress.is_globalplus a supplementary deny-list for the two SSRF-risky rangesis_globalmarks global (NAT6464:ff9b::/96, 6to4-relay192.88.99.0/24) and an IPv4-mapped-IPv6 embedded-v4 check. It rejects the whole host if any resolved IP is unsafe (defeating split-horizon / rebinding).validate(url)now does scheme / blocked-domain / IP-literal policy only (no DNS). NewBlockedAddressError(httpx.RequestError).PinnedTransport(webfetch/transport.py, new). A composition wrapper that, per request, resolves+validates the host and rewrites the request to connect to the validated pinned IP while preserving theHostheader and settingsni_hostnameto the original host — so TLS certificate verification stays bound to the hostname, not the IP (verified against httpcore: it setsserver_hostname = sni_hostname, so the pin fails closed if ever dropped). Because it sits at the transport layer, the initial fetch, every manually-followed redirect hop, and the robots fetch are pinned+validated by construction.Fetcher (
webfetch/fetcher.py). Injects the shared transport, streams the body under a hard cap (max_content_bytes/max_pdf_bytes, buffer never grows unbounded), and removes the racy post-fetchgethostbynamere-check (the transport now guarantees the connected IP was validated). The manual redirect loop, cross-host-redirect blocking, and per-hop Location + robots revalidation are preserved.Robots (
webfetch/robots.py). Fetchesrobots.txtthrough the same pinned transport (closing the unvalidated-request hole), with a capped body; permissive-on-error behavior unchanged.Factory (
webfetch_tool.py).get_or_create_fetcherbuilds one validator + onePinnedTransportand shares it across the fetcher and robots.PinnedTransport.aclose()is a no-op so a per-callAsyncClient's close doesn't tear down the shared pool (fixes a concurrent-web_fetchfragility; also enables keep-alive).Verification
1969 passed, 1 skipped, 26 xfailed, 0 failed. All new/migrated tests are fully offline —socket.getaddrinfois monkeypatched and anhttpx.MockTransportis thePinnedTransportinner, so no real DNS or sockets. The migration replacedpatch("httpx.AsyncClient.get")mocks (which bypassed the transport) with the real pinned path, asserting pinning actually happens (url.host == IP,Host == original,sni == original).Not in scope (separate cycles / deferred)
http.readcapability split for fetch vs. search; built-in secret-path read denials).web_searchstill uses a bare client, but its URL is the fixed Tavily/Brave API endpoint (not agent-controlled), so it is not the P0-5 SSRF vector.This closes the last Tier-3 Critical — with P0-1, P0-2, and P0-5 done, the Tier-3 arc from the 2026-07-10 review is complete.
https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv