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:
@@ -56,7 +56,11 @@ def _rate_limit(bucket: str, spec_name: str): # type: ignore[no-untyped-def]
|
||||
rule = parse_rate(getattr(settings, spec_name))
|
||||
if rule is None:
|
||||
return
|
||||
client = resolve_client_ip(request, trust_proxy_headers=settings.trust_proxy_headers)
|
||||
client = resolve_client_ip(
|
||||
request,
|
||||
trust_proxy_headers=settings.trust_proxy_headers,
|
||||
trusted_networks=settings.trusted_proxy_networks,
|
||||
)
|
||||
retry_after = limiter.check(f"{bucket}:{client}", rule)
|
||||
if retry_after is not None:
|
||||
raise RateLimitedError(retry_after)
|
||||
|
||||
@@ -224,6 +224,36 @@ def _sqlite_backup(source: Path, destination: Path) -> None:
|
||||
origin.backup(copy)
|
||||
|
||||
|
||||
def _apply_retention(destination: Path, keep: int) -> list[Path]:
|
||||
"""Ne garde que les `keep` sauvegardes les plus recentes du meme dossier.
|
||||
|
||||
Le filtre est volontairement etroit : meme dossier, meme extension, et le
|
||||
fichier qui vient d'etre ecrit est toujours conserve. Une commande de
|
||||
sauvegarde ne doit pas devenir un effaceur de fichiers surprise.
|
||||
"""
|
||||
if keep <= 0:
|
||||
return []
|
||||
|
||||
others = sorted(
|
||||
(
|
||||
path
|
||||
for path in destination.parent.glob(f"*{destination.suffix}")
|
||||
if path.is_file() and path != destination
|
||||
),
|
||||
key=lambda path: path.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
# La sauvegarde qui vient d'etre ecrite compte comme la plus recente, et
|
||||
# comme l'une des `keep` conservees : on ne peut donc pas finir avec keep+1.
|
||||
ordered = [destination, *others]
|
||||
|
||||
removed = []
|
||||
for path in ordered[keep:]:
|
||||
path.unlink()
|
||||
removed.append(path)
|
||||
return removed
|
||||
|
||||
|
||||
def cmd_backup(args: argparse.Namespace) -> int:
|
||||
source = database_path()
|
||||
if source is None or not source.exists():
|
||||
@@ -237,6 +267,9 @@ def cmd_backup(args: argparse.Namespace) -> int:
|
||||
|
||||
_sqlite_backup(source, destination)
|
||||
print(f"Sauvegarde ecrite : {destination} ({_format_size(destination.stat().st_size)})")
|
||||
|
||||
for removed in _apply_retention(destination, args.keep):
|
||||
print(f"Ancienne sauvegarde supprimee : {removed.name}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -315,6 +348,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
backup = sub.add_parser("backup", help="Sauvegarder la base pendant que l'app tourne")
|
||||
backup.add_argument("destination", help="Chemin du fichier de sauvegarde")
|
||||
backup.add_argument("--yes", action="store_true", help="Ecraser un fichier existant")
|
||||
backup.add_argument(
|
||||
"--keep",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Ne garder que les N sauvegardes les plus recentes du dossier (0 = tout garder)",
|
||||
)
|
||||
backup.set_defaults(func=cmd_backup)
|
||||
|
||||
restore = sub.add_parser("restore", help="Restaurer une sauvegarde (prudent)")
|
||||
|
||||
@@ -1,12 +1,51 @@
|
||||
"""Configuration applicative, entierement pilotee par variables d'environnement."""
|
||||
"""Configuration applicative, entierement pilotee par variables d'environnement.
|
||||
|
||||
Toute valeur illisible arrete le demarrage avec un message nommant la variable
|
||||
fautive. Une instance publique ne doit jamais tourner en croyant appliquer une
|
||||
protection qu'une faute de frappe aurait desactivee.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, ValidationError, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from app.services.rate_limit import RateLimitSyntaxError, parse_rate
|
||||
|
||||
|
||||
class ConfigurationError(RuntimeError):
|
||||
"""Configuration inexploitable : le demarrage doit s'arreter ici."""
|
||||
|
||||
|
||||
def _validate_rate(value: str) -> str:
|
||||
"""Verifie une regle de debit sans la conserver : elle est relue a l'usage."""
|
||||
parse_rate(value) # leve RateLimitSyntaxError si la syntaxe est fautive
|
||||
return value
|
||||
|
||||
|
||||
def parse_cidrs(raw: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
|
||||
"""Lit une liste de reseaux CIDR separes par des virgules.
|
||||
|
||||
`strict=False` accepte aussi bien `10.0.0.0/8` qu'une adresse seule comme
|
||||
`192.0.2.7`, qui devient alors un reseau /32.
|
||||
"""
|
||||
networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
|
||||
for entry in raw.split(","):
|
||||
text = entry.strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
networks.append(ipaddress.ip_network(text, strict=False))
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"'{text}' n'est pas un reseau valide. Attendu : une adresse ou un CIDR, "
|
||||
"par exemple 172.16.0.0/12, 10.0.0.0/8 ou 2001:db8::/32."
|
||||
) from None
|
||||
return networks
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Parametres du backend.
|
||||
@@ -34,7 +73,8 @@ class Settings(BaseSettings):
|
||||
sharing_enabled: bool = True
|
||||
|
||||
# Limites de debit, au format "<nombre>/<fenetre>" : 10/hour, 120/minute,
|
||||
# 5/second. Une valeur vide ou "0" desactive la limite concernee.
|
||||
# 5/second. Seules les valeurs 0, off, none, disabled ou vide desactivent
|
||||
# une limite ; toute autre valeur illisible arrete le demarrage.
|
||||
share_create_rate_limit: str = "10/hour"
|
||||
share_read_rate_limit: str = "120/minute"
|
||||
share_write_rate_limit: str = "60/hour"
|
||||
@@ -50,9 +90,13 @@ class Settings(BaseSettings):
|
||||
# Autorise l'option "Jamais" dans l'interface et via l'API.
|
||||
allow_permanent_shares: bool = True
|
||||
|
||||
# A n'activer que derriere un reverse proxy de confiance : sinon un client
|
||||
# direct pourrait falsifier son adresse et contourner les limites de debit.
|
||||
# A n'activer que derriere un reverse proxy de confiance, et uniquement avec
|
||||
# trusted_proxy_cidrs renseigne : sinon un client direct pourrait falsifier
|
||||
# son adresse et contourner les limites de debit.
|
||||
trust_proxy_headers: bool = False
|
||||
# Reseaux depuis lesquels X-Forwarded-For est accepte, separes par des
|
||||
# virgules. IPv4, IPv6 et CIDR acceptes.
|
||||
trusted_proxy_cidrs: str = ""
|
||||
|
||||
# Liste d'origines autorisees, separees par des virgules.
|
||||
# Vide = aucune origine externe (l'app est servie par ce meme serveur).
|
||||
@@ -60,6 +104,32 @@ class Settings(BaseSettings):
|
||||
|
||||
log_level: str = "INFO"
|
||||
|
||||
@field_validator("share_create_rate_limit", "share_read_rate_limit", "share_write_rate_limit")
|
||||
@classmethod
|
||||
def _check_rate(cls, value: str) -> str:
|
||||
return _validate_rate(value)
|
||||
|
||||
@field_validator("trusted_proxy_cidrs")
|
||||
@classmethod
|
||||
def _check_cidrs(cls, value: str) -> str:
|
||||
parse_cidrs(value)
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_proxy_trust(self) -> Settings:
|
||||
"""Faire confiance a un proxy sans dire lequel ne protege de rien.
|
||||
|
||||
Autant le dire au demarrage : sans reseau declare, aucun en-tete ne
|
||||
serait accepte et l'exploitant croirait sa configuration active.
|
||||
"""
|
||||
if self.trust_proxy_headers and not parse_cidrs(self.trusted_proxy_cidrs):
|
||||
raise ValueError(
|
||||
"TRUST_PROXY_HEADERS=true exige TRUSTED_PROXY_CIDRS : indiquez les reseaux "
|
||||
"depuis lesquels X-Forwarded-For peut etre accepte, par exemple "
|
||||
"172.16.0.0/12,10.0.0.0/8."
|
||||
)
|
||||
return self
|
||||
|
||||
@property
|
||||
def max_map_size_bytes(self) -> int:
|
||||
return int(self.max_map_size_mb * 1024 * 1024)
|
||||
@@ -75,6 +145,10 @@ class Settings(BaseSettings):
|
||||
def max_share_size_mb_effective(self) -> float:
|
||||
return self.max_share_size_bytes / (1024 * 1024)
|
||||
|
||||
@property
|
||||
def trusted_proxy_networks(self) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
|
||||
return parse_cidrs(self.trusted_proxy_cidrs)
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||
@@ -84,6 +158,21 @@ class Settings(BaseSettings):
|
||||
return self.app_env.lower() in {"dev", "development", "debug"}
|
||||
|
||||
|
||||
def _readable_error(error: ValidationError) -> str:
|
||||
"""Traduit une erreur Pydantic en message nommant la variable fautive."""
|
||||
lines = []
|
||||
for item in error.errors():
|
||||
variable = str(item["loc"][0]).upper() if item.get("loc") else "CONFIGURATION"
|
||||
reason = str(item.get("msg", "")).replace("Value error, ", "")
|
||||
lines.append(f" {variable} : {reason}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
try:
|
||||
return Settings()
|
||||
except RateLimitSyntaxError as error: # pragma: no cover - filet de securite
|
||||
raise ConfigurationError(f"Configuration invalide :\n {error}") from None
|
||||
except ValidationError as error:
|
||||
raise ConfigurationError(f"Configuration invalide :\n{_readable_error(error)}") from None
|
||||
|
||||
@@ -22,7 +22,6 @@ from alembic.config import Config
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db.session import engine as default_engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,6 +42,11 @@ def build_config(database_url: str | None = None, *, configure_logger: bool = Fa
|
||||
|
||||
|
||||
def run_migrations(engine=None, database_url: str | None = None) -> None: # type: ignore[no-untyped-def]
|
||||
# Import tardif : creer le moteur lit la configuration. En le faisant ici
|
||||
# plutot qu'a l'import du module, une configuration invalide remonte comme
|
||||
# une erreur attrapable, avec un message d'exploitation et sans traceback.
|
||||
from app.db.session import engine as default_engine
|
||||
|
||||
target = engine or default_engine
|
||||
config = build_config(database_url)
|
||||
|
||||
@@ -63,5 +67,13 @@ def run_migrations(engine=None, database_url: str | None = None) -> None: # typ
|
||||
if __name__ == "__main__": # pragma: no cover - appele par l'entrypoint Docker
|
||||
import sys
|
||||
|
||||
from app.config import ConfigurationError
|
||||
|
||||
logging.basicConfig(level="INFO", format="%(levelname)-8s | %(message)s", stream=sys.stdout)
|
||||
run_migrations()
|
||||
try:
|
||||
run_migrations()
|
||||
except ConfigurationError as error:
|
||||
# Message d'exploitation, pas de traceback : c'est le fichier .env qu'il
|
||||
# faut corriger, pas le code.
|
||||
print(str(error), file=sys.stderr)
|
||||
raise SystemExit(2) from None
|
||||
|
||||
@@ -4,16 +4,22 @@ Le point sensible : `X-Forwarded-For` est un en-tete que n'importe quel client
|
||||
peut ecrire. Le croire sans condition permettrait a un client direct de changer
|
||||
d'identite a chaque requete et de contourner toute limite.
|
||||
|
||||
La confiance est donc explicite et desactivee par defaut
|
||||
(`TRUST_PROXY_HEADERS`). Quand elle est activee, on lit la DERNIERE valeur de
|
||||
`X-Forwarded-For` : un reverse proxy correctement configure ajoute l'adresse
|
||||
qu'il a reellement vue a la fin de la liste, tandis que les valeurs precedentes
|
||||
peuvent avoir ete forgees par le client. Cette lecture suppose un seul proxy
|
||||
directement devant OpenMind, ce qui correspond au deploiement documente.
|
||||
La confiance est donc doublement conditionnee :
|
||||
|
||||
1. `TRUST_PROXY_HEADERS` doit etre actif ;
|
||||
2. la connexion doit provenir d'un reseau declare dans `TRUSTED_PROXY_CIDRS`.
|
||||
|
||||
Sans ces deux conditions, l'en-tete est ignore et seule l'adresse reelle de la
|
||||
socket compte. OpenMind suppose **un reverse proxy frontal de confiance** : on
|
||||
lit la derniere valeur de la chaine, celle que ce proxy a ajoutee, les
|
||||
precedentes pouvant avoir ete forgees par le client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from collections.abc import Sequence
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
FORWARDED_FOR = "x-forwarded-for"
|
||||
@@ -21,19 +27,60 @@ REAL_IP = "x-real-ip"
|
||||
|
||||
UNKNOWN_CLIENT = "unknown"
|
||||
|
||||
IpNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network
|
||||
|
||||
def resolve_client_ip(request: Request, *, trust_proxy_headers: bool) -> str:
|
||||
if trust_proxy_headers:
|
||||
|
||||
def normalize_ip(value: str) -> str:
|
||||
"""Forme canonique d'une adresse, pour que la cle de limitation soit unique.
|
||||
|
||||
Sans cela, `2001:db8::1` et `2001:0db8:0000:0000:0000:0000:0000:0001`
|
||||
designeraient deux clients differents, et il suffirait de reecrire son
|
||||
adresse autrement pour repartir avec un compteur neuf.
|
||||
"""
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return UNKNOWN_CLIENT
|
||||
|
||||
# Un port peut trainer dans un en-tete mal configure : [::1]:443, 1.2.3.4:80.
|
||||
if text.startswith("[") and "]" in text:
|
||||
text = text[1 : text.index("]")]
|
||||
elif text.count(":") == 1 and "." in text:
|
||||
text = text.split(":", 1)[0]
|
||||
|
||||
try:
|
||||
return str(ipaddress.ip_address(text))
|
||||
except ValueError:
|
||||
# Adresse illisible : on la garde telle quelle, bornee, plutot que de
|
||||
# regrouper tous les clients douteux sous une meme cle.
|
||||
return text[:64]
|
||||
|
||||
|
||||
def is_trusted_peer(host: str, networks: Sequence[IpNetwork]) -> bool:
|
||||
if not networks:
|
||||
return False
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return False
|
||||
return any(address in network for network in networks)
|
||||
|
||||
|
||||
def resolve_client_ip(
|
||||
request: Request,
|
||||
*,
|
||||
trust_proxy_headers: bool,
|
||||
trusted_networks: Sequence[IpNetwork] = (),
|
||||
) -> str:
|
||||
peer = request.client.host if request.client and request.client.host else ""
|
||||
|
||||
if trust_proxy_headers and is_trusted_peer(peer, trusted_networks):
|
||||
forwarded = request.headers.get(FORWARDED_FOR)
|
||||
if forwarded:
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if parts:
|
||||
return parts[-1]
|
||||
return normalize_ip(parts[-1])
|
||||
real_ip = request.headers.get(REAL_IP)
|
||||
if real_ip and real_ip.strip():
|
||||
return real_ip.strip()
|
||||
return normalize_ip(real_ip)
|
||||
|
||||
client = request.client
|
||||
if client and client.host:
|
||||
return client.host
|
||||
return UNKNOWN_CLIENT
|
||||
return normalize_ip(peer) if peer else UNKNOWN_CLIENT
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
@@ -33,6 +34,13 @@ logger = logging.getLogger(__name__)
|
||||
# Duree maximale acceptee pour une expiration explicite (10 ans).
|
||||
MAX_EXPIRY_DAYS = 3650
|
||||
|
||||
# Serialise "compter puis inserer" pour que deux creations simultanees ne
|
||||
# franchissent pas ensemble la derniere place. Suffisant pour l'architecture
|
||||
# d'OpenMind : un seul processus applicatif devant un SQLite local. Avec
|
||||
# plusieurs workers, la limite redeviendrait un garde-fou approximatif (voir
|
||||
# README, limitations connues).
|
||||
_capacity_lock = threading.Lock()
|
||||
|
||||
|
||||
def share_ref(share_token: str) -> str:
|
||||
"""Reference courte et non reversible d'un partage, pour les journaux.
|
||||
@@ -131,7 +139,6 @@ def create_share(
|
||||
"""
|
||||
payload = serialize_map(document)
|
||||
_guard_payload_size(payload)
|
||||
_guard_capacity(session)
|
||||
|
||||
management_token = generate_management_token()
|
||||
share = SharedMap(
|
||||
@@ -142,8 +149,11 @@ def create_share(
|
||||
map_data=payload,
|
||||
expires_at=resolve_expiry(expires_in_days, explicit=expiry_explicit),
|
||||
)
|
||||
session.add(share)
|
||||
session.commit()
|
||||
|
||||
with _capacity_lock:
|
||||
_guard_capacity(session)
|
||||
session.add(share)
|
||||
session.commit()
|
||||
session.refresh(share)
|
||||
|
||||
logger.info(
|
||||
|
||||
Reference in New Issue
Block a user