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.
85 lines
3.5 KiB
Python
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}
|