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

@@ -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