PST-051: No port or self-address restriction: members can make the server open TCP connections to any port of any public address #780

Closed
opened 2026-10-10 10:56:24 +00:00 by tiagoagueda · 1 comment
Owner

Found in the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md), run against main at c30851b from 8 to 10 October 2026, as PST-051. Severity: Low.

The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it.


  • Severity: Low
  • Category: ssrf-scope / port-scan
  • Location: src/postulo/plugins/public_addresses.py (58-95); src/postulo/core/mail.py (172-200; 127-132)
  • Found by: SSRF and outbound traffic, completeness review: shipped plugins, completeness review: tests against claims
  • Related: PST-050 (#779), PST-052 (#781)

Who can do it, and what they need. Any authenticated account (capture 30/h, link check or logo 300/h, webhook test 10/h). Precondition: the target is on a publicly routable address, including the instance host's own public IP or an IP-allowlisted upstream service.

How it works.

  1. Submit https://<public-ip>:<port>/ as a career link and press Check: check_detail records ConnectError: [Errno 111] Connection refused vs ReadTimeout vs Answered 200. (links.py:105, 109-113), so each press distinguishes closed, filtered and open ports on any internet host from the instance's egress IP.
  2. Submit http://<host's own public IP>:8000/ (a Docker-published gunicorn, a metrics exporter, a database admin UI bound on 0.0.0.0 but filtered for external sources by an interface-based firewall rule): the connection originates on the host itself and may bypass the external filter.
  3. Through capture (fetch_page, public-only client) the body is parsed and, where kept-sources are on, the full HTML is stored and shown back, which is a read primitive against any HTTP service that allowlists the server's IP. The guard only asks whether the resolved address is globally routable; it never asks whether the address is this instance's own, nor restricts ports.

Impact. The instance becomes a modest port scanner and HTTP read proxy from its own IP for whoever holds an account: reputation/abuse risk for the operator, and a route into services that trust the server's address. Confidentiality impact is environment-dependent; the guard's documented promise (no private addresses) is kept.

Evidence.

src/postulo/plugins/public_addresses.py:81-91 `addresses = _addresses_for(host) ... if not all(is_public(address) for address in addresses): raise PrivateAddress(...)` (no port check, no self check)
src/postulo/resume/links.py:105 `return LinkStatus.BROKEN, f"{type(error).__name__}: {error}"`
src/postulo/plugins/fetching.py:216-218 `_not_fetched`: `str(error)[:200]` is shown to the person
src/postulo/jobs/logos.py:189-192 `except Exception as error: raise UnusableLogo(... % {"error": f"{type(error).__name__}: {error}"})`

Verification (verdict: confirmed; severity after verification: Low). Keep low, framed as 'no port restriction: server can be used to probe open/closed ports on public hosts from the instance's IP (abuse/reputation), bounded by 300/h link-check and 30/h capture limits'. Drop or soften: (a) 'HTTP read proxy' (only text/html responses that parse as a job posting are ever stored or shown; link check returns a status line only); (b) the host's-own-public-IP bypass, which is speculative. Cited lines public_addresses.py:58-95, links.py:105, fetching.py:216-218 are accurate; logos.py:189-192 not independently re-read. Optional hardening: restrict fetches to ports 80/443 (and perhaps common alternates) or make the exception text uniform for connect failures.

Suggested remediation. Restrict public-only fetches to ports 80 and 443 (and perhaps 8080/8443) unless the operator allows others; refuse addresses that belong to the instance itself (resolve ALLOWED_HOSTS / POSTULO_PUBLIC_URL at start-up and add them to the refusal set); collapse low-level transport errors into one neutral sentence in check_detail and the logo/capture messages instead of the exception text.

Also covered by this issue.

  • Outgoing SMTP tests and sends identify the server to any public mail host a member names: smtplib is built with no local_hostname, so EHLO carries the machine's FQDN or its IP literal (src/postulo/core/mail.py (172-200); Informational). Discloses the instance host's internal hostname or private address (container or LAN IP) to a member, which the operator may regard as topology information. The sandbox here reports '[127.0.0.1]'. Public destinations only, so the guard works as designed; this is only what the conversation carries.
  • Own-mail Test reflects the first line a remote service sends, so it banner-grabs any public host and port (SMTP variant of the unrestricted-port finding) (src/postulo/core/mail.py (127-132); Informational). The server can be used to fingerprint services on any public address and port: a connect oracle (already known) plus the first banner line. Public addresses only, so no internal exposure. Slightly more than the open/closed oracle already recorded, and a way to scan from the instance's IP.
Found in the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`), run against `main` at `c30851b` from 8 to 10 October 2026, as **PST-051**. Severity: **Low**. The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it. --- - **Severity:** Low - **Category:** ssrf-scope / port-scan - **Location:** `src/postulo/plugins/public_addresses.py` (58-95); `src/postulo/core/mail.py` (172-200; 127-132) - **Found by:** SSRF and outbound traffic, completeness review: shipped plugins, completeness review: tests against claims - **Related:** PST-050 (#779), PST-052 (#781) **Who can do it, and what they need.** Any authenticated account (capture 30/h, link check or logo 300/h, webhook test 10/h). Precondition: the target is on a publicly routable address, including the instance host's own public IP or an IP-allowlisted upstream service. **How it works.** 1. Submit `https://<public-ip>:<port>/` as a career link and press Check: `check_detail` records `ConnectError: [Errno 111] Connection refused` vs `ReadTimeout` vs `Answered 200.` (links.py:105, 109-113), so each press distinguishes closed, filtered and open ports on any internet host from the instance's egress IP. 2. Submit `http://<host's own public IP>:8000/` (a Docker-published gunicorn, a metrics exporter, a database admin UI bound on 0.0.0.0 but filtered for external sources by an interface-based firewall rule): the connection originates on the host itself and may bypass the external filter. 3. Through capture (`fetch_page`, public-only client) the body is parsed and, where kept-sources are on, the full HTML is stored and shown back, which is a read primitive against any HTTP service that allowlists the server's IP. The guard only asks whether the resolved address is globally routable; it never asks whether the address is this instance's own, nor restricts ports. **Impact.** The instance becomes a modest port scanner and HTTP read proxy from its own IP for whoever holds an account: reputation/abuse risk for the operator, and a route into services that trust the server's address. Confidentiality impact is environment-dependent; the guard's documented promise (no private addresses) is kept. **Evidence.** ```text src/postulo/plugins/public_addresses.py:81-91 `addresses = _addresses_for(host) ... if not all(is_public(address) for address in addresses): raise PrivateAddress(...)` (no port check, no self check) src/postulo/resume/links.py:105 `return LinkStatus.BROKEN, f"{type(error).__name__}: {error}"` src/postulo/plugins/fetching.py:216-218 `_not_fetched`: `str(error)[:200]` is shown to the person src/postulo/jobs/logos.py:189-192 `except Exception as error: raise UnusableLogo(... % {"error": f"{type(error).__name__}: {error}"})` ``` **Verification** (verdict: confirmed; severity after verification: Low). Keep low, framed as 'no port restriction: server can be used to probe open/closed ports on public hosts from the instance's IP (abuse/reputation), bounded by 300/h link-check and 30/h capture limits'. Drop or soften: (a) 'HTTP read proxy' (only text/html responses that parse as a job posting are ever stored or shown; link check returns a status line only); (b) the host's-own-public-IP bypass, which is speculative. Cited lines public_addresses.py:58-95, links.py:105, fetching.py:216-218 are accurate; logos.py:189-192 not independently re-read. Optional hardening: restrict fetches to ports 80/443 (and perhaps common alternates) or make the exception text uniform for connect failures. **Suggested remediation.** Restrict public-only fetches to ports 80 and 443 (and perhaps 8080/8443) unless the operator allows others; refuse addresses that belong to the instance itself (resolve ALLOWED_HOSTS / POSTULO_PUBLIC_URL at start-up and add them to the refusal set); collapse low-level transport errors into one neutral sentence in `check_detail` and the logo/capture messages instead of the exception text. **Also covered by this issue.** - **Outgoing SMTP tests and sends identify the server to any public mail host a member names: smtplib is built with no local_hostname, so EHLO carries the machine's FQDN or its IP literal** (`src/postulo/core/mail.py` (172-200); Informational). Discloses the instance host's internal hostname or private address (container or LAN IP) to a member, which the operator may regard as topology information. The sandbox here reports '[127.0.0.1]'. Public destinations only, so the guard works as designed; this is only what the conversation carries. - **Own-mail Test reflects the first line a remote service sends, so it banner-grabs any public host and port (SMTP variant of the unrestricted-port finding)** (`src/postulo/core/mail.py` (127-132); Informational). The server can be used to fingerprint services on any public address and port: a connect oracle (already known) plus the first banner line. Public addresses only, so no internal exposure. Slightly more than the open/closed oracle already recorded, and a way to scan from the instance's IP.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:52 +00:00
Author
Owner

Fixed in fc348bb57 on main.

Fixed in fc348bb57 on main.
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#780
No description provided.