SEC2-012: A capture refusal tells a token holder whether a name resolves to a private address #954
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#954
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-10-16 in a read-only security review of
postuloat56fc9d531, checked by a second, independent pass.Severity: Informational · Category: Data Exposure · Reference: CWE-204 (Observable Response Discrepancy); OWASP A01:2021 (Broken Access Control)
Location:
src/postulo/api/api.py:663(raise HttpError(422, str(exc))for aCaptureError)src/postulo/plugins/fetching.py:285-286(UnsafeURL(str(exc))from aDestinationRefused)src/postulo/plugins/public_addresses.py:62, 120-125(the two sentences of the first lookup)src/postulo/core/destinations.py:168, 191, 196-203(the two sentences of the hook on later requests)src/postulo/jobs/slow.py:56-61(the capture errand passes the same sentence to the form)Issue
POST /capturesandPOST /captures/previewpass the fetcher's sentence straight into the422
detail. The address check has two sentences: one for a name with no address ("could notbe resolved") and one for a name that resolved to a private or local network. The first lookup
(
public_addresses.py) says them one way; the destination hook that runs on later requests(
destinations.py) says them another; both differ between the two cases. A caller thereforelearns, for any hostname, whether the instance's resolver knows it and whether it lives on the
private side: a map of internal names, one question at a time, bounded by the capture allowance
(30/h, spent before the fetch). The web capture form shows the same sentence through the
capture errand. The API is recorded here because it is the door that makes it scriptable.
The project chose this wording deliberately: the test at
tests/security/test_outbound.py:1490calls it "the sentence the person is owed". So this is a trade-off for the owner between telling
the person why and not answering the question for a stranger's hostname.
Impact
A token holder (or any account, on the form) enumerates internal hostnames of the network
the instance sits on. Low value on a personal instance; more on one in an office.
Fix
One sentence for both refusals at the API and the form ("The page could not be fetched from
that address"), in
public_addresses.pyanddestinations.pyalike, keeping the distinctionin the log at
INFOfor the operator. The tests that assert on either sentence for the capturepath change with it (
tests/security/test_outbound.py,tests/test_capture.py,tests/test_capture_api.py). Done when a test intests/security/test_outbound.pyasserts thetwo refusals answer the same
detailonPOST /capturesand the same message on the form.Related
#779 and #780 (closed) - the neighbouring outbound-fetching fixes; neither covers the wording.