Checking a portfolio link follows a redirect onto a private address, and reports what it found #57

Closed
opened 2026-09-06 16:05:52 +00:00 by tiagoagueda · 0 comments
Owner

What happens

resume/links.py validates the address a person saved, and then makes the request with a
plain client that follows redirects on its own:

validate_public_url(url)          # the address they saved: checked
with httpx.Client(timeout=TIMEOUT, follow_redirects=True, max_redirects=3) as client:
    response = client.head(url, headers=headers)

Nothing checks where a redirect goes. A public host answering 302 Location: http://127.0.0.1:9000/ sends the request there, and the status that comes back is written
onto the link and shown in the interface.

Demonstrated against the real code with a mock transport, pressing Check on a link at a
public host that redirects inwards:

=== every address the link checker actually requested
    https://portfolio.example.org/
    http://127.0.0.1:9999/admin/
  reached a private address: True
  recorded status: ok | Answered 200.

The same redirect through the logo fetcher, which uses the guarded client, is refused:

  reached a private address: False
  outcome: UnusableLogo: Could not be fetched: DestinationRefused: ...

Why it matters

Postulo is self-hosted, which usually means it sits beside a router's administration page,
a NAS, a hypervisor and whatever else is on that network. This turns Check into a port
scanner for that network whose results are displayed: "Answered 200" and "Answered 401"
distinguish a service that exists from one that does not, and a GET follows the HEAD
whenever the first answer is 401, 403, 405, 501 or a 5xx — so it is not even limited to
requests without side effects.

Only somebody who can sign in can reach it, which is the one thing keeping this from being
worse. On a multi-person instance, every account holder has it.

The file says otherwise

The module docstring already states the intended behaviour:

one request per link, when a person presses it, through the same guarded client capture
uses
— public addresses only, a short timeout, no redirects onto somebody's router.

The design was right and the code does not do it. That is the whole bug.

Fix

Use postulo.plugins.http.client(). It attaches check_destination as a request event
hook, so every request the client makes is checked, redirects included — which is
exactly why it exists, and why the logo fetcher is not affected.

Then a test with a mock transport that redirects to a private address, asserting the
request is never made — the same shape as the one that demonstrated this.

Classification

Bug, security. Not breaking.

## What happens `resume/links.py` validates the address a person saved, and then makes the request with a plain client that follows redirects on its own: ```python validate_public_url(url) # the address they saved: checked with httpx.Client(timeout=TIMEOUT, follow_redirects=True, max_redirects=3) as client: response = client.head(url, headers=headers) ``` Nothing checks where a redirect goes. A public host answering `302 Location: http://127.0.0.1:9000/` sends the request there, and the status that comes back is written onto the link and shown in the interface. Demonstrated against the real code with a mock transport, pressing *Check* on a link at a public host that redirects inwards: ``` === every address the link checker actually requested https://portfolio.example.org/ http://127.0.0.1:9999/admin/ reached a private address: True recorded status: ok | Answered 200. ``` The same redirect through the logo fetcher, which uses the guarded client, is refused: ``` reached a private address: False outcome: UnusableLogo: Could not be fetched: DestinationRefused: ... ``` ## Why it matters Postulo is self-hosted, which usually means it sits beside a router's administration page, a NAS, a hypervisor and whatever else is on that network. This turns *Check* into a port scanner for that network whose results are displayed: "Answered 200" and "Answered 401" distinguish a service that exists from one that does not, and a `GET` follows the `HEAD` whenever the first answer is 401, 403, 405, 501 or a 5xx — so it is not even limited to requests without side effects. Only somebody who can sign in can reach it, which is the one thing keeping this from being worse. On a multi-person instance, every account holder has it. ## The file says otherwise The module docstring already states the intended behaviour: > one request per link, when a person presses it, **through the same guarded client capture > uses** — public addresses only, a short timeout, no redirects onto somebody's router. The design was right and the code does not do it. That is the whole bug. ## Fix Use `postulo.plugins.http.client()`. It attaches `check_destination` as a request event hook, so **every** request the client makes is checked, redirects included — which is exactly why it exists, and why the logo fetcher is not affected. Then a test with a mock transport that redirects to a private address, asserting the request is never made — the same shape as the one that demonstrated this. ## Classification Bug, security. Not breaking.
tiagoagueda added this to the 0.2.0 milestone 2026-09-06 16:05:52 +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#57
No description provided.