SEC2-035: /healthz treats a request as forwarded only on X-Forwarded-For, so behind a loopback proxy every visitor is a "probe" #977

Closed
opened 2026-10-17 10:38:59 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo at 56fc9d531, checked by a second, independent pass.

Severity: Informational · Category: Data Exposure · Reference: CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor); OWASP A05:2021 (Security Misconfiguration)

Location:

  • src/postulo/core/middleware.py:64-75 (HealthProbeMiddleware._is_the_probe: the marker check at :70, the loopback check at :73)
  • src/postulo/core/views.py:66-83 (healthz adds version when _is_local)
  • src/postulo/core/proxy.py:146-154 (POSTULO_PROXY_ADDR is set only when HTTP_X_FORWARDED_FOR is present)

Issue

The middleware's comment says the marker is left "whenever a trusted peer sent a forwarding header", but it is written for one header only:

# proxy.py
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
if forwarded_for:
    request.META["REMOTE_ADDR"] = client_address(remote_addr, forwarded_for)
    request.META["POSTULO_PROXY_ADDR"] = remote_addr
# middleware.py
if "POSTULO_PROXY_ADDR" in request.META:
    return False
return ipaddress.ip_address(request.META.get("REMOTE_ADDR", "")).is_loopback

A reverse proxy whose packets arrive from a loopback address (bare metal, or a container with network_mode: host) is trusted by default. nginx adds no X-Forwarded-For unless told to, and the wiki's proxy section names only X-Forwarded-Proto, so this is the documented configuration. Behind such a proxy every visitor's GET /healthz arrives from 127.0.0.1 with no X-Forwarded-For, is answered by the probe path before the host check, and _is_local is true, so the exact version goes to the stranger #800 moved it away from. tests/security/test_health_probe_host.py:81-88 covers only the X-Forwarded-For case.

Impact

Version fingerprinting by anybody who can reach the instance, in a deployment the install pages describe; the host-list bypass itself gains nothing on /healthz, which builds no links. No personal data.

Fix

Mark a trusted peer's request as forwarded whenever any forwarding header arrived, not only X-Forwarded-For: in TrustedProxyMiddleware, set POSTULO_PROXY_ADDR when any(request.META.get(h) for h in FORWARDING_HEADERS), and keep the REMOTE_ADDR rewrite conditional on X-Forwarded-For as today. views.healthz._is_local then reads the same marker ("POSTULO_PROXY_ADDR" not in request.META and ...). The marker has only two readers (middleware.py:70, proxy.py:101), both of which want the broader meaning. Done when: a test in tests/security/test_health_probe_host.py sends REMOTE_ADDR=127.0.0.1 with HTTP_X_FORWARDED_PROTO=https and no X-Forwarded-For, and asserts a 400 for a host not in ALLOWED_HOSTS and no version in the answer with a right host.

#800 (closed) - the version disclosure; this is a gap in its fix. #580 (closed) - the probe middleware. #757 (closed) - the untrusted-forwarder marker.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Informational · **Category:** Data Exposure · **Reference:** CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor); OWASP A05:2021 (Security Misconfiguration) **Location:** - `src/postulo/core/middleware.py:64-75` (`HealthProbeMiddleware._is_the_probe`: the marker check at `:70`, the loopback check at `:73`) - `src/postulo/core/views.py:66-83` (`healthz` adds `version` when `_is_local`) - `src/postulo/core/proxy.py:146-154` (`POSTULO_PROXY_ADDR` is set only when `HTTP_X_FORWARDED_FOR` is present) ## Issue The middleware's comment says the marker is left "whenever a trusted peer sent a forwarding header", but it is written for one header only: ```python # proxy.py forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "") if forwarded_for: request.META["REMOTE_ADDR"] = client_address(remote_addr, forwarded_for) request.META["POSTULO_PROXY_ADDR"] = remote_addr # middleware.py if "POSTULO_PROXY_ADDR" in request.META: return False return ipaddress.ip_address(request.META.get("REMOTE_ADDR", "")).is_loopback ``` A reverse proxy whose packets arrive from a loopback address (bare metal, or a container with `network_mode: host`) is trusted by default. nginx adds no `X-Forwarded-For` unless told to, and the wiki's proxy section names only `X-Forwarded-Proto`, so this is the documented configuration. Behind such a proxy every visitor's `GET /healthz` arrives from `127.0.0.1` with no `X-Forwarded-For`, is answered by the probe path before the host check, and `_is_local` is true, so the exact version goes to the stranger #800 moved it away from. `tests/security/test_health_probe_host.py:81-88` covers only the `X-Forwarded-For` case. ## Impact Version fingerprinting by anybody who can reach the instance, in a deployment the install pages describe; the host-list bypass itself gains nothing on `/healthz`, which builds no links. No personal data. ## Fix Mark a trusted peer's request as forwarded whenever any forwarding header arrived, not only `X-Forwarded-For`: in `TrustedProxyMiddleware`, set `POSTULO_PROXY_ADDR` when `any(request.META.get(h) for h in FORWARDING_HEADERS)`, and keep the `REMOTE_ADDR` rewrite conditional on `X-Forwarded-For` as today. `views.healthz._is_local` then reads the same marker (`"POSTULO_PROXY_ADDR" not in request.META and ...`). The marker has only two readers (`middleware.py:70`, `proxy.py:101`), both of which want the broader meaning. Done when: a test in `tests/security/test_health_probe_host.py` sends `REMOTE_ADDR=127.0.0.1` with `HTTP_X_FORWARDED_PROTO=https` and no `X-Forwarded-For`, and asserts a 400 for a host not in `ALLOWED_HOSTS` and no `version` in the answer with a right host. ## Related #800 (closed) - the version disclosure; this is a gap in its fix. #580 (closed) - the probe middleware. #757 (closed) - the untrusted-forwarder marker.
tiagoagueda added this to the 1.0.0 milestone 2026-10-17 10:39:00 +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#977
No description provided.