Aller au contenu

Sécurité et exposition

Voir aussi Rôles et accès pour savoir qui a le droit de quoi, dans chacun des trois modes d'authentification.

Romule n'a pas de TLS

Il parle du HTTP en clair. Tout ce qui est joignable depuis internet a besoin d'un proxy inverse terminant le HTTPS devant lui. C'est une limite délibérée, pas un oubli : une pile TLS écrite à la main serait une plus mauvaise idée que de déléguer à nginx, Caddy ou Traefik.

Le piège du proxy inverse

Un proxy sur la même machine fait paraître toutes les requêtes comme venant de 127.0.0.1. Or Romule accorde tous les droits aux requêtes locales : une mise en place naïve laisserait donc entrer n'importe qui depuis internet dès l'instant où l'on place un proxy devant.

Romule ignore donc X-Forwarded-For et X-Real-IP tant que tu ne nommes pas le proxy toi-même :

ROMULE_TRUSTED_PROXIES=127.0.0.1,::1        # proxy sur l'hôte
ROMULE_TRUSTED_PROXIES=172.16.0.0/12        # proxy en conteneur

Sans cela, un en-tête transmis n'accorde rien — et une requête qui en porte un n'est pas non plus traitée comme locale. Avec, l'adresse du client est prise dans l'en-tête, mais seulement si la requête vient réellement d'un proxy listé.

Ne saute pas cette étape

Derrière un proxy et sans ROMULE_TRUSTED_PROXIES, tous les utilisateurs partagent une seule adresse apparente. La limitation de débit et les décisions d'accès se dégradent l'une comme l'autre.

Comment l'accès est décidé

Dans cet ordre :

  1. Authentification active ? Une session valide est exigée — y compris depuis la machine elle-même. Activer un SSO et rester joignable sans mot de passe depuis l'hôte viderait la mesure de son sens sur un poste partagé.
  2. Requête venue de cette machine ? Autorisée.
  3. Un jeton est posé ? Il doit correspondre, comparé en temps constant.
  4. Sinon — autorisée seulement si lan_access est actif.

Le premier accès

Une installation que personne n'a revendiquée répond à tout le monde. C'est délibéré, et c'est ce que fait tout outil comparable : Jellyfin, Home Assistant et la pile *arr s'ouvrent tous sur leur écran de mise en route.

L'alternative a été essayée et ne marche pas. Romule engendrait un jeton, l'affichait dans les journaux, et refusait tout le reste. Il fallait le recopier depuis un terminal sur chaque appareil — téléphone, tablette, portable — et le premier compte restait impossible à créer, parce que c'était refusé sauf si la requête venait de 127.0.0.1. Sous Docker, c'est personne : une requête passant par le port publié arrive par le pont. Le chemin d'installation principal s'achevait sur un mur qu'aucun jeton n'ouvrait.

C'est donc l'étape « Ton accès » de l'assistant qui revendique l'installation, et elle ne peut pas être sautée :

  • un compte — le premier devient administrateur, l'authentification interne est activée du même geste, et ce navigateur est connecté sur-le-champ ;
  • aucun mot de passe — un choix valable sur un réseau de confiance, et celui que fait la plupart des outils auto-hébergés.

Tant qu'aucune des deux réponses n'est donnée, le terminal le dit à chaque démarrage, en capitales, et l'audit le signale.

La fenêtre que cela accepte

Entre docker compose up et ta réponse, quiconque atteint l'adresse peut revendiquer l'installation. C'est la même fenêtre de confiance au premier accès que celle avec laquelle vit toute cette famille d'outils. Deux choses la raccourcissent : l'assistant est la première chose affichée, et il ne peut pas être écarté.

« Aucun mot de passe » vaut pour tout le monde

Le choix n'est pas restreint aux adresses privées. Derrière un proxy inverse ou un nom de domaine, une installation ouverte l'est pour qui trouve l'adresse. Romule le dit à côté du bouton plutôt que de décider à ta place — mais c'est ton choix et ton exposition.

Depuis le terminal

Le jour où c'est l'interface qui est en panne — un mot de passe oublié sans second compte, un SSO dont le fournisseur ne répond plus, un close regretté depuis le mauvais réseau — l'accès se règle là où sont les données :

romule access status    # ce qui protège cette installation
romule access open      # aucun mot de passe (DÉSACTIVE l'authentification)
romule access close     # exiger de nouveau une connexion
romule user create toi@exemple.fr

open désactive l'authentification en plus d'ouvrir le réseau. Il le faut : le serveur consulte la session avant le réglage, donc lan_access seul n'aurait rien rouvert tout en répondant « ouvert ». close la remet en service — les comptes n'ont jamais été touchés.

Un jeton, si tu en veux un

Plus rien n'en engendre, mais ROMULE_TOKEN fonctionne toujours et romule token reset en pose un délibérément. Quand un jeton protège l'installation, la page de refus porte un champ où le coller et un cookie s'en souvient pour ce navigateur, pendant un an.

romule token show     # le réafficher
romule token reset    # le remplacer

La feuille de style, et elle seule, est servie à un client qui n'est pas encore entré : la page de refus la référence, et sans elle la page arrivait sans habillage et se lisait comme un serveur en panne plutôt que comme une porte. app.js, l'interface et toutes les routes restent derrière le contrôle.

Comptes et rôles

  • Le premier compte créé est l'administrateur. Lui seul change les réglages, gère les comptes ou lance les actions destructrices.
  • Ce premier compte ne peut être créé que depuis la machine qui héberge la ludothèque — sinon « le premier compte gouverne » voudrait dire « le premier appareil du réseau gouverne ».
  • Il n'y a jamais zéro administrateur : le dernier ne peut pas être supprimé.
  • Les mots de passe passent par scrypt (N=2¹⁷). Le second facteur TOTP est disponible par compte, et l'écran de mise en route affiche un QR code à scanner — dessiné par romule/qr.py, un encodeur QR écrit sur la bibliothèque standard, parce que Romule n'a aucune dépendance à l'exécution et n'en prendrait pas une pour une seule image. La clé reste affichée, pour qui ne peut pas scanner.

Un QR code subtilement faux a l'air parfait et ne scanne simplement pas : test_qr.py relit donc la matrice — il retrouve le masque dans l'information de format, le défait, parcourt le même zigzag, dés-entrelace les blocs et rend la charge utile, pour chaque version, à la taille qui la force. Il vérifie aussi les syndromes de Reed-Solomon, un calcul indépendant de la division qui les a produits, et les octets correcteurs reproduisent le vecteur de référence publié pour HELLO WORLD.

Parcourir le système de fichiers de l'hôte

Le sélecteur de ludothèque liste les dossiers de la machine qui fait tourner Romule. C'est une primitive de divulgation, et elle est traitée comme telle :

  • elle est réservée à l'administrateur, comme toutes les routes destructrices ;
  • elle rend des dossiers seulement — aucun nom de fichier ne quitte le serveur. Le seul nombre supplémentaire est un décompte de jeux reconnus, parce que c'est lui qui permet de distinguer ta ludothèque d'un dossier qui lui ressemble ;
  • choisir un dossier obéit à la même règle que le parcourir : taper un chemin n'est donc pas un moyen de contourner ROMULE_BASES.

ROMULE_BASES n'est pas renseigné par défaut, et c'est délibéré : dans un conteneur, la frontière est la liste des volumes:, appliquée par le noyau plutôt que par du code applicatif ; sur une installation directe, c'est le compte Unix qui fait tourner le service. Une liste blanche applicative par dessus donnerait surtout l'impression d'une frontière. Renseigne ROMULE_BASES quand tu tournes en direct sous un compte large.

Bornes appliquées

Borne Défaut Pourquoi
Taille d'un envoi 64 Gio Un disque saturé est un déni de service
Espace libre gardé 2 Gio Refuser l'écriture plutôt que remplir le disque
Délai de socket 300 s Une connexion lente ne doit pas retenir un fil
Connexions 64 Concurrence bornée
Requêtes 600/min par client Limitation sur tout /api/*
Confinement des chemins Dossiers de plateforme, extensions et title ID sont validés
Emplacement de la ludothèque Refusé s'il est en lecture seule, ou s'il s'agit de ton dossier personnel, d'une racine de disque ou d'un dépôt de code

Faiblesses connues

script-src vaut 'self' — aucun script en ligne. C'était la plus grande faiblesse connue du projet jusqu'à la 0.2.0, et la retirer a demandé toute une phase : 153 gestionnaires d'événements en ligne, chacun étant une raison pour le navigateur d'accepter des scripts écrits dans la page.

L'ordre a compté. Un bouton qui cesse de répondre est invisible côté serveur — aucune requête n'échoue, rien n'est journalisé — le filet a donc été écrit d'abord : un test qui parcourt chaque écran, trouve chaque élément cliquable, et échoue si l'un n'a pas de gestionnaire. L'écrire honnêtement a demandé trois corrections, et chacune mérite d'être dite parce que chacune était une idée fausse sur le DOM :

  • un gestionnaire posé en propriété (el.onclick = fn) n'apparaît dans aucun attribut, et querySelectorAll('[onclick]') ne le voit pas ;
  • un <select> ou une case à cocher répond nativement — le geste a un effet visible et la valeur est relue à l'enregistrement. Pas inerte, juste sans code ;
  • document n'est pas un Element : remonter les parentElement ne l'atteint jamais — or c'est là que porte la délégation.

Les gestionnaires portent désormais leur action en donnée : data-act nomme l'action, data-arg son argument. ACTES est une liste blanche, pas un appel dynamique — app[el.dataset.act]() aurait été soixante lignes plus court et aurait laissé n'importe quel attribut atteindre n'importe quelle méthode, y compris celles qui suppriment.

Le gain de sécurité n'est pas un échappement plus solide, c'est un analyseur en moins. Une valeur placée dans onclick="app.faire('ICI')" en traversait deux : l'analyseur HTML décodait les entités, puis le moteur JavaScript compilait ce qu'il en restait — le &#39; d'esc() redevenait donc une apostrophe avant que le script ne soit lu, la chaîne se refermait, et la suite de la valeur devenait du code. Un nom de fichier suffisait à en fabriquer un, et la clé d'une carte est le chemin du fichier. C'était l'XSS stockée corrigée en 0.1.0, et jsq() en était le bon correctif.

Dans data-arg="ICI", il n'y a qu'un analyseur et rien n'est jamais compilé. esc() suffit — et un test vérifie qu'il est présent sur les 28 sites, car un guillemet double dans un nom de fichier sortirait sinon de l'attribut.

jsq() reste défini, avec ses tests d'aller-retour, comme garde-fou du jour où quelqu'un réintroduira un gestionnaire en ligne. Deux invariants plus forts remplacent son ancien rôle : aucun attribut on*= n'est engendré nulle part, dans aucun des deux fichiers, et le test navigateur écoute securitypolicyviolation — une violation de la politique de contenu ne casse pas la page, elle écrit une ligne en console et continue, ce qui est exactement le genre d'échec silencieux que ce projet n'arrête pas de trouver. Ce contrôle est lui-même prouvé : il injecte un script en ligne et vérifie que le navigateur refuse de l'exécuter.

style-src tolère encore 'unsafe-inline'. Les attributs style= restent nombreux dans le balisage engendré. Un style ne s'exécute pas : c'est une faiblesse d'une autre nature que la précédente — gardée, et dite.

Pas de TLS, comme ci-dessus.

Les fonctions bêta portent leurs propres risques, listés là-bas. La plus sensible du point de vue de la sécurité est le SSO OpenID Connect.

Vérifier ta propre installation

python3 -m romule.audit

Il rend compte de la configuration réellement en service : exposition, authentification, en-têtes, permissions de fichiers, dépendances, version de Python. L'intégration continue échoue sur tout ce qu'il classe grave. Lance-le après chaque changement.

Signaler une vulnérabilité

En privé, pas dans le suivi de tickets. Voir SECURITY.md.