PST-052: Outbound responses are read with no size cap or total deadline, and name resolution has no timeout #781

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-052. 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.

Related to #365.


  • Severity: Low
  • Category: resource-exhaustion / unbounded-response
  • Location: src/postulo/plugins/webhook/__init__.py (249-250); src/postulo/resume/links.py (91-105); src/postulo/plugins/browser/webpush.py (240-241); src/postulo/plugins/public_addresses.py (44-50)
  • Found by: SSRF and outbound traffic, completeness review: shipped plugins
  • Related: PST-051 (#780), PST-053 (#782)

Who can do it, and what they need. An authenticated user who creates a webhook connection pointing at a server they control (public address; no operator switch needed). Precondition: an account that may use the webhook notifier (shipped, on by default).

How it works.

  1. Create a webhook connection with url=https://attacker.example/hook.
  2. Press Test (rate: POSTULO_CONNECTION_TEST_RATE, 10/h) -> post() calls http.post(...) on client(); httpx reads the entire body into memory (client.post is non-streaming) and inflates Content-Encoding: gzip/deflate whole (httpx default Accept-Encoding; the lock file also installs brotli, so brotli bombs work too). A 10 MB gzip stream inflating to ~10 GB, or a chunked body with no end, grows the gunicorn worker until it is OOM-killed or until gunicorn's timeout; client() is not built with bounded=True and nothing opens http.until, so a body dribbled a byte every 9 s is never cut off by the 10 s per-wait timeout.
  3. The same plugin.post is called from the scheduler for every delivery (src/postulo/notifications/webhooks.py:157), so the attacker's receiver can do this on every event the account produces, with 8 retries each; the scheduler process is shared by every account on the instance.

Impact. Denial of service of the scheduler (reminders, deliveries, copies for every account) or of a web worker, by one authenticated account. No cross-account data exposure. The threat model treats DoS as out of scope for a personal instance, but this is a per-account lever on shared instances and the project already bounded the equivalent public-only paths (#321).

Evidence.

src/postulo/plugins/webhook/__init__.py:249 `with client(timeout=TIMEOUT, follow_redirects=False) as http:`
src/postulo/plugins/webhook/__init__.py:250 `return http.post(url, content=body.encode("utf-8"), headers=headers)`
src/postulo/notifications/webhooks.py:157 `response = plugin.post(url, secret, row.body, event=row.event, delivery=str(row.pk))`
src/postulo/plugins/http.py:301-307 `client()` calls `_build(_guard, timeout, kwargs)` without `bounded=True`; only `public_only_client` (line 310) gets `_hold_to_deadlines`.

Verification (verdict: confirmed; severity after verification: Low). Keep low (availability only; threat model puts DoS against a personal instance out of scope), though on a multi-account instance the scheduler stall is indefinite and shared, which is the strongest form of it. Fix the premise: brotli is installed (uv.lock), so br bombs also work. Cited lines are correct (webhook/init.py:249-250; http.py:301-307; notifications/webhooks.py:157). Remediation: stream the webhook response (client.stream), read at most a few KB with http.read_body-style limits, advertise Accept-Encoding: identity, and wrap the delivery in http.until(deadline), or build the connection client with bounded=True.

Suggested remediation. In webhook.post, use client.stream("POST", ...) and read nothing of the body (only the status and the Location header are used), or read it through http.read_body with a small limit; open an http.until() deadline around the request and build the client bounded. Consider making client() bounded by default and offering read_body to plugins as the only way to take a body.

Also covered by this issue.

  • Career-link check lets httpx follow redirects, so a redirect's body is read whole with no size cap and no deadline (src/postulo/resume/links.py (91-105); Low). Memory exhaustion or a held gunicorn worker on the instance from one authenticated account (a cross-account availability problem on a shared instance), or a user's own worker OOM when a linked third-party site turns hostile. No confidentiality impact. …
  • Browser-push and webhook sends have no total deadline, no cap on resolved addresses and no cap on connections, and run inline in the single scheduler pass for all accounts (src/postulo/plugins/browser/webpush.py (240-241); Low). One member delays or stops the instance-wide scheduler for the duration. The reminder stamp (notified_at) is written before sending, so reminders later in the loop are late. Availability only, but across accounts. …
  • Name resolution for the guard runs in the request thread with no timeout of its own (src/postulo/plugins/public_addresses.py (44-50); Informational). Short, bounded worker stalls; limited by the fetch and test rates. Noted for completeness since every other wait on the public-only path is cut to a deadline.
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-052**. 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. Related to #365. --- - **Severity:** Low - **Category:** resource-exhaustion / unbounded-response - **Location:** `src/postulo/plugins/webhook/__init__.py` (249-250); `src/postulo/resume/links.py` (91-105); `src/postulo/plugins/browser/webpush.py` (240-241); `src/postulo/plugins/public_addresses.py` (44-50) - **Found by:** SSRF and outbound traffic, completeness review: shipped plugins - **Related:** PST-051 (#780), PST-053 (#782) **Who can do it, and what they need.** An authenticated user who creates a webhook connection pointing at a server they control (public address; no operator switch needed). Precondition: an account that may use the webhook notifier (shipped, on by default). **How it works.** 1. Create a webhook connection with `url=https://attacker.example/hook`. 2. Press Test (rate: POSTULO_CONNECTION_TEST_RATE, 10/h) -> `post()` calls `http.post(...)` on `client()`; httpx reads the entire body into memory (`client.post` is non-streaming) and inflates `Content-Encoding: gzip`/`deflate` whole (httpx default Accept-Encoding; the lock file also installs brotli, so brotli bombs work too). A 10 MB gzip stream inflating to ~10 GB, or a chunked body with no end, grows the gunicorn worker until it is OOM-killed or until gunicorn's timeout; `client()` is not built with `bounded=True` and nothing opens `http.until`, so a body dribbled a byte every 9 s is never cut off by the 10 s per-wait timeout. 3. The same `plugin.post` is called from the scheduler for every delivery (src/postulo/notifications/webhooks.py:157), so the attacker's receiver can do this on every event the account produces, with 8 retries each; the scheduler process is shared by every account on the instance. **Impact.** Denial of service of the scheduler (reminders, deliveries, copies for every account) or of a web worker, by one authenticated account. No cross-account data exposure. The threat model treats DoS as out of scope for a personal instance, but this is a per-account lever on shared instances and the project already bounded the equivalent public-only paths (#321). **Evidence.** ```text src/postulo/plugins/webhook/__init__.py:249 `with client(timeout=TIMEOUT, follow_redirects=False) as http:` src/postulo/plugins/webhook/__init__.py:250 `return http.post(url, content=body.encode("utf-8"), headers=headers)` src/postulo/notifications/webhooks.py:157 `response = plugin.post(url, secret, row.body, event=row.event, delivery=str(row.pk))` src/postulo/plugins/http.py:301-307 `client()` calls `_build(_guard, timeout, kwargs)` without `bounded=True`; only `public_only_client` (line 310) gets `_hold_to_deadlines`. ``` **Verification** (verdict: confirmed; severity after verification: Low). Keep low (availability only; threat model puts DoS against a personal instance out of scope), though on a multi-account instance the scheduler stall is indefinite and shared, which is the strongest form of it. Fix the premise: brotli is installed (uv.lock), so br bombs also work. Cited lines are correct (webhook/__init__.py:249-250; http.py:301-307; notifications/webhooks.py:157). Remediation: stream the webhook response (client.stream), read at most a few KB with http.read_body-style limits, advertise Accept-Encoding: identity, and wrap the delivery in http.until(deadline), or build the connection client with bounded=True. **Suggested remediation.** In `webhook.post`, use `client.stream("POST", ...)` and read nothing of the body (only the status and the Location header are used), or read it through `http.read_body` with a small limit; open an `http.until()` deadline around the request and build the client bounded. Consider making `client()` bounded by default and offering `read_body` to plugins as the only way to take a body. **Also covered by this issue.** - **Career-link check lets httpx follow redirects, so a redirect's body is read whole with no size cap and no deadline** (`src/postulo/resume/links.py` (91-105); Low). Memory exhaustion or a held gunicorn worker on the instance from one authenticated account (a cross-account availability problem on a shared instance), or a user's own worker OOM when a linked third-party site turns hostile. No confidentiality impact. … - **Browser-push and webhook sends have no total deadline, no cap on resolved addresses and no cap on connections, and run inline in the single scheduler pass for all accounts** (`src/postulo/plugins/browser/webpush.py` (240-241); Low). One member delays or stops the instance-wide scheduler for the duration. The reminder stamp (notified_at) is written before sending, so reminders later in the loop are late. Availability only, but across accounts. … - **Name resolution for the guard runs in the request thread with no timeout of its own** (`src/postulo/plugins/public_addresses.py` (44-50); Informational). Short, bounded worker stalls; limited by the fetch and test rates. Noted for completeness since every other wait on the public-only path is cut to a deadline.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:53 +00:00
Author
Owner

Fixed in a38923f4d on main.

Fixed in a38923f4d 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#781
No description provided.