FR EN

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.

7 min de lecture13 sectionsChapitre 13 / 22

Le principe

Ce qui est protégé, c'est l'écriture

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

PointRègle
Génération24 octets aléatoires, en base64url. Jamais composée à la main.
PortéeUne clé par site. Jamais de clé d'agence : une fuite reste circonscrite à un client.
StockageSeule l'empreinte argon2id existe côté serveur, en variable d'environnement. La clé n'est écrite nulle part.
Paramètresm=19456, t=2, p=1 — recommandation OWASP, portée par l'empreinte elle-même.
ComparaisonExclusivement 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.
format de l'empreinte text
$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.

Trois interdits absolus
  • Ne jamais servir EDITOR_KEY_HASH au 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

CookieContenuAttributs
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

Le point de sécurité le plus critique du projet

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.

RouteBudgetCe qu'il protège
/api/auth5 par quart d'heurela clé du site
/api/content60 par 5 minutesle quota de l'API du dépôt
/api/save30 par 5 minutesle dépôt, le quota
/api/upload30 par quart d'heurele 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.

Sans la liaison

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 :

  1. 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.

  2. 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.

  3. Identité

    verifyAuth, et rien d'autre. Aucune route n'évalue l'identité par elle-même.

  4. 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ôleRègle
Cheminsrc/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 contenu100 Ko
Taille de l'enveloppe128 Ko
SchémaLe même objet Zod qu'au build.
MédiasUne image doit désigner un fichier de la liste blanche ; une vidéo, un couple fournisseur/identifiant cohérent.
Identifiants d'itemsUniques dans leur liste.
Agression caractériséescript, iframe, on…=, javascript:refusés, pas seulement nettoyés.
AssainissementAppliqué ensuite à tous les champs, quelle que soit leur origine.
Verrou optimisteLa version lue à l'ouverture est comparée avant d'écrire.
Message de publicationUne seule ligne, 120 caractères, sans caractère de contrôle ni marque d'inversion d'écriture.
Pourquoi refuser plutôt que nettoyer

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

VariableNatureNe doit jamais…
GIT_TOKENJeton d'écriture du compte machine…apparaître dans une réponse, un fichier servi, un journal, localStorage.
EDITOR_KEY_HASHEmpreinte de la clé…être servie au navigateur.
SESSION_SECRETSignature 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.

  1. Générer

    dans le projet du site bash
    npm run make:key
  2. Remplacer EDITOR_KEY_HASH chez l'hébergeur

  3. 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.

  4. Redéployer

    Les variables ne sont relues qu'au déploiement.

  5. 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

après déploiement bash
# 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           # → 405

Puis, à la main :

  • Cinq clés fausses de suite sur /admin aboutissent à 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_LIMIT est 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émoinoui — et sans effet : toutes les écritures sont refusées.
Lire le contenu du siteoui — il est public.
Lire le fichier de contenu via /api/contentnon — session requise.
Publier une modificationnon — session requise.
Écrire ailleurs que dans src/content/pages/…non — même avec une session valide.
Deviner la clé par essaisnon — 5 tentatives par quart d'heure, clé de 24 octets aléatoires.
Récupérer le jeton Gitnon — 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.