Files
plm-lottery/tests/unit/test_caddyfile_security_headers.py
T
davideandClaude Opus 5 907e32e9e0 Make X-Forwarded-For trustworthy instead of attacker-controlled (B-54)
client_ip() read the first element of X-Forwarded-For, which is correct only if
the proxy replaces the header. Caddy appends the peer address to whatever the
client sent, so element 0 was whatever the caller claimed: rotating a fake value
per request minted a fresh identity every time and walked straight through the
login and registration throttles (B-33) and the SSE per-IP subscriber cap
(B-38). Only the per-username login bucket, which doesn't key on the IP, still
bit.

Both halves of the audit's fix, since they hold independently:

- the Caddyfile overwrites the header with `header_up X-Forwarded-For
  {remote_host}`, so what reaches the app is the actual peer and nothing else.
  This is the one that makes the app's assumption true at the source.
- client_ip() reads the *last* hop rather than the first — the element written
  by the hop closest to us, i.e. by our own proxy. Exactly one trusted proxy
  sits in front of the app (`app` is only `expose`d on the compose network,
  never published to the host), so that element is the real peer.

An empty or comma-only header now falls back to request.client.host instead of
returning "", which was its own shared-bucket evasion.

Regression tests both sides: two requests spoofing different prefixes must key
to the same IP, and the Caddyfile must keep the header_up directive (checked by
`caddy validate`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:14:03 +02:00

45 lines
1.5 KiB
Python

"""B-43: the Caddyfile must keep sending baseline security headers. Caddy adds
none of these on its own, and the JWT lives in localStorage, so a regression
here silently reopens an XSS/clickjacking exposure with no test ever failing
in the Python suite (the Caddyfile isn't imported/exercised by anything else)."""
from pathlib import Path
CADDYFILE = (Path(__file__).parent.parent.parent / "Caddyfile").read_text()
def test_header_block_present():
assert "header {" in CADDYFILE
def test_hsts_is_set():
assert "Strict-Transport-Security" in CADDYFILE
assert "max-age=" in CADDYFILE
def test_nosniff_is_set():
assert 'X-Content-Type-Options "nosniff"' in CADDYFILE
def test_frame_ancestors_are_blocked():
assert 'X-Frame-Options "DENY"' in CADDYFILE
assert "frame-ancestors 'none'" in CADDYFILE
def test_referrer_policy_is_set():
assert "Referrer-Policy" in CADDYFILE
def test_csp_default_src_is_self():
assert "Content-Security-Policy" in CADDYFILE
assert "default-src 'self'" in CADDYFILE
def test_forwarded_for_is_overwritten_with_the_real_peer(): # B-54
"""Caddy appends to a client-supplied X-Forwarded-For instead of replacing it,
so without this directive the header's first element is whatever the caller
claimed. app/api/client_ip.py reads the last hop and so holds on its own, but
this is what makes the header itself trustworthy — losing it silently weakens
every IP-keyed control (B-33's throttles, B-38's SSE cap)."""
assert "header_up X-Forwarded-For {remote_host}" in CADDYFILE