SEC2-012: A capture refusal tells a token holder whether a name resolves to a private address #954

Closed
opened 2026-10-17 10:38:09 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo at 56fc9d531, 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 a CaptureError)
  • src/postulo/plugins/fetching.py:285-286 (UnsafeURL(str(exc)) from a DestinationRefused)
  • 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 /captures and POST /captures/preview pass the fetcher's sentence straight into the
422 detail. The address check has two sentences: one for a name with no address ("could not
be 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 therefore
learns, 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:1490
calls 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.py and destinations.py alike, keeping the distinction
in the log at INFO for the operator. The tests that assert on either sentence for the capture
path change with it (tests/security/test_outbound.py, tests/test_capture.py,
tests/test_capture_api.py). Done when a test in tests/security/test_outbound.py asserts the
two refusals answer the same detail on POST /captures and the same message on the form.

#779 and #780 (closed) - the neighbouring outbound-fetching fixes; neither covers the wording.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, 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 a `CaptureError`) - `src/postulo/plugins/fetching.py:285-286` (`UnsafeURL(str(exc))` from a `DestinationRefused`) - `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 /captures` and `POST /captures/preview` pass the fetcher's sentence straight into the 422 `detail`. The address check has two sentences: one for a name with no address ("could not be 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 therefore learns, 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:1490` calls 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.py` and `destinations.py` alike, keeping the distinction in the log at `INFO` for the operator. The tests that assert on either sentence for the capture path change with it (`tests/security/test_outbound.py`, `tests/test_capture.py`, `tests/test_capture_api.py`). Done when a test in `tests/security/test_outbound.py` asserts the two refusals answer the same `detail` on `POST /captures` and the same message on the form. ## Related #779 and #780 (closed) - the neighbouring outbound-fetching fixes; neither covers the wording.
tiagoagueda added this to the 1.0.0 milestone 2026-10-17 10:38:11 +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#954
No description provided.