Outbound requests: the connection client does not pin what it checked, and public-by-nature fetches use it anyway #215
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Postulo/postulo#215
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Rule 5 of
docs/THREAT-MODEL.mdpromises that every outbound request connects to the address that was checked. Onlypublic_only_clientkeeps that promise today (pinning added in04ecab6ee). 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
_guard(plugins/http.py:47-48) callscheck_destination, which resolves the name and discards the answer; httpx then resolves it again to connect. A hostname can answer publicly for the check and127.0.0.1or169.254.169.254a moment later. Everything built on it is exposed: every connected plugin,webpush.py,logos.py,consent.py:238,catalogue.py:228,277.jobs/logos.py:102-106validates the URL once, then fetches withhttp.client(), which allows private addresses whenPOSTULO_CONNECTIONS_ALLOW_PRIVATE=trueand 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 sendog: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.resume/links.py:68usespublic_only_client, because the private switch "is about connections, not about a portfolio" (tests/test_links_and_letters.py:481-487).robots_allowdefaults to an unguarded client.plugins/fetching.py:160-161builds a barehttpx.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.core/destinations.py(approve,private_allowed, the pinned SMTP classes) already solves this for SMTP but is not onpostulo.plugins.api.postulo-imaptherefore dials raw sockets; it has its own issue, postulo/postulo-imap#1.Proposal
_guard, when private destinations are not allowed, resolve withpublic_addresses_forand pin with_pin, exactly as_public_onlydoes.logos.downloadandfind_on_website: usehttp.public_only_client(timeout=TIMEOUT)and drop the separate check.robots_allow: makeclientrequired, or default topublic_only_client().tests/security/test_outbound.pycovering capture, logos, links, Gravatar and push, each with a redirect to a private address (switch on and off) and a rebinding stub.