Skip to content

security: pin fetched connections to the vetted address (close DNS rebinding) - #415

Open
coderdailyone wants to merge 1 commit into
andrewyng:mainfrom
coderdailyone:security/pin-web-fetch-connections
Open

security: pin fetched connections to the vetted address (close DNS rebinding)#415
coderdailyone wants to merge 1 commit into
andrewyng:mainfrom
coderdailyone:security/pin-web-fetch-connections

Conversation

@coderdailyone

Copy link
Copy Markdown

coworker/web/guard.py's own docstring stated the gap: "Not covered: DNS rebinding. The name is resolved here and resolved again by the client when it connects… Closing that needs connection-level IP pinning; the hop check is the cheap 90%." This PR is that pinning.

What was broken: get_checked vetted the resolved addresses, then handed the name back to httpx, which resolved it a second time. A ~0-TTL record can answer with a public IP for the check and 127.0.0.1 (or 169.254.169.254) for the connect — and web_fetch is requires_approval=False, so nothing ever prompts.

Fix: every hop now connects to the exact address that passed its check — the request URL carries the vetted IP, Host keeps the original name (and explicit port) for virtual hosting, and the sni_hostname request extension keeps the TLS handshake and certificate verification against the name. Literal-IP URLs are unchanged. Redirect Locations resolve against the logical URL (a relative Location must stay on the original host), and the final logical URL rides resp.extensions["logical_url"] so web_fetch reports the name, not the pinned address. browser_read_url inherits the fix through get_checked.

Verification (terminal evidence in place of screenshots — no UI in this change):

  • tests/test_url_address_guard.py: 34/34, six new tests — pin target + Host + SNI, a rebinding name that flips to loopback after the check, explicit-port Host, IPv6 bracketing, literal passthrough, logical-URL reporting:
$ pytest tests/test_url_address_guard.py -q
34 passed in 1.59s
$ pytest -q
1021 passed, 1 skipped in 115.54s
  • Against a local TLS server whose certificate carries only DNS:pinned.test (no IP SAN): the pinned request (URL=https://127.0.0.1:<port>/, sni_hostname=pinned.test) verifies and returns 200 with the server seeing Host: pinned.test; the identical request without the extension fails with certificate verify failed: IP address mismatch — confirming httpcore verifies certificates against sni_hostname, not the URL host.

Residual, stated in the updated docstring: browser_open_url's pre-check still resolves twice — the browser owns its own connections and cannot be pinned from here.

🤖 Generated with Claude Code

…binding)

guard.get_checked previously resolved a name for the address check and then let
the client resolve it again to connect, so a ~0-TTL record could flip to
127.0.0.1 (or the metadata endpoint) between the two — the exact gap the module
docstring called out as "not covered".

Every hop now connects to the address that passed its check: the request URL
carries the vetted IP, Host keeps the original name (and explicit port) for
virtual hosting, and the sni_hostname extension keeps the TLS handshake — and
certificate verification — against the name. Literal-IP URLs are unchanged.
Redirect Locations are resolved against the logical URL, and the final logical
URL rides resp.extensions["logical_url"] so web_fetch reports the name, not the
pinned address.

Verified against a local TLS server whose cert carries only DNS:pinned.test:
the pinned request (URL=127.0.0.1, sni_hostname=pinned.test) verifies and the
server sees Host: pinned.test; the same request without the extension fails
with "IP address mismatch" — httpcore does verify certificates against
sni_hostname, not the URL host.

Co-Authored-By: Claude Fable 5 <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