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>
44 lines
2.3 KiB
Caddyfile
44 lines
2.3 KiB
Caddyfile
# SITE_ADDRESS is the domain to serve (e.g. lottery.example.com) — Caddy
|
|
# automatically requests a Let's Encrypt certificate for it.
|
|
#
|
|
# Left at the default "localhost" (dev mode, no domain), Caddy detects it's
|
|
# not a public hostname and issues a locally-trusted self-signed certificate
|
|
# instead, via its internal CA. Browsers will still warn on first visit
|
|
# unless that CA is explicitly trusted — expected for local/dev use.
|
|
{$SITE_ADDRESS:localhost} {
|
|
# gzip buffers output, which would delay delivery on the SSE stream
|
|
# (/rounds/stream, app/api/routes/rounds.py) — it needs each event flushed
|
|
# to the client immediately, not batched. Everything else still compresses.
|
|
@not_sse {
|
|
not path /rounds/stream
|
|
}
|
|
encode @not_sse gzip
|
|
|
|
# B-43: Caddy adds none of these on its own. The JWT lives in
|
|
# localStorage, so any XSS exfiltrates it — CSP is the main mitigation.
|
|
# script-src/style-src need 'unsafe-inline' because both SPAs
|
|
# (app/static/index.html, admin.html) use inline onclick handlers and
|
|
# style="" attributes throughout; removing those is a separate,
|
|
# larger refactor, not a header change. fonts.googleapis.com/gstatic.com
|
|
# are the one external asset (the Google Fonts @import in style.css/admin.css).
|
|
header {
|
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
|
X-Content-Type-Options "nosniff"
|
|
X-Frame-Options "DENY"
|
|
Referrer-Policy "strict-origin-when-cross-origin"
|
|
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"
|
|
}
|
|
|
|
# B-54: Caddy *appends* the real peer address to whatever X-Forwarded-For the
|
|
# client sent, so without this the header arrives as "<whatever the client
|
|
# claimed>, <real ip>" and every IP-keyed control in the app (the login and
|
|
# registration throttles of B-33, the SSE per-IP subscriber cap of B-38) is
|
|
# defeated by simply rotating a fake value per request. Overwriting the header
|
|
# with the actual peer makes the app's assumption true at the source; it also
|
|
# reads the last hop rather than the first (app/api/client_ip.py), so the two
|
|
# defences hold independently.
|
|
reverse_proxy app:8123 {
|
|
header_up X-Forwarded-For {remote_host}
|
|
}
|
|
}
|