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:
153
backend/tests/test_configuration.py
Normal file
153
backend/tests/test_configuration.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Validation de la configuration au demarrage.
|
||||
|
||||
Une valeur illisible doit arreter l'application avec un message nommant la
|
||||
variable fautive, jamais desactiver silencieusement une protection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.config import ConfigurationError, Settings, get_settings, parse_cidrs
|
||||
from app.services.rate_limit import RateLimitSyntaxError, parse_rate
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("spec", "limit", "window"),
|
||||
[
|
||||
("10/hour", 10, 3600),
|
||||
("120/minute", 120, 60),
|
||||
("5/second", 5, 1),
|
||||
("2/day", 2, 86400),
|
||||
("10/h", 10, 3600),
|
||||
(" 30/MINUTE ", 30, 60),
|
||||
("3", 3, 60),
|
||||
],
|
||||
)
|
||||
def test_valid_rate_specs(spec: str, limit: int, window: int) -> None:
|
||||
rule = parse_rate(spec)
|
||||
assert rule is not None
|
||||
assert rule.limit == limit
|
||||
assert rule.window_seconds == window
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", ["", "0", "off", "none", "disabled", " OFF "])
|
||||
def test_explicit_deactivation(spec: str) -> None:
|
||||
"""Desactiver une limite reste possible, mais doit etre explicite."""
|
||||
assert parse_rate(spec) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"spec",
|
||||
["10/hours", "abc", "-1/hour", "10/banana", "10/", "/hour", "dix/hour", "10 par heure"],
|
||||
)
|
||||
def test_invalid_rate_specs_are_refused(spec: str) -> None:
|
||||
with pytest.raises(RateLimitSyntaxError):
|
||||
parse_rate(spec)
|
||||
|
||||
|
||||
def test_invalid_rate_message_is_actionable() -> None:
|
||||
with pytest.raises(RateLimitSyntaxError) as error:
|
||||
parse_rate("10/hours")
|
||||
message = str(error.value)
|
||||
assert "10/hours" in message
|
||||
assert "hours" in message
|
||||
assert "second" in message and "hour" in message
|
||||
|
||||
|
||||
class TestSettingsValidation:
|
||||
def test_valid_configuration_loads(self) -> None:
|
||||
settings = Settings(
|
||||
share_create_rate_limit="10/hour",
|
||||
share_read_rate_limit="120/minute",
|
||||
share_write_rate_limit="off",
|
||||
)
|
||||
assert settings.share_write_rate_limit == "off"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field",
|
||||
["share_create_rate_limit", "share_read_rate_limit", "share_write_rate_limit"],
|
||||
)
|
||||
def test_each_rate_variable_is_validated(self, field: str) -> None:
|
||||
with pytest.raises(Exception) as error:
|
||||
Settings(**{field: "10/banana"})
|
||||
assert "banana" in str(error.value)
|
||||
|
||||
def test_negative_shared_map_limit_is_refused(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(max_shared_maps=-1)
|
||||
|
||||
def test_negative_share_size_is_refused(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(max_share_size_mb=-2)
|
||||
|
||||
def test_zero_share_size_means_inherit(self) -> None:
|
||||
settings = Settings(max_share_size_mb=0, max_map_size_mb=10)
|
||||
assert settings.max_share_size_bytes == settings.max_map_size_bytes
|
||||
|
||||
def test_negative_expiry_is_refused(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(default_share_expiry_days=-1)
|
||||
|
||||
def test_zero_expiry_is_allowed(self) -> None:
|
||||
assert Settings(default_share_expiry_days=0).default_share_expiry_days == 0
|
||||
|
||||
def test_zero_map_size_is_refused(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(max_map_size_mb=0)
|
||||
|
||||
|
||||
class TestTrustedProxyConfiguration:
|
||||
def test_valid_cidrs(self) -> None:
|
||||
networks = parse_cidrs("172.16.0.0/12, 10.0.0.0/8 ,2001:db8::/32")
|
||||
assert len(networks) == 3
|
||||
assert str(networks[0]) == "172.16.0.0/12"
|
||||
|
||||
def test_single_address_becomes_a_host_network(self) -> None:
|
||||
assert str(parse_cidrs("192.0.2.7")[0]) == "192.0.2.7/32"
|
||||
|
||||
def test_empty_value_is_allowed(self) -> None:
|
||||
assert parse_cidrs("") == []
|
||||
assert parse_cidrs(" , ") == []
|
||||
|
||||
@pytest.mark.parametrize("value", ["999.1.1.1/8", "10.0.0.0/64", "pas-un-reseau", "10.0.0.0/"])
|
||||
def test_invalid_cidr_is_refused(self, value: str) -> None:
|
||||
with pytest.raises(ValueError, match="reseau valide"):
|
||||
parse_cidrs(value)
|
||||
|
||||
def test_invalid_cidr_stops_settings(self) -> None:
|
||||
with pytest.raises(Exception) as error:
|
||||
Settings(trusted_proxy_cidrs="10.0.0.0/8,pas-un-reseau")
|
||||
assert "pas-un-reseau" in str(error.value)
|
||||
|
||||
def test_trusting_proxies_without_declaring_them_is_refused(self) -> None:
|
||||
"""Faire confiance sans dire a qui n'appliquerait aucune confiance."""
|
||||
with pytest.raises(Exception) as error:
|
||||
Settings(trust_proxy_headers=True, trusted_proxy_cidrs="")
|
||||
assert "TRUSTED_PROXY_CIDRS" in str(error.value)
|
||||
|
||||
def test_trusting_proxies_with_networks_is_accepted(self) -> None:
|
||||
settings = Settings(trust_proxy_headers=True, trusted_proxy_cidrs="172.16.0.0/12")
|
||||
assert len(settings.trusted_proxy_networks) == 1
|
||||
|
||||
|
||||
class TestStartupFailure:
|
||||
def test_bad_rate_stops_startup_with_a_readable_message(self, monkeypatch) -> None:
|
||||
monkeypatch.setenv("SHARE_CREATE_RATE_LIMIT", "10/banana")
|
||||
get_settings.cache_clear()
|
||||
with pytest.raises(ConfigurationError) as error:
|
||||
get_settings()
|
||||
message = str(error.value)
|
||||
assert "Configuration invalide" in message
|
||||
assert "SHARE_CREATE_RATE_LIMIT" in message
|
||||
assert "banana" in message
|
||||
get_settings.cache_clear()
|
||||
|
||||
def test_bad_cidr_stops_startup(self, monkeypatch) -> None:
|
||||
monkeypatch.setenv("TRUSTED_PROXY_CIDRS", "10.0.0.0/8,nawak")
|
||||
get_settings.cache_clear()
|
||||
with pytest.raises(ConfigurationError) as error:
|
||||
get_settings()
|
||||
assert "TRUSTED_PROXY_CIDRS" in str(error.value)
|
||||
get_settings.cache_clear()
|
||||
Reference in New Issue
Block a user