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

@@ -33,6 +33,14 @@ _WINDOW_SECONDS = {
# Garde-fou memoire : au-dela, les entrees les plus anciennes sont oubliees.
MAX_TRACKED_CLIENTS = 20000
# Seules ces valeurs desactivent volontairement une limite. Toute autre valeur
# non analysable est une faute de frappe, pas une intention.
DISABLED_VALUES = frozenset({"", "0", "off", "none", "disabled"})
class RateLimitSyntaxError(ValueError):
"""Regle de debit illisible. Remonte comme erreur de configuration."""
@dataclass(frozen=True)
class RateRule:
@@ -48,24 +56,39 @@ class RateRule:
def parse_rate(spec: str) -> RateRule | None:
"""Lit une regle "10/hour". Retourne None si la limite est desactivee.
Une valeur vide, "0", ou une syntaxe non reconnue desactive la limite :
une configuration erronee ne doit jamais rendre l'instance inutilisable.
Desactiver une limite se fait explicitement : valeur vide, `0`, `off`,
`none` ou `disabled`. Toute autre valeur illisible leve une erreur plutot
que d'etre silencieusement traitee comme une desactivation : une faute de
frappe comme `10/hours` laisserait sinon l'instance sans protection, sans
que personne ne s'en apercoive.
"""
text = (spec or "").strip().lower()
if not text or text in {"0", "none", "off", "disabled"}:
if text in DISABLED_VALUES:
return None
number, _, unit = text.partition("/")
number, separator, unit = text.partition("/")
try:
limit = int(number.strip())
except ValueError:
return None
raise RateLimitSyntaxError(
f"'{spec}' n'est pas une limite de debit valide : le nombre est illisible. "
"Attendu : <nombre>/<fenetre>, par exemple 10/hour."
) from None
if limit <= 0:
return None
raise RateLimitSyntaxError(
f"'{spec}' n'est pas une limite de debit valide : le nombre doit etre positif. "
"Pour desactiver la limite, utilisez 0, off, ou une valeur vide."
)
window = _WINDOW_SECONDS.get(unit.strip() or "minute")
# "10" vaut 10/minute ; "10/" est une fenetre oubliee, pas un raccourci.
unit_name = unit.strip() if separator else "minute"
window = _WINDOW_SECONDS.get(unit_name)
if window is None:
return None
allowed = ", ".join(sorted({"second", "minute", "hour", "day"}))
raise RateLimitSyntaxError(
f"'{spec}' n'est pas une limite de debit valide : fenetre '{unit_name}' inconnue. "
f"Fenetres acceptees : {allowed}."
)
return RateRule(limit=limit, window_seconds=window)