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

161
README.md
View File

@@ -121,6 +121,7 @@ Toutes definies dans `.env` (modele : `.env.example`). Aucun secret n'est requis
|---|---|---|
| `APP_NAME` | `OpenMind` | Nom du produit, affiche dans l'interface et l'onglet |
| `APP_PORT` | `8080` | Port publie sur l'hote (le conteneur ecoute toujours 8080) |
| `APP_BIND` | `0.0.0.0` | Interface de publication. `127.0.0.1` en production derriere un proxy local |
| `DATABASE_URL` | `sqlite:////app/data/openmind.db` | Base des cartes partagees |
| `MAX_MAP_SIZE_MB` | `10` | Taille maximale d'une carte envoyee au serveur |
| `MAX_NODES` | `5000` | Nombre maximal de noeuds par carte |
@@ -140,10 +141,26 @@ Exposition publique :
| `DEFAULT_SHARE_EXPIRY_DAYS` | `30` | Duree de vie par defaut d'un nouveau partage. `0` = permanent |
| `ALLOW_PERMANENT_SHARES` | `true` | Autorise l'option "Jamais" dans le choix d'expiration |
| `TRUST_PROXY_HEADERS` | `false` | Croire `X-Forwarded-For`. **A n'activer que derriere un reverse proxy de confiance** |
| `TRUSTED_PROXY_CIDRS` | vide | Reseaux depuis lesquels `X-Forwarded-For` est accepte. Obligatoire si `TRUST_PROXY_HEADERS=true` |
Format des limites de debit : `<nombre>/<fenetre>`, ou la fenetre vaut `second`,
`minute`, `hour` ou `day`. Une valeur vide, `0` ou `off` desactive la limite
concernee.
`minute`, `hour` ou `day`.
**La configuration est validee au demarrage.** Desactiver une limite se fait
avec `0`, `off`, `none`, `disabled` ou une valeur vide. Toute autre valeur
illisible arrete l'application avec un message nommant la variable :
```
Configuration invalide :
SHARE_CREATE_RATE_LIMIT : '10/hours' n'est pas une limite de debit valide :
fenetre 'hours' inconnue. Fenetres acceptees : day, hour, minute, second.
```
Une faute de frappe n'est pas une desactivation volontaire : sans ce controle,
une instance publique tournerait sans protection en croyant en avoir une. Sont
validees de la meme facon : `MAX_SHARED_MAPS >= 0`, `MAX_SHARE_SIZE_MB >= 0`
(`0` = reprend `MAX_MAP_SIZE_MB`), `MAX_MAP_SIZE_MB > 0`,
`DEFAULT_SHARE_EXPIRY_DAYS >= 0`, et chaque entree de `TRUSTED_PROXY_CIDRS`.
Le nom du produit est centralise : cote backend via `APP_NAME`, cote frontend
dans `frontend/src/config/app.ts`. Le frontend recupere le nom a l'execution via
@@ -346,6 +363,61 @@ docker compose exec openmind python -m app.cli restore /app/data/backups/openmin
| `backup <chemin>` | Sauvegarde a chaud via l'API SQLite. `--yes` ecrase un fichier existant |
| `restore <chemin>` | Restauration prudente : verification, copie de securite, puis remplacement |
`backup` accepte `--keep N` : apres ecriture, seules les `N` sauvegardes les
plus recentes du meme dossier sont conservees, la nouvelle comprise. Sans
`--keep`, rien n'est jamais supprime.
### Automatiser la purge et la sauvegarde
OpenMind n'embarque volontairement **aucun planificateur** : ces commandes
s'appellent depuis l'outillage de la machine.
Avec cron, une entree quotidienne :
```cron
# Sauvegarde a 3h15, sept jours de retention
15 3 * * * cd /srv/openmind && docker compose exec -T openmind python -m app.cli backup /app/data/backups/openmind-$(date +\%F).db --yes --keep 7
# Purge des partages expires a 3h30
30 3 * * * cd /srv/openmind && docker compose exec -T openmind python -m app.cli shares purge-expired --yes
```
Dans un fichier cron, `%` doit etre echappe : d'ou `\%F`.
Avec systemd, un service et son minuteur :
```ini
# /etc/systemd/system/openmind-backup.service
[Unit]
Description=Sauvegarde OpenMind
[Service]
Type=oneshot
WorkingDirectory=/srv/openmind
ExecStart=/usr/bin/docker compose exec -T openmind python -m app.cli backup /app/data/backups/openmind-daily.db --yes --keep 7
ExecStart=/usr/bin/docker compose exec -T openmind python -m app.cli shares purge-expired --yes
```
```ini
# /etc/systemd/system/openmind-backup.timer
[Unit]
Description=Sauvegarde quotidienne d'OpenMind
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
```
```bash
systemctl enable --now openmind-backup.timer
```
Les fichiers produits arrivent dans `./data/backups/` sur l'hote : copiez-les
hors de la machine si vous voulez une vraie sauvegarde.
Aucune commande n'affiche le jeton de gestion, son hash, ni le contenu des
cartes. Les jetons de partage y sont masques (`sbcY...BCyw`) : suffisant pour
identifier une ligne, insuffisant pour ouvrir le lien.
@@ -361,11 +433,38 @@ n'est imposee.
Points a respecter :
1. Transmettre l'hote et le protocole d'origine.
2. Activer `TRUST_PROXY_HEADERS=true` **uniquement** dans ce cas : la limitation
de debit lit alors la derniere valeur de `X-Forwarded-For`, celle ajoutee par
votre proxy. Laisse a `false`, un client direct pourrait forger cet en-tete et
changer d'identite a chaque requete.
3. Ne pas mettre en cache `/api/`.
2. **Ne pas publier 8080 sur Internet.** Posez `APP_BIND=127.0.0.1` dans `.env`
si le proxy tourne sur l'hote : OpenMind n'est alors joignable que par lui.
Si le proxy tourne dans Docker, retirez entierement la section `ports` et
placez les deux conteneurs sur un reseau Docker commun.
3. Activer `TRUST_PROXY_HEADERS=true` **et** renseigner `TRUSTED_PROXY_CIDRS`
avec les reseaux d'ou viennent les connexions du proxy. Les deux vont
ensemble : activer la confiance sans declarer de reseau arrete le demarrage.
4. Ne pas mettre en cache `/api/`.
### Comment l'adresse du client est determinee
`X-Forwarded-For` est un en-tete que n'importe qui peut ecrire. OpenMind ne le
croit qu'a deux conditions cumulees :
| `TRUST_PROXY_HEADERS` | Connexion venant d'un reseau de `TRUSTED_PROXY_CIDRS` | Adresse retenue |
|---|---|---|
| `false` | peu importe | adresse reelle de la socket |
| `true` | non | adresse reelle de la socket |
| `true` | oui | derniere valeur de `X-Forwarded-For`, sinon `X-Real-IP`, sinon la socket |
OpenMind suppose **un reverse proxy frontal de confiance**. Un proxy
correctement configure (`$proxy_add_x_forwarded_for` chez Nginx) ajoute a la fin
de la chaine l'adresse qu'il a reellement vue : c'est donc la derniere valeur
qui fait foi, les precedentes pouvant avoir ete forgees par le client. Aucun
mecanisme multi-sauts n'est prevu ; avec plusieurs proxies en cascade, declarez
uniquement celui qui est directement devant OpenMind.
L'adresse retenue est normalisee avec le module standard `ipaddress` : les
differentes ecritures d'une meme IPv6 (`2001:db8::1`,
`2001:0db8:0000:...:0001`, `2001:DB8::1`) donnent une seule cle de limitation,
et il ne suffit donc pas de reecrire son adresse autrement pour repartir avec un
compteur neuf.
Aucune variable `PUBLIC_BASE_URL` n'est necessaire : le serveur ne fabrique
jamais d'URL absolue (il renvoie un chemin relatif `/s/<token>`), et le lien
@@ -393,17 +492,43 @@ server {
}
```
Cote OpenMind, dans `.env` :
```ini
APP_BIND=127.0.0.1
TRUST_PROXY_HEADERS=true
TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
```
Exemple Traefik (labels sur le service `openmind` du `docker-compose.yml`) :
```yaml
labels:
- traefik.enable=true
- traefik.http.routers.openmind.rule=Host(`mind.example.com`)
- traefik.http.routers.openmind.entrypoints=websecure
- traefik.http.routers.openmind.tls.certresolver=letsencrypt
- traefik.http.services.openmind.loadbalancer.server.port=8080
# docker-compose.override.yml — Traefik reste hors du projet.
services:
openmind:
# Plus aucun port publie : seul Traefik joint le conteneur.
ports: !reset []
networks: [proxy]
environment:
TRUST_PROXY_HEADERS: "true"
TRUSTED_PROXY_CIDRS: "172.16.0.0/12" # reseau du conteneur Traefik
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.openmind.rule=Host(`mind.example.com`)
- traefik.http.routers.openmind.entrypoints=websecure
- traefik.http.routers.openmind.tls.certresolver=letsencrypt
- traefik.http.services.openmind.loadbalancer.server.port=8080
networks:
proxy:
external: true
```
Traefik transmet `X-Forwarded-For`, `X-Forwarded-Proto` et `X-Real-IP` par
defaut. Adaptez `TRUSTED_PROXY_CIDRS` au sous-reseau reel du reseau `proxy`
(`docker network inspect proxy`).
Les journaux du reverse proxy contiennent les URL demandees, jetons de partage
compris. Si cela vous derange, configurez leur format ou leur retention : c'est
independant d'OpenMind.
@@ -913,6 +1038,16 @@ modeles, assainissement des URL.
auraient chacun les leurs. C'est un garde-fou contre la saturation triviale
d'une instance publique, pas un quota strict. Un reverse proxy peut ajouter sa
propre limitation si le besoin est plus exigeant.
- **Capacite maximale** : le comptage et l'insertion sont serialises par un
verrou de processus, ce qui empeche deux creations simultanees de franchir
ensemble la derniere place. Cette garantie vaut pour l'architecture livree :
un seul service applicatif devant un SQLite local. Avec plusieurs workers ou
plusieurs conteneurs, `MAX_SHARED_MAPS` redeviendrait un garde-fou approximatif
et non un quota transactionnel.
- **Un seul niveau de proxy** : OpenMind suppose un reverse proxy frontal de
confiance et lit la derniere valeur de `X-Forwarded-For`. Avec plusieurs
proxies en cascade, declarez uniquement celui qui est directement devant
l'application ; aucun mecanisme multi-sauts n'est prevu.
- **Partage expire et information** : un lien expire repond `410` et non `404`,
ce qui revele qu'un partage a existe a cette adresse. C'est assume : sans cela,
la page publique ne pourrait pas expliquer la difference entre un lien expire