Outbound requests: the connection client does not pin what it checked, and public-by-nature fetches use it anyway #215

Closed
opened 2026-09-15 21:23:19 +00:00 by tiagoagueda · 0 comments
Owner

Rule 5 of docs/THREAT-MODEL.md promises that every outbound request connects to the address that was checked. Only public_only_client keeps that promise today (pinning added in 04ecab6ee). The connection client, and several fetches that are public by nature but go through it, do not.

Found in the 2026-09-15 code audit.

What is wrong

  • The connection client does not pin (DNS rebinding). _guard (plugins/http.py:47-48) calls check_destination, which resolves the name and discards the answer; httpx then resolves it again to connect. A hostname can answer publicly for the check and 127.0.0.1 or 169.254.169.254 a moment later. Everything built on it is exposed: every connected plugin, webpush.py, logos.py, consent.py:238, catalogue.py:228,277.
  • Logo fetching obeys the connection setting. jobs/logos.py:102-106 validates the URL once, then fetches with http.client(), which allows private addresses when POSTULO_CONNECTIONS_ALLOW_PRIVATE=true and follows up to 3 redirects. find_on_website (logos.py:283-301) fetches up to six image URLs taken from the company's own page. A hostile website can send og:image → 302 → http://192.168.1.50/snapshot.jpg, and that image is stored and shown. Non-image replies still reveal the status code and content type, which is enough to probe the network.
    • The project already decided this elsewhere: resume/links.py:68 uses public_only_client, because the private switch "is about connections, not about a portfolio" (tests/test_links_and_letters.py:481-487).
  • robots_allow defaults to an unguarded client. plugins/fetching.py:160-161 builds a bare httpx.Client(follow_redirects=True) when no client is passed. Its only caller passes the guarded one, but the function is public in a plugin-facing module.
  • Non-HTTP plugins have no guard to use. core/destinations.py (approve, private_allowed, the pinned SMTP classes) already solves this for SMTP but is not on postulo.plugins.api. postulo-imap therefore dials raw sockets; it has its own issue, postulo/postulo-imap#1.

Proposal

  • In _guard, when private destinations are not allowed, resolve with public_addresses_for and pin with _pin, exactly as _public_only does.
  • logos.download and find_on_website: use http.public_only_client(timeout=TIMEOUT) and drop the separate check.
  • robots_allow: make client required, or default to public_only_client().
  • Put a "guarded socket" helper on the plugin surface: resolve and approve a host and port under the instance's policy, return the approved address, and keep the typed name for SNI and certificate checks. Document it in Writing a plugin.
  • tests/security/test_outbound.py covering capture, logos, links, Gravatar and push, each with a redirect to a private address (switch on and off) and a rebinding stub.
Rule 5 of `docs/THREAT-MODEL.md` promises that every outbound request connects to the address that was checked. Only `public_only_client` keeps that promise today (pinning added in 04ecab6ee). The connection client, and several fetches that are public by nature but go through it, do not. Found in the 2026-09-15 code audit. ## What is wrong - **The connection client does not pin (DNS rebinding).** `_guard` (`plugins/http.py:47-48`) calls `check_destination`, which resolves the name and discards the answer; httpx then resolves it again to connect. A hostname can answer publicly for the check and `127.0.0.1` or `169.254.169.254` a moment later. Everything built on it is exposed: every connected plugin, `webpush.py`, `logos.py`, `consent.py:238`, `catalogue.py:228,277`. - **Logo fetching obeys the connection setting.** `jobs/logos.py:102-106` validates the URL once, then fetches with `http.client()`, which allows private addresses when `POSTULO_CONNECTIONS_ALLOW_PRIVATE=true` and follows up to 3 redirects. `find_on_website` (`logos.py:283-301`) fetches up to six image URLs taken from the company's own page. A hostile website can send `og:image` → `302` → `http://192.168.1.50/snapshot.jpg`, and that image is stored and shown. Non-image replies still reveal the status code and content type, which is enough to probe the network. - The project already decided this elsewhere: `resume/links.py:68` uses `public_only_client`, because the private switch "is about connections, not about a portfolio" (`tests/test_links_and_letters.py:481-487`). - **`robots_allow` defaults to an unguarded client.** `plugins/fetching.py:160-161` builds a bare `httpx.Client(follow_redirects=True)` when no client is passed. Its only caller passes the guarded one, but the function is public in a plugin-facing module. - **Non-HTTP plugins have no guard to use.** `core/destinations.py` (`approve`, `private_allowed`, the pinned SMTP classes) already solves this for SMTP but is not on `postulo.plugins.api`. `postulo-imap` therefore dials raw sockets; it has its own issue, postulo/postulo-imap#1. ## Proposal - In `_guard`, when private destinations are not allowed, resolve with `public_addresses_for` and pin with `_pin`, exactly as `_public_only` does. - `logos.download` and `find_on_website`: use `http.public_only_client(timeout=TIMEOUT)` and drop the separate check. - `robots_allow`: make `client` required, or default to `public_only_client()`. - Put a "guarded socket" helper on the plugin surface: resolve and approve a host and port under the instance's policy, return the approved address, and keep the typed name for SNI and certificate checks. Document it in *Writing a plugin*. - `tests/security/test_outbound.py` covering capture, logos, links, Gravatar and push, each with a redirect to a private address (switch on and off) and a rebinding stub.
tiagoagueda added this to the 0.3.0 milestone 2026-09-15 21:33:17 +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#215
No description provided.