PST-095: Container hardening: gunicorn keeps its own trusted-proxy list, and services run without read-only root, dropped capabilities or memory limits #824

Closed
opened 2026-10-10 10:57:01 +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-095. Severity: Informational.

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: Informational
  • Category: proxy-trust-divergence
  • Location: docker/Dockerfile (363-375); docker/compose.yml (22-36)
  • Found by: CSRF, methods and redirects, configuration and dependencies

Who can do it, and what they need. A peer that gunicorn's forwarded_allow_ips (default 127.0.0.1/::1) trusts but that the operator has removed from POSTULO_TRUSTED_PROXIES, or the reverse. No path for a remote stranger under either default.

How it works.

  1. gunicorn sets wsgi.url_scheme=https from X-Forwarded-Proto: https, X-Forwarded-Protocol: ssl or X-Forwarded-Ssl: on when the peer is in its own forwarded_allow_ips, before Django runs.
  2. TrustedProxyMiddleware strips only X-Forwarded-Proto/For/Host/Port and Forwarded from untrusted peers; X-Forwarded-Protocol and X-Forwarded-Ssl are not on its list, and stripping cannot undo what gunicorn already decided.
  3. Django's request.scheme falls back to wsgi.url_scheme when SECURE_PROXY_SSL_HEADER's header is absent, so a loopback peer can mark a request secure even if the operator narrowed POSTULO_TRUSTED_PROXIES to exclude loopback.

Impact. The two trust lists agree under both defaults (loopback), so there is no attack today; the gap only opens if an operator narrows one and not the other. Recorded so the two settings are documented together.

Verification (verdict: confirmed; severity after verification: Informational). Keep it informational. Cited location: docker/Dockerfile:369-375 (CMD, no --forwarded-allow-ips), src/postulo/core/proxy.py:51-57, and prod.py:56-63 where the comment says 'Changing one of those two without the other undoes both' but names only two lists. Corrections to the write-up: (1) the middleware's header list is not the gap, because gunicorn decides the scheme before Django runs; (2) the realistic divergence is an operator-set FORWARDED_ALLOW_IPS=*, not a narrowed POSTULO_TRUSTED_PROXIES, and it lets a direct peer make is_secure() True, which gains the stranger nothing beyond skipping the SSL redirect for their own request. …

Suggested remediation. Pass --forwarded-allow-ips derived from POSTULO_TRUSTED_PROXIES in the entrypoint (or set it to an empty list and rely solely on Postulo's middleware), and add HTTP_X_FORWARDED_PROTOCOL / HTTP_X_FORWARDED_SSL to FORWARDING_HEADERS for completeness.

Also covered by this issue.

  • Compose services run without read-only root, dropped capabilities or memory limits; CV rendering runs in the web worker with no ceiling (docker/compose.yml (22-36); Informational). Self-inflicted or post-compromise denial of service and a larger blast radius for any in-container compromise. DoS is out of scope per the threat model; recorded as a hardening gap because the Dockerfile anticipates the …
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-095**. Severity: **Informational**. 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:** Informational - **Category:** proxy-trust-divergence - **Location:** `docker/Dockerfile` (363-375); `docker/compose.yml` (22-36) - **Found by:** CSRF, methods and redirects, configuration and dependencies **Who can do it, and what they need.** A peer that gunicorn's `forwarded_allow_ips` (default 127.0.0.1/::1) trusts but that the operator has removed from POSTULO_TRUSTED_PROXIES, or the reverse. No path for a remote stranger under either default. **How it works.** 1. gunicorn sets `wsgi.url_scheme=https` from `X-Forwarded-Proto: https`, `X-Forwarded-Protocol: ssl` or `X-Forwarded-Ssl: on` when the peer is in its own `forwarded_allow_ips`, before Django runs. 2. TrustedProxyMiddleware strips only `X-Forwarded-Proto/For/Host/Port` and `Forwarded` from untrusted peers; `X-Forwarded-Protocol` and `X-Forwarded-Ssl` are not on its list, and stripping cannot undo what gunicorn already decided. 3. Django's `request.scheme` falls back to `wsgi.url_scheme` when SECURE_PROXY_SSL_HEADER's header is absent, so a loopback peer can mark a request secure even if the operator narrowed POSTULO_TRUSTED_PROXIES to exclude loopback. **Impact.** The two trust lists agree under both defaults (loopback), so there is no attack today; the gap only opens if an operator narrows one and not the other. Recorded so the two settings are documented together. **Verification** (verdict: confirmed; severity after verification: Informational). Keep it informational. Cited location: docker/Dockerfile:369-375 (CMD, no --forwarded-allow-ips), src/postulo/core/proxy.py:51-57, and prod.py:56-63 where the comment says 'Changing one of those two without the other undoes both' but names only two lists. Corrections to the write-up: (1) the middleware's header list is not the gap, because gunicorn decides the scheme before Django runs; (2) the realistic divergence is an operator-set FORWARDED_ALLOW_IPS=*, not a narrowed POSTULO_TRUSTED_PROXIES, and it lets a direct peer make is_secure() True, which gains the stranger nothing beyond skipping the SSL redirect for their own request. … **Suggested remediation.** Pass `--forwarded-allow-ips` derived from POSTULO_TRUSTED_PROXIES in the entrypoint (or set it to an empty list and rely solely on Postulo's middleware), and add `HTTP_X_FORWARDED_PROTOCOL` / `HTTP_X_FORWARDED_SSL` to FORWARDING_HEADERS for completeness. **Also covered by this issue.** - **Compose services run without read-only root, dropped capabilities or memory limits; CV rendering runs in the web worker with no ceiling** (`docker/compose.yml` (22-36); Informational). Self-inflicted or post-compromise denial of service and a larger blast radius for any in-container compromise. DoS is out of scope per the threat model; recorded as a hardening gap because the Dockerfile anticipates the …
tiagoagueda added this to the 1.0.0 milestone 2026-10-10 10:58:22 +00:00
Author
Owner

Fixed in 240e347de on main.

Fixed in 240e347de 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#824
No description provided.