Outbound requests: pinning rewrites the URL to the address, so relative and same-host redirects break #363
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
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#363
Loading…
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?
Found on 2026-09-29 in a read-only review of postulo at
95348908d, reading the request hooks inplugins/http.pyand following redirects through them with anhttpx.MockTransport.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atplugins/http.py:101-113(_pin),:57-80(_guard) and:116-136(_public_only),plugins/fetching.py:192and:206(fetch_page), andapi/api.py:614; the other locations are unchanged._pinconnects to the approved address by rewriting the request's URL to it, keeping the name only in theHostheader and thesni_hostnameextension. httpx builds every redirect from that rewritten request, so the address leaks into whatever comes next. A capture whose page answers with a relative redirect fails or lands on the wrong site, and a connection that is redirected to its own hostname loses its credentials. Neither happens whenPOSTULO_CONNECTIONS_ALLOW_PRIVATEis on, because then the connection client pins nothing.plugins/http.py:99-111(_pin:request.url = request.url.copy_with(host=str(address))), called from_guard(:55-78) and_public_only(:114-134);plugins/fetching.py:237(fetch_page:current = validate_public_url(str(response.next_request.url))) and:251(FetchedPage(url=current, ...)).fetch_page("https://jobs.example.org/jobs/1"), answered with301 Location: /jobs/1-engineer, makes a second request tohttps://93.184.216.34/jobs/1-engineerwithHost: 93.184.216.34and no SNI name, becausenext_request.urlwas resolved against the rewritten URL andclient.get(current)builds a fresh request from it. Over HTTPS the certificate is checked against the number and fails; over HTTP the server's default virtual host answers. When it does succeed,FetchedPage.url, and soCapture.url(jobs/slow.py:60and:74,api/api.py:612), is the address rather than the listing's link. Slug and trailing-slash redirects of this kind are common on job boards.http.client(auth=("me", "pw")),PROPFIND https://cloud.example.org/.well-known/carddavanswered with an absolute301tohttps://cloud.example.org/remote.php/dav/is followed withoutAuthorization, because httpx compares the new URL with the rewritten one, sees another origin and strips the credentials; the server answers 401. With private destinations allowed the same exchange answers 207.postulo-dav's discovery (src/postulo_dav/dav.py:126-137) starts from exactly this path.Host, SNI and redirects on the name and opens the socket to the approved address. Short of that, infetch_pageresolveLocationagainstcurrentwithurljoininstead of readingnext_request.url, and in_guard/_public_onlyrestore the name on the response's request before httpx builds the redirect.Done when
tests/security/test_outbound.pyhas a capture test with a relative301that expects the second request to carry the hostname inHostand SNI andFetchedPage.urlto be the hostname URL, and aclient(auth=...)test with an absolute same-host redirect that expectsAuthorizationon the second request.Related to #215 (closed), whose pinning in the connection client brought the second case with it.
Half of this landed with #321, in
317a3fa1e.Hostand SNI, and the capture keeps the site's address.tests/security/test_outbound.py::test_a_redirect_is_followed_by_name_and_its_body_is_never_readholds it with a 301. Logo redirects are followed by hand in the same way.http.client(auth=…)on an absolute same-host 301 still dropsAuthorization. The #321 fixer probed it and got 401, with the second request sent tohttps://93.184.216.34/remote.php/dav/and noAuthorization. That needs the pin moved below the request, and no test covers it yet.So this issue stays open for the connection half.