Capture: a refusal from the pinning hook escapes fetch_page as a 500 instead of a capture error #607
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#607
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 capture fetch path and its callers, and settled by runningfetch_pagewith a resolver that answers the first lookup and fails the second.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atplugins/http.py:116-136(_public_only, raising at line 133),plugins/fetching.py:179-185(the loop offetch_page, with the redirect check at line 192) andapi/api.py:609-616;jobs/slow.pyandcore/errands.pyhave not moved. The address check itself moved toplugins/public_addresses.py(#248), whichfetchingre-exports, and the same run ends in the sameDestinationRefused.fetch_pageresolves the address once invalidate_public_url, and the request hook ofpublic_only_clientresolves it again on every request to pin the connection (the fix for #58 and #215). When that second lookup fails, or answers with an address that is not public, the hook raisesDestinationRefused. That class is a plainException: it is neither aCaptureErrornor anhttpx.HTTPError, so nothing between the hook and the person catches it. The address is still refused, so the security property holds; what is lost is the explanation, and the API answers 500.Where:
plugins/http.py:114-134(_public_only, which raisesDestinationRefused(str(exc))at line 131);plugins/fetching.py:224-230(fetch_page, whose loop catches onlyhttpx.HTTPErroraroundclient.get);api/api.py:607-613(except CaptureErroraroundfetch_page);jobs/slow.py:52-60(fetch_a_capture, the sameexcept CaptureError);core/errands.py:139-149(the errand runner's catch-all).What happens: a capture URL whose name resolves for the first lookup and then fails to resolve (a transient resolver error) or answers with a private address on the next one. Running
fetch_page("https://example.org/job")withsocket.getaddrinfoanswering a public address once and then raisinggaierrorends inpostulo.plugins.http.DestinationRefused: That hostname could not be resolved.rather than in anUnsafeURL. ThroughPOST /api/v1/capturesthat is an unhandled exception and a 500; through the capture errand it is logged with a traceback as an unexpected failure, and the person reads "Something went wrong. The server log has the details." instead of "That hostname could not be resolved." or "That address is on a private or local network".Why it matters: the message the hook carries is already the translated, person-facing sentence from
public_addresses_for, and it is thrown away. An operator sees an error-level traceback for what is an expected refusal, and an API client (the browser extensions) gets a server error with nodetailit can show. The same applies to a redirect hop whose target changes its answer between the check at line 237 and the request that follows.Fix: in
fetch_page, catch the hook's refusal besidehttpx.HTTPErrorand turn it into the capture error it stands for:Keep
DestinationRefusedas it is for the other users ofpublic_only_client(resume/links.py:74,jobs/logos.py:122), which already catch it by name.Done when
tests/test_capture.pypatchespublic_addresses.socket.getaddrinfoto answer a public address on the first call and raisegaierroron the next, stubsrobots_allowto allow, and asserts thatfetch_pageraisesfetching.UnsafeURLwith "could not be resolved"; it fails today withDestinationRefused.