Outbound connections: pinning dials only the first resolved address, so one dead address fails the connection #547

Closed
opened 2026-10-01 10:47:32 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the address pinning in the HTTP guards and in core/destinations.py, and httpcore's connection code. Not reproduced against a real host.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The code is now at plugins/http.py:78 (_guard), plugins/http.py:134 (_public_only) and plugins/http.py:96 (approve_host); core/destinations.py:73 and :85 (approve); core/mail.py:180 (_open) and core/mail.py:240 (GuardedBackend.open).

The guards approve every address a name resolves to and then connect to the first one only. Before pinning (#58, #148, #215), httpcore and smtplib opened sockets with socket.create_connection, which tries each address in turn. Pinning a single address removed that fallback, so a name with one unreachable address now fails whenever that address comes first.

  • Where: plugins/http.py:76 (_guard: _pin(request, addresses[0])) and plugins/http.py:132 (_public_only, the same); core/destinations.py:72 and :84 (approve returns found[0]), used by core/mail.py:190 (_open), GuardedBackend.open and plugins/http.py:94 (approve_host).
  • What happens: the pinned request carries a literal IP, so httpcore's socket.create_connection has one address to try. Two cases meet it. A host whose IPv6 is configured but broken upstream gets the AAAA answer sorted first and times out on every webhook, capture, link check and SMTP connection to a dual-stack name, while the IPv4 address works. A round-robin name with one node down fails on the passes where the resolver lists that node first. The HTTP guard only pins under the default public-only policy (with POSTULO_CONNECTIONS_ALLOW_PRIVATE on, _guard returns early and httpx resolves and falls back as before); destinations.approve pins in both cases.
  • Why it matters: a connection that works from every other client fails from Postulo with a timeout or "connection refused", and nothing in the message says another approved address was never tried.
  • Fix: keep the approved list and try each address in order at connection time, still sending the typed name for SNI and the Host header: in PinnedSMTP._get_socket loop over the approved addresses, and for HTTP use a transport or network backend that receives the approved list (or retry the request over the remaining addresses on httpx.ConnectError). Every address was already approved, so trying the next one opens no gap.

Done when

  • A test in tests/security/test_outbound.py stubs the resolver to answer [unreachable, reachable] for one public name and asserts a request through http.client() and a connection through mail.check_connection both succeed on the second address, with the certificate name still the typed host.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the address pinning in the HTTP guards and in `core/destinations.py`, and httpcore's connection code. Not reproduced against a real host. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The code is now at `plugins/http.py:78` (`_guard`), `plugins/http.py:134` (`_public_only`) and `plugins/http.py:96` (`approve_host`); `core/destinations.py:73` and `:85` (`approve`); `core/mail.py:180` (`_open`) and `core/mail.py:240` (`GuardedBackend.open`). The guards approve every address a name resolves to and then connect to the first one only. Before pinning (#58, #148, #215), httpcore and `smtplib` opened sockets with `socket.create_connection`, which tries each address in turn. Pinning a single address removed that fallback, so a name with one unreachable address now fails whenever that address comes first. - **Where:** `plugins/http.py:76` (`_guard`: `_pin(request, addresses[0])`) and `plugins/http.py:132` (`_public_only`, the same); `core/destinations.py:72` and `:84` (`approve` returns `found[0]`), used by `core/mail.py:190` (`_open`), `GuardedBackend.open` and `plugins/http.py:94` (`approve_host`). - **What happens:** the pinned request carries a literal IP, so httpcore's `socket.create_connection` has one address to try. Two cases meet it. A host whose IPv6 is configured but broken upstream gets the AAAA answer sorted first and times out on every webhook, capture, link check and SMTP connection to a dual-stack name, while the IPv4 address works. A round-robin name with one node down fails on the passes where the resolver lists that node first. The HTTP guard only pins under the default public-only policy (with `POSTULO_CONNECTIONS_ALLOW_PRIVATE` on, `_guard` returns early and httpx resolves and falls back as before); `destinations.approve` pins in both cases. - **Why it matters:** a connection that works from every other client fails from Postulo with a timeout or "connection refused", and nothing in the message says another approved address was never tried. - **Fix:** keep the approved list and try each address in order at connection time, still sending the typed name for SNI and the `Host` header: in `PinnedSMTP._get_socket` loop over the approved addresses, and for HTTP use a transport or network backend that receives the approved list (or retry the request over the remaining addresses on `httpx.ConnectError`). Every address was already approved, so trying the next one opens no gap. ## Done when - A test in `tests/security/test_outbound.py` stubs the resolver to answer `[unreachable, reachable]` for one public name and asserts a request through `http.client()` and a connection through `mail.check_connection` both succeed on the second address, with the certificate name still the typed host.
tiagoagueda added this to the 0.6.0 milestone 2026-10-01 10:47:32 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#547
No description provided.