Skip to content

fix(egress): pin resolved public IP to close SSRF DNS-rebinding TOCTOU (S-1)#208

Merged
brownjuly2003-code merged 2 commits into
mainfrom
fix/egress-ip-pinning
Jul 19, 2026
Merged

fix(egress): pin resolved public IP to close SSRF DNS-rebinding TOCTOU (S-1)#208
brownjuly2003-code merged 2 commits into
mainfrom
fix/egress-ip-pinning

Conversation

@brownjuly2003-code

Copy link
Copy Markdown
Owner

What

Closes the webhook/alert egress DNS-rebinding TOCTOU (audit S-1, P1). The delivery path validated the target URL and then let httpx re-resolve the same hostname to open the socket, so a low-TTL name that flips public → internal between the check and the connect slipped past the guard and got fetched. Re-validating before each delivery only narrowed the window.

How

  • egress_guard.resolve_public_ip() resolves + validates the host once and returns the approved public IP.
  • pinned_transport() / _PinnedIPTransport (an httpx.AsyncHTTPTransport subclass) connects to that IP literal, so httpx performs no second name lookup — the rebinding window is closed by construction.
  • The original Host header and TLS SNI / certificate hostname are preserved via the sni_hostname request extension, so routing and cert verification still key on the hostname, not the IP.
  • Operator-allowlisted hosts (AGENTFLOW_EGRESS_ALLOWED_HOSTS) stay connected by name (unpinned).
  • Wired at both delivery sites: webhook_dispatcher.py, alerts/escalation.py.

Tests

  • tests/unit/test_egress_guard.py::test_pinned_transport_connects_to_validated_ip_not_rebound — the socket target is the validated public IP with getaddrinfo consulted exactly once (a subsequent rebind flip is never reached); plus resolve / allowlist cases.
  • Local run: 33 egress + 64 webhook/alert unit+integration tests pass; ruff check + ruff format --check clean. Integration URL contracts unchanged (the mock replaces AsyncClient wholesale and still sees the hostname URL).

A live rebinding-harness run (real low-TTL DNS + TLS) belongs to the external pen-test; the mechanism is proven deterministically here.

🤖 Generated with Claude Code

…U (S-1)

The webhook/alert delivery path validated the target URL and then let httpx
re-resolve the same hostname to connect, so a low-TTL name that flips
public->internal between the check and the connect slipped past the guard
(audit S-1, P1). Resolve + validate once via resolve_public_ip() and pin the
connection to that IP literal through _PinnedIPTransport, so httpx performs no
second lookup. The original Host header and TLS SNI/certificate hostname are
preserved via the sni_hostname request extension; allowlisted hosts stay
connected by name (unpinned). Wired at both delivery sites.

Test: test_pinned_transport_connects_to_validated_ip_not_rebound proves the
socket target is the validated public IP with getaddrinfo consulted exactly
once; resolve/allowlist paths covered too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

DORA Metrics

  • Window: last 30 days
  • Branch: main
  • Deployment frequency: 105 total / 24.5 per week
  • Lead time for changes: avg 0.61h / median 0.0h
  • Change failure rate: 74.29% (78/105)
  • MTTR: 20.13h across 5 incident(s)

The transport is only ever constructed with (hostname, pinned_ip), and typing
**kwargs as `object` made the `super().__init__(**kwargs)` splat fail mypy
against AsyncHTTPTransport's typed signature. Construct the base with no args.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@brownjuly2003-code
brownjuly2003-code merged commit 8af0cf7 into main Jul 19, 2026
26 of 27 checks passed
@brownjuly2003-code
brownjuly2003-code deleted the fix/egress-ip-pinning branch July 19, 2026 11:55
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.

2 participants