Chapitre 13Éditer
Sécurité et authentification
Ce qui est protégé, c'est l'écriture, pas l'interface. La clé, la session, les budgets de débit, l'ordre des contrôles, et la procédure de rotation.
Le principe
Quelqu'un qui ouvre l'overlay sans la clé modifie le DOM de son propre navigateur — sans
plus de conséquence qu'avec les outils de développement. Ne pas dépenser d'effort à
verrouiller l'interface : tout l'effort va sur les routes /api/*.
La surface d'authentification se réduit donc à une route, et les trois autres se contentent de vérifier un cookie de session.
La clé de site
| Point | Règle |
|---|---|
| Génération | 24 octets aléatoires, en base64url. Jamais composée à la main. |
| Portée | Une clé par site. Jamais de clé d'agence : une fuite reste circonscrite à un client. |
| Stockage | Seule l'empreinte argon2id existe côté serveur, en variable d'environnement. La clé n'est écrite nulle part. |
| Paramètres | m=19456, t=2, p=1 — recommandation OWASP, portée par l'empreinte elle-même. |
| Comparaison | Exclusivement côté serveur, à durée constante. |
| Coût | ~350 ms par vérification, une fois par session de 8 h. C'est ce qui rend la force brute impraticable. |
$argon2id$v=19$m=19456,t=2,p=1$<sel base64url>$<empreinte base64url>Les paramètres étant dans l'empreinte, les durcir plus tard ne demande que de régénérer la clé — sans toucher au code.
- Ne jamais servir
EDITOR_KEY_HASHau navigateur : un hash exposé est attaquable hors ligne. - Ne jamais comparer la clé en JavaScript client. C'est le raccourci tentant du développement de l'overlay, et une faille totale.
- Ne jamais journaliser la clé, l'empreinte, le cookie ou le jeton Git.
La session
| Cookie | Contenu | Attributs |
|---|---|---|
inline_session |
Jeton signé en HMAC-SHA256 avec SESSION_SECRET. Rien n'est stocké côté serveur. |
HttpOnly, Secure, SameSite=Strict, 8 h |
inline_edit |
Aucun secret. Un simple témoin qui dit à la page de charger l'overlay. | lisible, falsifiable — sans conséquence |
Forger inline_edit n'affiche qu'une interface dont toutes les écritures seront
refusées : les routes vérifient inline_session, jamais le témoin. Remplacer
SESSION_SECRET ferme instantanément toutes les sessions ouvertes.
La limitation de débit
Sans elle, la clé tombe en force brute. C'est aussi le contrôle le plus facile à repousser « pour plus tard », parce que le code fonctionne parfaitement sans lui. Il fait partie du premier lot, pas du durcissement final.
| Route | Budget | Ce qu'il protège |
|---|---|---|
/api/auth | 5 par quart d'heure | la clé du site |
/api/content | 60 par 5 minutes | le quota de l'API du dépôt |
/api/save | 30 par 5 minutes | le dépôt, le quota |
/api/upload | 30 par quart d'heure | le dépôt, le quota |
Seul le premier protège un secret. Les autres protègent le dépôt contre une session volée ou un script parti en boucle : ils sont larges, un humain qui édite sa page ne les approche jamais.
La liaison RATE_LIMIT
Le comptage a besoin d'un état partagé entre les instances de la fonction. Déclarez chez
l'hébergeur un espace clé-valeur nommé RATE_LIMIT.
Le comptage retombe sur un compteur en mémoire : suffisant en local, insuffisant en production, où chaque instance compterait pour elle seule et où un démarrage à froid remettrait tout à zéro.
Les espaces clé-valeur des hébergeurs sont à cohérence différée : deux tentatives
quasi simultanées peuvent lire le même compteur. La protection reste efficace contre une force
brute — qui suppose des milliers d'essais — mais elle n'est pas exacte à l'unité. Pour un
comptage strict, implémenter RateLimitStore sur un stockage fortement cohérent.
L'ordre des contrôles
Chaque route refuse dans cet ordre, et l'ordre compte autant que les règles :
-
Débit
Refuser un appelant qui insiste ne demande pas de savoir qui il est — et une session volée ne doit pas pouvoir marteler l'API du dépôt.
-
Taille annoncée
Inutile de mettre 200 Mo en mémoire pour découvrir ensuite qu'ils dépassent le plafond. L'en-tête peut mentir : ce n'est pas la protection, c'est l'économie.
-
Identité
verifyAuth, et rien d'autre. Aucune route n'évalue l'identité par elle-même. -
Taille reçue, forme, chemin, schéma, verrou, écriture
Le vrai plafond est mesuré sur les octets reçus, pas sur ce qui était annoncé.
Un plafond de taille placé après la lecture du corps ne protège de rien. C'est pour cela que les tests appellent les routes directement plutôt que de tester les règles isolément : c'est le seul moyen de vérifier l'ordre.
Ce que la fonction d'écriture vérifie
| Contrôle | Règle |
|---|---|
| Chemin | src/content/pages/{langue déclarée}/{page}.json. Un .., un antislash, un octet nul ou une séquence encodée échouent d'eux-mêmes. |
| Taille du contenu | 100 Ko |
| Taille de l'enveloppe | 128 Ko |
| Schéma | Le même objet Zod qu'au build. |
| Médias | Une image doit désigner un fichier de la liste blanche ; une vidéo, un couple fournisseur/identifiant cohérent. |
| Identifiants d'items | Uniques dans leur liste. |
| Agression caractérisée | script, iframe, on…=, javascript: — refusés, pas seulement nettoyés. |
| Assainissement | Appliqué ensuite à tous les champs, quelle que soit leur origine. |
| Verrou optimiste | La version lue à l'ouverture est comparée avant d'écrire. |
| Message de publication | Une seule ligne, 120 caractères, sans caractère de contrôle ni marque d'inversion d'écriture. |
Un collage depuis un traitement de texte n'apporte jamais de <script> :
l'overlay l'a déjà retiré. En voir arriver un signifie que la route est appelée directement.
Nettoyer serait suffisant techniquement, mais refuser laisse une trace dans les journaux.
Pourquoi l'assainissement serveur n'utilise pas DOMPurify
DOMPurify a besoin d'un DOM, que le runtime des fonctions ne fournit pas. Avec un DOM en
JavaScript pur, DOMPurify ne lève aucune erreur : il passe
isSupported à faux et renvoie son entrée telle quelle. Vérifié dans le runtime, un
<script> et un href="javascript:" ressortaient intacts.
L'assainisseur serveur reconstruit donc le fragment depuis son analyse syntaxique : seule la liste blanche est réécrite, le reste n'existe pas dans le résultat. Un test soumet le même corpus aux deux implémentations — navigateur et serveur — et compare les sorties : c'est lui qui garantit qu'elles ne divergent pas.
Les secrets
| Variable | Nature | Ne doit jamais… |
|---|---|---|
GIT_TOKEN | Jeton d'écriture du compte machine | …apparaître dans une réponse, un fichier servi, un journal, localStorage. |
EDITOR_KEY_HASH | Empreinte de la clé | …être servie au navigateur. |
SESSION_SECRET | Signature des cookies | …être partagée entre sites. |
Sur GitHub, le jeton doit être :
- celui d'un compte machine dédié, pas d'une personne ;
- à portée restreinte (fine-grained), limité au seul dépôt du site ;
- avec la permission Contents : Read and write, et rien d'autre.
Deux contrôles automatiques veillent : check-logs.mjs refuse un
console.* qui évaluerait un jeton, une empreinte, un cookie ou un objet qui les
contient ; check-secrets.mjs vérifie qu'aucun secret ne se trouve dans le dossier
de build. Voir Contrôles et tests.
Rotation de la clé
À faire au départ d'un collaborateur, en cas de doute, ou périodiquement.
-
Générer
dans le projet du site bash npm run make:key -
Remplacer
EDITOR_KEY_HASHchez l'hébergeur -
Remplacer
SESSION_SECRET— si nécessaireÀ faire dans un départ ou un doute : cela ferme immédiatement toutes les sessions ouvertes. Sinon, les sessions en cours restent valables jusqu'à leur terme de 8 h.
-
Redéployer
Les variables ne sont relues qu'au déploiement.
-
Transmettre la nouvelle clé
Par un canal séparé de celui qui porte l'adresse d'édition.
L'ancienne clé cesse de fonctionner à l'étape 4. Il n'y a pas de période de recouvrement : c'est volontaire — deux clés valables en même temps est le genre de confort dont on oublie de sortir.
Vérifier une installation
# Le contenu est bien dans le HTML brut
curl -s https://le-site.fr/ | grep -c "un titre de la page" # → 1
# L'écriture est fermée sans session
curl -s -o /dev/null -w "%{http_code}" -X POST https://le-site.fr/api/save # → 401
# Aucune méthode inattendue n'est ouverte
curl -s -o /dev/null -w "%{http_code}" https://le-site.fr/api/save # → 405Puis, à la main :
- Cinq clés fausses de suite sur
/adminaboutissent à un message d'attente. Si la sixième tentative est encore refusée par « clé incorrecte », la limitation de débit n'est pas active — ne pas livrer. - La liaison
RATE_LIMITest déclarée. - Les variables sont posées en secrets d'exécution, pas en variables de build.
- Le jeton est restreint à ce seul dépôt.
- La clé a été transmise, et effacée de partout ailleurs.
Ce qu'un attaquant peut, et ne peut pas
| Sans la clé | Possible ? |
|---|---|
| Ouvrir l'overlay en forgeant le témoin | oui — et sans effet : toutes les écritures sont refusées. |
| Lire le contenu du site | oui — il est public. |
Lire le fichier de contenu via /api/content | non — session requise. |
| Publier une modification | non — session requise. |
Écrire ailleurs que dans src/content/pages/… | non — même avec une session valide. |
| Deviner la clé par essais | non — 5 tentatives par quart d'heure, clé de 24 octets aléatoires. |
| Récupérer le jeton Git | non — il ne quitte jamais la fonction. |
Passer à plusieurs utilisateurs
Le besoin se traite en remplaçant une seule implémentation :
verifyAuth / createSession, par une authentification déléguée
(Cloudflare Access, Supabase Auth, un fournisseur d'identité). C'est hors du périmètre v1 —
mais rien dans le reste du code ne doit empêcher la bascule, et c'est la raison pour laquelle
aucune vérification d'identité n'existe ailleurs.