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:
103
backend/tests/test_reverse_proxy.py
Normal file
103
backend/tests/test_reverse_proxy.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Limitation de debit derriere un reverse proxy, de bout en bout.
|
||||
|
||||
Deux chemins sont reproduits sur l'application reelle :
|
||||
|
||||
* une connexion venant d'un proxy approuve, qui transmet l'adresse du client ;
|
||||
* une connexion directe qui forge le meme en-tete.
|
||||
|
||||
Dans le premier cas la limite doit suivre l'adresse transmise, dans le second
|
||||
l'adresse reelle de la socket.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from tests.conftest import build_map
|
||||
|
||||
PROXY = "172.18.0.1"
|
||||
DIRECT = "203.0.113.9"
|
||||
|
||||
|
||||
def _client(peer: str) -> Iterator[TestClient]:
|
||||
with TestClient(app, client=(peer, 40000)) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def behind_proxy(configure) -> Iterator[TestClient]:
|
||||
configure(
|
||||
trust_proxy_headers=True,
|
||||
trusted_proxy_cidrs="172.16.0.0/12,10.0.0.0/8",
|
||||
share_create_rate_limit="2/hour",
|
||||
)
|
||||
yield from _client(PROXY)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def direct_client(configure) -> Iterator[TestClient]:
|
||||
configure(
|
||||
trust_proxy_headers=True,
|
||||
trusted_proxy_cidrs="172.16.0.0/12,10.0.0.0/8",
|
||||
share_create_rate_limit="2/hour",
|
||||
)
|
||||
yield from _client(DIRECT)
|
||||
|
||||
|
||||
def _create(client: TestClient, forwarded_for: str | None) -> int:
|
||||
headers = {"X-Forwarded-For": forwarded_for} if forwarded_for else {}
|
||||
return client.post(
|
||||
"/api/shares", json={"title": "x", "map": build_map()}, headers=headers
|
||||
).status_code
|
||||
|
||||
|
||||
def test_limit_follows_the_address_transmitted_by_an_approved_proxy(
|
||||
behind_proxy: TestClient,
|
||||
) -> None:
|
||||
assert _create(behind_proxy, "198.51.100.10") == 201
|
||||
assert _create(behind_proxy, "198.51.100.10") == 201
|
||||
assert _create(behind_proxy, "198.51.100.10") == 429
|
||||
|
||||
# Un autre client, derriere le meme proxy, garde son propre compteur.
|
||||
assert _create(behind_proxy, "198.51.100.11") == 201
|
||||
|
||||
|
||||
def test_approved_proxy_ignores_the_part_forged_by_the_client(
|
||||
behind_proxy: TestClient,
|
||||
) -> None:
|
||||
"""Le client ecrit "9.9.9.9" ; le proxy ajoute l'adresse qu'il a vue."""
|
||||
assert _create(behind_proxy, "9.9.9.9, 198.51.100.20") == 201
|
||||
assert _create(behind_proxy, "9.9.9.9, 198.51.100.20") == 201
|
||||
assert _create(behind_proxy, "9.9.9.9, 198.51.100.20") == 429
|
||||
|
||||
# Changer la partie forgee ne remet pas le compteur a zero.
|
||||
assert _create(behind_proxy, "1.1.1.1, 198.51.100.20") == 429
|
||||
|
||||
|
||||
def test_direct_client_cannot_escape_the_limit_by_forging_the_header(
|
||||
direct_client: TestClient,
|
||||
) -> None:
|
||||
assert _create(direct_client, "10.0.0.1") == 201
|
||||
assert _create(direct_client, "10.0.0.2") == 201
|
||||
# Adresse forgee differente a chaque fois : la socket, elle, ne change pas.
|
||||
assert _create(direct_client, "10.0.0.3") == 429
|
||||
assert _create(direct_client, "10.0.0.4") == 429
|
||||
assert _create(direct_client, None) == 429
|
||||
|
||||
|
||||
def test_ipv6_rewriting_does_not_reset_the_counter(behind_proxy: TestClient) -> None:
|
||||
assert _create(behind_proxy, "2001:db8::1") == 201
|
||||
assert _create(behind_proxy, "2001:0db8:0000:0000:0000:0000:0000:0001") == 201
|
||||
assert _create(behind_proxy, "2001:DB8:0:0:0:0:0:1") == 429
|
||||
|
||||
|
||||
def test_headers_are_ignored_when_trust_is_disabled(configure) -> None:
|
||||
configure(trust_proxy_headers=False, share_create_rate_limit="2/hour")
|
||||
with TestClient(app, client=(PROXY, 40000)) as client:
|
||||
assert _create(client, "198.51.100.30") == 201
|
||||
assert _create(client, "198.51.100.31") == 201
|
||||
assert _create(client, "198.51.100.32") == 429
|
||||
Reference in New Issue
Block a user