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>
193 lines
7.1 KiB
Python
193 lines
7.1 KiB
Python
"""Protections d'une instance exposee publiquement.
|
|
|
|
Couvre la limitation de debit, la coupure du partage, la capacite maximale et
|
|
l'identification du client derriere un reverse proxy.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.services.rate_limit import RateLimiter, parse_rate
|
|
from tests.conftest import build_map
|
|
|
|
|
|
def _post(client: TestClient) -> int:
|
|
return client.post("/api/shares", json={"title": "x", "map": build_map()}).status_code
|
|
|
|
|
|
class TestRateRuleParsing:
|
|
def test_reads_usual_syntaxes(self) -> None:
|
|
assert parse_rate("10/hour") == parse_rate("10/h")
|
|
assert parse_rate("5/minute").limit == 5
|
|
assert parse_rate("5/minute").window_seconds == 60
|
|
assert parse_rate("2/second").window_seconds == 1
|
|
|
|
def test_missing_unit_defaults_to_minute(self) -> None:
|
|
assert parse_rate("3").window_seconds == 60
|
|
|
|
def test_disabled_values(self) -> None:
|
|
"""Seules ces valeurs desactivent : voir test_configuration.py pour le reste."""
|
|
for spec in ("", "0", "off", "none", "disabled"):
|
|
assert parse_rate(spec) is None
|
|
|
|
|
|
class TestRateLimiter:
|
|
def test_allows_then_blocks_within_window(self) -> None:
|
|
limiter = RateLimiter()
|
|
rule = parse_rate("2/minute")
|
|
assert limiter.check("a", rule, now=0.0) is None
|
|
assert limiter.check("a", rule, now=1.0) is None
|
|
retry = limiter.check("a", rule, now=2.0)
|
|
assert retry is not None and retry > 0
|
|
|
|
def test_window_slides(self) -> None:
|
|
limiter = RateLimiter()
|
|
rule = parse_rate("1/minute")
|
|
assert limiter.check("a", rule, now=0.0) is None
|
|
assert limiter.check("a", rule, now=10.0) is not None
|
|
assert limiter.check("a", rule, now=61.0) is None
|
|
|
|
def test_clients_are_independent(self) -> None:
|
|
limiter = RateLimiter()
|
|
rule = parse_rate("1/minute")
|
|
assert limiter.check("a", rule, now=0.0) is None
|
|
assert limiter.check("b", rule, now=0.0) is None
|
|
|
|
|
|
def test_share_creation_is_rate_limited(client: TestClient, configure) -> None:
|
|
configure(share_create_rate_limit="3/hour")
|
|
|
|
assert [_post(client) for _ in range(3)] == [201, 201, 201]
|
|
|
|
response = client.post("/api/shares", json={"title": "x", "map": build_map()})
|
|
assert response.status_code == 429
|
|
assert "Retry-After" in response.headers
|
|
assert int(response.headers["Retry-After"]) > 0
|
|
assert "detail" in response.json()
|
|
|
|
|
|
def test_health_and_ready_are_never_rate_limited(client: TestClient, configure) -> None:
|
|
configure(share_create_rate_limit="1/hour", share_read_rate_limit="1/hour")
|
|
client.post("/api/shares", json={"title": "x", "map": build_map()})
|
|
client.post("/api/shares", json={"title": "x", "map": build_map()})
|
|
|
|
for _ in range(30):
|
|
assert client.get("/api/health").status_code == 200
|
|
assert client.get("/api/ready").status_code == 200
|
|
|
|
|
|
def test_reading_a_share_is_rate_limited(client: TestClient, configure) -> None:
|
|
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
|
|
configure(share_read_rate_limit="2/minute")
|
|
|
|
token = created["share_token"]
|
|
assert client.get(f"/api/shares/{token}").status_code == 200
|
|
assert client.get(f"/api/shares/{token}").status_code == 200
|
|
assert client.get(f"/api/shares/{token}").status_code == 429
|
|
|
|
|
|
def test_rate_limit_can_be_disabled(client: TestClient, configure) -> None:
|
|
configure(share_create_rate_limit="0")
|
|
assert all(_post(client) == 201 for _ in range(12))
|
|
|
|
|
|
def test_sharing_disabled_blocks_the_whole_api(client: TestClient, configure) -> None:
|
|
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
|
|
configure(sharing_enabled=False)
|
|
|
|
expected = "Le partage est desactive sur cette instance."
|
|
assert client.post("/api/shares", json={"title": "x", "map": build_map()}).status_code == 503
|
|
assert client.get(f"/api/shares/{created['share_token']}").status_code == 503
|
|
assert (
|
|
client.put(
|
|
f"/api/shares/{created['share_token']}",
|
|
json={"title": "x", "map": build_map()},
|
|
headers={"X-Management-Token": created["management_token"]},
|
|
).status_code
|
|
== 503
|
|
)
|
|
assert (
|
|
client.delete(
|
|
f"/api/shares/{created['share_token']}",
|
|
headers={"X-Management-Token": created["management_token"]},
|
|
).status_code
|
|
== 503
|
|
)
|
|
assert (
|
|
client.post("/api/shares", json={"title": "x", "map": build_map()}).json()["detail"]
|
|
== expected
|
|
)
|
|
|
|
|
|
def test_sharing_disabled_is_announced_in_config(client: TestClient, configure) -> None:
|
|
configure(sharing_enabled=False)
|
|
assert client.get("/api/config").json()["sharingEnabled"] is False
|
|
|
|
|
|
def test_capacity_limit_refuses_new_shares(client: TestClient, configure) -> None:
|
|
configure(max_shared_maps=2)
|
|
first = client.post("/api/shares", json={"title": "1", "map": build_map()}).json()
|
|
assert client.post("/api/shares", json={"title": "2", "map": build_map()}).status_code == 201
|
|
|
|
refused = client.post("/api/shares", json={"title": "3", "map": build_map()})
|
|
assert refused.status_code == 503
|
|
assert refused.json()["detail"] == "Cette instance a atteint sa capacite maximale de partages."
|
|
|
|
# Les partages existants restent lisibles et modifiables.
|
|
assert client.get(f"/api/shares/{first['share_token']}").status_code == 200
|
|
assert (
|
|
client.put(
|
|
f"/api/shares/{first['share_token']}",
|
|
json={"title": "1 bis", "map": build_map()},
|
|
headers={"X-Management-Token": first["management_token"]},
|
|
).status_code
|
|
== 200
|
|
)
|
|
|
|
|
|
def test_capacity_zero_means_unlimited(client: TestClient, configure) -> None:
|
|
configure(max_shared_maps=0)
|
|
assert all(_post(client) == 201 for _ in range(6))
|
|
|
|
|
|
def test_share_specific_size_limit(client: TestClient, configure) -> None:
|
|
configure(max_share_size_mb=0.001)
|
|
response = client.post("/api/shares", json={"title": "x", "map": build_map(node_count=50)})
|
|
assert response.status_code == 413
|
|
assert "limite de partage" in response.json()["detail"]
|
|
|
|
|
|
def test_capacity_is_not_crossed_by_simultaneous_creations(client: TestClient, configure) -> None:
|
|
"""La derniere place ne doit pas etre prise deux fois.
|
|
|
|
Le comptage et l'insertion sont serialises par un verrou de processus :
|
|
avec un seul service applicatif devant SQLite, cela suffit a ce que deux
|
|
requetes concurrentes ne franchissent pas ensemble la limite.
|
|
"""
|
|
import threading
|
|
|
|
configure(max_shared_maps=5)
|
|
for _ in range(4):
|
|
assert _post(client) == 201
|
|
|
|
results: list[int] = []
|
|
lock = threading.Lock()
|
|
|
|
def attempt() -> None:
|
|
status = client.post("/api/shares", json={"title": "x", "map": build_map()}).status_code
|
|
with lock:
|
|
results.append(status)
|
|
|
|
threads = [threading.Thread(target=attempt) for _ in range(8)]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join()
|
|
|
|
assert results.count(201) == 1
|
|
assert results.count(503) == 7
|
|
|
|
listing = client.get("/api/config")
|
|
assert listing.status_code == 200
|