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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user