Phase 2.1.1 : durcissement avant exposition publique

Fail-fast sur la configuration. Une limite de debit illisible (10/hours,
10/banana, -1/hour) arrete desormais le demarrage avec un message nommant la
variable, au lieu de desactiver silencieusement la protection : seules les
valeurs 0, off, none, disabled et vide desactivent volontairement. Meme
traitement pour MAX_SHARED_MAPS, MAX_SHARE_SIZE_MB, DEFAULT_SHARE_EXPIRY_DAYS
et chaque entree de TRUSTED_PROXY_CIDRS.

X-Forwarded-For n'est plus cru sur la seule foi de TRUST_PROXY_HEADERS : la
connexion doit aussi provenir d'un reseau declare dans TRUSTED_PROXY_CIDRS
(ipaddress, IPv4/IPv6/CIDR). Activer la confiance sans declarer de reseau est
refuse au demarrage, faute de quoi la configuration semblerait active sans
l'etre. L'adresse retenue est normalisee : les differentes ecritures d'une meme
IPv6 ne donnent plus des compteurs distincts.

MAX_SHARED_MAPS : comptage et insertion serialises par un verrou de processus,
pour que deux creations simultanees ne franchissent pas ensemble la derniere
place. Documente comme valable pour un service applicatif unique.

Ajoute APP_BIND pour ne pas publier 8080 sur toutes les interfaces en
production, une option backup --keep pour la retention, et documente Nginx,
Traefik en override, cron et systemd.

Corrige un bug de rendu du build local : mon edition avait reecrit
docker-entrypoint.sh en CRLF, rendant le shebang introuvable dans le conteneur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 09:53:29 +02:00
parent 49870dcd56
commit 9ede2c5e68
17 changed files with 915 additions and 79 deletions

View File

@@ -1,11 +1,20 @@
"""Identification du client : X-Forwarded-For n'est cru que sur demande."""
"""Identification du client : X-Forwarded-For n'est cru qu'a deux conditions.
`TRUST_PROXY_HEADERS` doit etre actif, ET la connexion doit venir d'un reseau
declare dans `TRUSTED_PROXY_CIDRS`. Ces tests reproduisent les deux chemins :
derriere un proxy approuve, et en connexion directe forgeant le meme en-tete.
"""
from __future__ import annotations
import pytest
from starlette.datastructures import Headers
from starlette.requests import Request
from app.services.client_ip import resolve_client_ip
from app.config import parse_cidrs
from app.services.client_ip import is_trusted_peer, normalize_ip, resolve_client_ip
PROXY_NETWORKS = parse_cidrs("172.16.0.0/12,10.0.0.0/8,2001:db8::/32")
def _request(headers: dict[str, str], peer: str | None = "203.0.113.9") -> Request:
@@ -19,34 +28,108 @@ def _request(headers: dict[str, str], peer: str | None = "203.0.113.9") -> Reque
return Request(scope)
def test_proxy_headers_are_ignored_by_default() -> None:
request = _request({"x-forwarded-for": "1.2.3.4"})
assert resolve_client_ip(request, trust_proxy_headers=False) == "203.0.113.9"
class TestWithoutTrust:
def test_proxy_headers_are_ignored_by_default(self) -> None:
request = _request({"x-forwarded-for": "1.2.3.4"})
assert resolve_client_ip(request, trust_proxy_headers=False) == "203.0.113.9"
def test_forged_header_cannot_change_identity(self) -> None:
"""Un client direct ne peut pas se demultiplier pour diluer sa limite."""
seen = {
resolve_client_ip(
_request({"x-forwarded-for": f"10.0.0.{i}"}), trust_proxy_headers=False
)
for i in range(20)
}
assert seen == {"203.0.113.9"}
def test_falls_back_to_peer_without_headers(self) -> None:
assert resolve_client_ip(_request({}), trust_proxy_headers=True) == "203.0.113.9"
def test_unknown_client_is_handled(self) -> None:
assert resolve_client_ip(_request({}, peer=None), trust_proxy_headers=False) == "unknown"
def test_forged_header_cannot_change_identity() -> None:
"""Sans confiance explicite, un client direct ne peut pas se demultiplier."""
seen = {
resolve_client_ip(_request({"x-forwarded-for": f"10.0.0.{i}"}), trust_proxy_headers=False)
for i in range(20)
}
assert seen == {"203.0.113.9"}
class TestTrustedProxy:
def test_approved_proxy_is_believed(self) -> None:
# Le proxy (172.18.0.1) a ajoute l'adresse reellement vue.
request = _request({"x-forwarded-for": "9.9.9.9, 198.51.100.7"}, peer="172.18.0.1")
resolved = resolve_client_ip(
request, trust_proxy_headers=True, trusted_networks=PROXY_NETWORKS
)
assert resolved == "198.51.100.7"
def test_direct_client_forging_the_same_header_is_not_believed(self) -> None:
"""Meme en-tete, connexion directe : c'est l'adresse socket qui compte."""
request = _request({"x-forwarded-for": "9.9.9.9, 198.51.100.7"}, peer="203.0.113.9")
resolved = resolve_client_ip(
request, trust_proxy_headers=True, trusted_networks=PROXY_NETWORKS
)
assert resolved == "203.0.113.9"
def test_approved_proxy_falls_back_to_real_ip(self) -> None:
request = _request({"x-real-ip": "198.51.100.8"}, peer="10.1.2.3")
resolved = resolve_client_ip(
request, trust_proxy_headers=True, trusted_networks=PROXY_NETWORKS
)
assert resolved == "198.51.100.8"
def test_no_declared_network_means_no_trust(self) -> None:
request = _request({"x-forwarded-for": "198.51.100.7"}, peer="172.18.0.1")
resolved = resolve_client_ip(request, trust_proxy_headers=True, trusted_networks=[])
assert resolved == "172.18.0.1"
def test_ipv6_proxy_is_recognised(self) -> None:
request = _request({"x-forwarded-for": "198.51.100.7"}, peer="2001:db8::5")
resolved = resolve_client_ip(
request, trust_proxy_headers=True, trusted_networks=PROXY_NETWORKS
)
assert resolved == "198.51.100.7"
def test_peer_membership(self) -> None:
assert is_trusted_peer("172.18.0.1", PROXY_NETWORKS) is True
assert is_trusted_peer("10.255.255.254", PROXY_NETWORKS) is True
assert is_trusted_peer("203.0.113.9", PROXY_NETWORKS) is False
assert is_trusted_peer("2001:db8::1", PROXY_NETWORKS) is True
assert is_trusted_peer("2001:dead::1", PROXY_NETWORKS) is False
assert is_trusted_peer("pas-une-ip", PROXY_NETWORKS) is False
assert is_trusted_peer("10.0.0.1", []) is False
def test_trusted_proxy_uses_the_last_forwarded_value() -> None:
# Le client a forge "9.9.9.9" ; le proxy a ajoute l'adresse reellement vue.
request = _request({"x-forwarded-for": "9.9.9.9, 198.51.100.7"})
assert resolve_client_ip(request, trust_proxy_headers=True) == "198.51.100.7"
class TestNormalisation:
@pytest.mark.parametrize(
("raw", "expected"),
[
("2001:db8::1", "2001:db8::1"),
("2001:0db8:0000:0000:0000:0000:0000:0001", "2001:db8::1"),
("2001:DB8::1", "2001:db8::1"),
("2001:0db8::0:1", "2001:db8::1"),
("[2001:db8::1]:443", "2001:db8::1"),
(" 192.0.2.7 ", "192.0.2.7"),
("192.0.2.7:8080", "192.0.2.7"),
],
)
def test_same_address_gives_the_same_key(self, raw: str, expected: str) -> None:
assert normalize_ip(raw) == expected
def test_ipv6_rewriting_cannot_reset_the_counter(self) -> None:
"""Plusieurs ecritures de la meme IPv6 doivent produire une seule cle."""
forms = [
"2001:db8::1",
"2001:0DB8:0000:0000:0000:0000:0000:0001",
"2001:db8:0:0:0:0:0:1",
"[2001:db8::1]:51000",
]
keys = {
resolve_client_ip(
_request({"x-forwarded-for": form}, peer="172.18.0.1"),
trust_proxy_headers=True,
trusted_networks=PROXY_NETWORKS,
)
for form in forms
}
assert keys == {"2001:db8::1"}
def test_trusted_proxy_falls_back_to_real_ip() -> None:
request = _request({"x-real-ip": "198.51.100.8"})
assert resolve_client_ip(request, trust_proxy_headers=True) == "198.51.100.8"
def test_falls_back_to_peer_without_headers() -> None:
assert resolve_client_ip(_request({}), trust_proxy_headers=True) == "203.0.113.9"
def test_unknown_client_is_handled() -> None:
assert resolve_client_ip(_request({}, peer=None), trust_proxy_headers=False) == "unknown"
def test_unreadable_value_is_kept_bounded(self) -> None:
assert normalize_ip("x" * 500) == "x" * 64
assert normalize_ip(" ") == "unknown"