Files
vulncheck/tests/test_client_ip_behind_proxy.py
T
vulncheck 0cd58a348e fix(auth): audit log and login rate limit see the client IP, not the frontend's
The backend never talks to the browser or the reverse proxy, only to the
Next.js container, and that proxy did not forward X-Forwarded-For.
FORWARDED_ALLOW_IPS defaulted to 127.0.0.1, which the frontend container
never is, so every audit row carried the container's Docker address and all
users shared one login rate-limit bucket. The limiter also parsed the header
a second time and trusted every private network, so an internal user could
pick a fresh rate-limit key per request with a forged header.

The Next.js proxy now forwards X-Forwarded-For. That was left out on purpose
while port 3003 was open, because Next.js keeps a client-supplied header.
Since the frontend is bound to 127.0.0.1, only nginx or the operator's proxy
reaches it, and those append the real peer.

The backend trusts the header from loopback and its own Docker subnet, read
from /proc/net/route, so no IP has to be configured and a recreated
container is still trusted. FORWARDED_ALLOW_IPS now adds proxies to that
(one on another host in front of nginx) instead of replacing it. uvicorn
takes the rightmost untrusted entry, so a forged entry in front never
counts. The rate limiter keys on that resolved address instead of parsing
the header itself. TRUST_PROXY_HEADERS defaults to true in Compose.
2026-10-24 14:01:55 +02:00

85 lines
3.5 KiB
Python

"""Echte Client-IP hinter nginx/OpenResty -> Next.js -> Backend.
Feldfrage 2026-10-24: Audit-Log und Login-Rate-Limit sahen fuer jeden Nutzer
die IP des Frontend-Containers. Der Next.js-Proxy gab X-Forwarded-For nicht
weiter, und FORWARDED_ALLOW_IPS stand auf 127.0.0.1, also nie auf dem
Container, der das Backend tatsaechlich anspricht. Der Limiter las den Header
zusaetzlich selbst und vertraute dabei jedem privaten Netz: ein interner
Nutzer konnte sein Rate-Limit mit einem gefaelschten Header umgehen.
Jetzt vertraut das Backend (ohne Konfiguration) dem eigenen Docker-Subnetz
aus /proc/net/route, und der Limiter nimmt die so aufgeloeste IP.
Run: venv/bin/python -m pytest tests/test_client_ip_behind_proxy.py
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("JWT_SECRET_KEY", "test-key-test-key-test-key-test-key")
import pytest # noqa: E402
from fastapi import FastAPI, Request # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware # noqa: E402
from app import rate_limiter # noqa: E402
# Backend-Container im Compose-Netz 172.19.0.0/16, Default-Route ueber .1.
ROUTE_TABLE = """Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT
eth0\t00000000\t010013AC\t0003\t0\t0\t0\t00000000\t0\t0\t0
eth0\t000013AC\t00000000\t0001\t0\t0\t0\t0000FFFF\t0\t0\t0
"""
FRONTEND = "172.19.0.4"
def test_eigenes_docker_subnetz_aus_der_routing_tabelle():
assert rate_limiter.local_subnets(ROUTE_TABLE) == ["172.19.0.0/16"]
assert rate_limiter.local_subnets("") == []
def test_ohne_konfiguration_loopback_und_eigenes_subnetz(monkeypatch):
monkeypatch.delenv("FORWARDED_ALLOW_IPS", raising=False)
monkeypatch.setattr(rate_limiter, "_read_route_table", lambda: ROUTE_TABLE)
assert rate_limiter.forwarded_allow_ips() == ["127.0.0.1", "172.19.0.0/16"]
# Ein Reverse-Proxy auf einem anderen Host kommt dazu, ersetzt nichts.
monkeypatch.setenv("FORWARDED_ALLOW_IPS", " 10.0.0.7 , 172.19.0.0/16")
assert rate_limiter.forwarded_allow_ips() == ["127.0.0.1", "172.19.0.0/16", "10.0.0.7"]
@pytest.fixture(autouse=True)
def _behind_proxy(monkeypatch):
monkeypatch.setattr(rate_limiter, "TRUST_PROXY_HEADERS", True)
def _seen_by_backend(peer, xff=None):
"""What audit log (request.client.host) and rate limiter key see."""
api = FastAPI()
@api.get("/ip")
def ip(request: Request):
return {"audit": request.client.host, "limiter": rate_limiter.get_client_ip(request)}
api.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["127.0.0.1", "172.19.0.0/16"])
headers = {"X-Forwarded-For": xff} if xff else {}
return TestClient(api, client=(peer, 50000)).get("/ip", headers=headers).json()
@pytest.mark.parametrize("xff, client", [
("203.0.113.7", "203.0.113.7"),
# Interne Nutzer sind auch RFC1918 — trotzdem zaehlt ihre eigene IP.
("192.168.1.50", "192.168.1.50"),
# Gefaelschter Header des Clients: nginx haengt die echte IP hinten an.
("1.2.3.4, 192.168.1.50", "192.168.1.50"),
])
def test_ueber_den_frontend_container_zaehlt_die_client_ip(xff, client):
assert _seen_by_backend(FRONTEND, xff) == {"audit": client, "limiter": client}
def test_direkt_von_aussen_wird_der_header_ignoriert():
assert _seen_by_backend("192.168.1.60", "1.2.3.4") == {"audit": "192.168.1.60", "limiter": "192.168.1.60"}
def test_ohne_header_bleibt_es_der_peer():
assert _seen_by_backend(FRONTEND) == {"audit": FRONTEND, "limiter": FRONTEND}