PST-028: Per-address limits are keyed on the peer address, which is the proxy for everyone until the operator names the proxy #757

Closed
opened 2026-10-10 10:56:04 +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-028. 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: rate-limit / denial-of-service
  • Location: src/postulo/core/throttle.py (186-207); src/postulo/core/proxy.py (43-46)
  • Found by: authentication and sessions, secrets and cryptography
  • Related: PST-005 (#734)

Who can do it, and what they need. Anyone on the internet who can reach the instance through its reverse proxy. Precondition: POSTULO_LOGS_ENDPOINT_ENABLED or POSTULO_METRICS_ENABLED with a token, and a reverse proxy that is not loopback and was not added to POSTULO_TRUSTED_PROXIES (a proxy container on the Compose network, or a proxy on another host - the case .env.example:103-111 describes).

How it works.

  1. TrustedProxyMiddleware strips X-Forwarded-For from the untrusted proxy, so every request arrives with REMOTE_ADDR = the proxy's address.
  2. Attacker sends eleven requests to /logs with Authorization: Bearer wrong. throttle.refused() counts them under postulo:rate:endpoint-refused:logs:<proxy addr>:<window>.
  3. The operator's collector presents the correct token; throttle.barred() runs before the token is read and raises TooOften -> 429 for the rest of the hour. Verified in a scratch test (11 wrong guesses from distinct XFF values behind an un-named proxy, then the right token -> 429).
  4. Repeat every hour.

Impact. Loss of log shipping / metrics scraping (and therefore of alerting) for as long as the attacker cares to keep it up; no data exposure. The same shared-address effect applies to allauth's IP-keyed limits (sign-in, signup, password reset) behind an un-named proxy. The module documents the trade-off, but the lockout rule (#472) was added after that reasoning and turns a shared identity into an outsider-triggerable denial.

Evidence.

throttle.py:189-190: `def _caller(request): return request.META.get("REMOTE_ADDR") or "unknown"`; throttle.py:193-202 `barred()` raises when `used > REFUSED.times`; views_logs.py:116-117: `throttle.barred("logs", request)` runs before `_authorised(request)`; proxy.py:149-151 pops every forwarding header for an untrusted peer so REMOTE_ADDR is the proxy.

Verification (verdict: confirmed; severity after verification: Low). Cited lines are right. Corrections to the path and precondition: the eleventh wrong request is already answered 429 (consume() raises at used > 10), and the bar then holds for the rest of the hour window. The precondition is narrower than 'any un-named proxy': the operator's collector must reach Postulo through that same un-named proxy, and for /logs the instance must also run with POSTULO_SSL_REDIRECT=false (otherwise an un-named proxy redirect-loops every page and the operator will have fixed the trust setting); /metrics is exempt from the SSL redirect, so a Prometheus scrape through an un-named proxy works and is the realistic target. Impact: loss of metrics/log shipping for an hour at a time, renewable; no data exposure. The shared-address effect on allauth's IP-keyed sign-in limits is real but already acknowledged in proxy.py's docstring. Remediation: when REMOTE_ADDR is itself an untrusted non-loopback peer and no forwarded address survived, either key the wrong-token bar on something the stranger cannot share with the collector (e.g. …

Suggested remediation. Key the wrong-token counter on (address, SHA-256 prefix of the presented token) or exempt a request that presents the correct token from the bar (check the token first, then bar only wrong presenters); alternatively refuse to enable the bar when the peer is a non-trusted non-loopback address and log a warning naming POSTULO_TRUSTED_PROXIES. Surface the 'your proxy is not trusted' state on the Logs settings page as well as Overview.

Also covered by this issue.

  • Per-address rate limits collapse to one shared bucket until the operator names the reverse proxy; the shipped default trusts loopback only (src/postulo/core/proxy.py (43-46); Informational). Denial of service only, which the threat model places out of scope; recorded because the limits that are meant to stop guessing are the ones that become a lockout lever, and the fix is one configuration line that the Overview page already shows.
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-028**. 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:** rate-limit / denial-of-service - **Location:** `src/postulo/core/throttle.py` (186-207); `src/postulo/core/proxy.py` (43-46) - **Found by:** authentication and sessions, secrets and cryptography - **Related:** PST-005 (#734) **Who can do it, and what they need.** Anyone on the internet who can reach the instance through its reverse proxy. Precondition: POSTULO_LOGS_ENDPOINT_ENABLED or POSTULO_METRICS_ENABLED with a token, and a reverse proxy that is not loopback and was not added to POSTULO_TRUSTED_PROXIES (a proxy container on the Compose network, or a proxy on another host - the case .env.example:103-111 describes). **How it works.** 1. TrustedProxyMiddleware strips X-Forwarded-For from the untrusted proxy, so every request arrives with REMOTE_ADDR = the proxy's address. 2. Attacker sends eleven requests to /logs with `Authorization: Bearer wrong`. throttle.refused() counts them under `postulo:rate:endpoint-refused:logs:<proxy addr>:<window>`. 3. The operator's collector presents the correct token; throttle.barred() runs before the token is read and raises TooOften -> 429 for the rest of the hour. Verified in a scratch test (11 wrong guesses from distinct XFF values behind an un-named proxy, then the right token -> 429). 4. Repeat every hour. **Impact.** Loss of log shipping / metrics scraping (and therefore of alerting) for as long as the attacker cares to keep it up; no data exposure. The same shared-address effect applies to allauth's IP-keyed limits (sign-in, signup, password reset) behind an un-named proxy. The module documents the trade-off, but the lockout rule (#472) was added after that reasoning and turns a shared identity into an outsider-triggerable denial. **Evidence.** ```text throttle.py:189-190: `def _caller(request): return request.META.get("REMOTE_ADDR") or "unknown"`; throttle.py:193-202 `barred()` raises when `used > REFUSED.times`; views_logs.py:116-117: `throttle.barred("logs", request)` runs before `_authorised(request)`; proxy.py:149-151 pops every forwarding header for an untrusted peer so REMOTE_ADDR is the proxy. ``` **Verification** (verdict: confirmed; severity after verification: Low). Cited lines are right. Corrections to the path and precondition: the eleventh wrong request is already answered 429 (consume() raises at used > 10), and the bar then holds for the rest of the hour window. The precondition is narrower than 'any un-named proxy': the operator's collector must reach Postulo through that same un-named proxy, and for /logs the instance must also run with POSTULO_SSL_REDIRECT=false (otherwise an un-named proxy redirect-loops every page and the operator will have fixed the trust setting); /metrics is exempt from the SSL redirect, so a Prometheus scrape through an un-named proxy works and is the realistic target. Impact: loss of metrics/log shipping for an hour at a time, renewable; no data exposure. The shared-address effect on allauth's IP-keyed sign-in limits is real but already acknowledged in proxy.py's docstring. Remediation: when REMOTE_ADDR is itself an untrusted non-loopback peer and no forwarded address survived, either key the wrong-token bar on something the stranger cannot share with the collector (e.g. … **Suggested remediation.** Key the wrong-token counter on (address, SHA-256 prefix of the presented token) or exempt a request that presents the correct token from the bar (check the token first, then bar only wrong presenters); alternatively refuse to enable the bar when the peer is a non-trusted non-loopback address and log a warning naming POSTULO_TRUSTED_PROXIES. Surface the 'your proxy is not trusted' state on the Logs settings page as well as Overview. **Also covered by this issue.** - **Per-address rate limits collapse to one shared bucket until the operator names the reverse proxy; the shipped default trusts loopback only** (`src/postulo/core/proxy.py` (43-46); Informational). Denial of service only, which the threat model places out of scope; recorded because the limits that are meant to stop guessing are the ones that become a lockout lever, and the fix is one configuration line that the Overview page already shows.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:36 +00:00
Author
Owner

Fixed in ae41694ed on main.

Fixed in ae41694ed 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#757
No description provided.