@mostajs/garde-http
v0.1.0
Published
Cette requête est-elle légitime, et que dit-on au navigateur ? Contrôle CSRF par métadonnées de requête (Sec-Fetch-Site, Origin) et en-têtes de sécurité (CSP à nonce, frame-ancestors, nosniff, HSTS). Zéro dépendance, sans état.
Maintainers
Readme
@mostajs/garde-http
Auteur : Dr Hamid MADANI [email protected] · Licence : AGPL-3.0-or-later · Niveau N0, zéro dépendance, Node ≥ 20
Deux décisions qu'une application node:http doit prendre à chaque requête, et qu'elle finit sinon
par réécrire à sa façon — ou par oublier :
- Une requête qui modifie vient-elle bien de mon site ? —
requeteLegitime(req, { origine }) - Quels en-têtes protègent la page que je rends ? —
enTetesSecurite({ https })
Le module décide ; il n'écrit rien dans la réponse. Pas d'état, pas de configuration globale, pas de dépendance.
D'où il vient
De l'audit de sécurité de QATrax du 30/09/2026. Deux constats :
- V-08 — depuis
clienta.sandbox.qatrax.dev, une page pouvait régénérer le jeton d'intégration declientb.sandbox.qatrax.dev. Le cookieSameSite=Laxpart vers un « même site », et deux sous-domaines d'un même domaine sont le même site pour le navigateur — mais deux clients différents pour nous. - V-09 — aucune page ne portait d'en-tête de sécurité : la page de connexion pouvait être encadrée dans un site tiers.
mdseal avait le même manque. Trois applications allaient donc écrire la même chose : c'est un module.
1. Le contrôle d'origine
import { requeteLegitime } from '@mostajs/garde-http';
const v = requeteLegitime(req, { origine: 'https://qatrax.dev' });
if (!v.ok) { res.writeHead(403); return res.end(v.motif); } // 'site-tiers' | 'origine-etrangere'Il juge sur les en-têtes que le navigateur pose lui-même et qu'aucune page ne peut falsifier :
| la requête | verdict |
|---|---|
| GET, HEAD, OPTIONS | admise — une méthode sûre ne modifie rien |
| Sec-Fetch-Site: same-origin (ou none) | admise |
| Sec-Fetch-Site: same-site — un sous-domaine voisin | refusée |
| Sec-Fetch-Site: cross-site | refusée |
| pas de Sec-Fetch-Site, Origin admis | admise |
| pas de Sec-Fetch-Site, Origin étranger ou null | refusée |
| ni l'un ni l'autre — curl, intégration continue, serveur à serveur | admise |
Le dernier cas n'est pas une faille : le CSRF est une attaque par le navigateur de la victime, qui porte son cookie. Un client machine n'a pas de cookie volé à rejouer ; il porte ses propres secrets. C'est ce qui permet de protéger les pages sans casser l'automatisation.
Pourquoi pas un jeton caché dans chaque formulaire ? Il faut un état côté serveur, modifier chaque
formulaire, et il ne protège pas les fetch. Les métadonnées de requête couvrent tout, sans état.
2. Les en-têtes
import { enTetesSecurite } from '@mostajs/garde-http';
const ENTETES = enTetesSecurite({ https: true, csp: { 'style-src': "'self' 'unsafe-inline'" } });
for (const [k, v] of Object.entries(ENTETES)) res.setHeader(k, v); // avant writeHead| en-tête | effet |
|---|---|
| Content-Security-Policy | aucun script inline ni externe non déclaré, aucun encadrement (frame-ancestors 'none'), aucun formulaire vers ailleurs, aucun <object> |
| X-Frame-Options: DENY | pour les navigateurs qui ignorent frame-ancestors |
| X-Content-Type-Options: nosniff | un fichier déposé ne s'exécute pas comme un script |
| Referrer-Policy: same-origin | un lien public qui porte un jeton ne le fuit pas vers un site tiers |
| Cross-Origin-Opener-Policy: same-origin | une fenêtre ouverte par un tiers ne garde pas la main |
| Permissions-Policy | caméra, micro, géolocalisation, paiement : refusés |
| Strict-Transport-Security | seulement si https: true |
csp remplace une directive, null la retire. Un script inline indispensable se déclare par
nonce, un par réponse : enTetesSecurite({ nonce: creerNonce() }).
Passer à une CSP sans script inline
Un onclick="return confirm('Supprimer ${nom} ?')" exécute ce qu'il contient : le navigateur
décode ' avant d'exécuter, et un nom X');alert(1);// devient du code (X-02 de l'audit).
La sortie : des attributs de données, lus par un fichier servi par l'application.
<button data-confirmer="Supprimer « Thermostat » ?">Supprimer</button>
<select name="id" data-soumettre>…</select>
<script src="/static/app.js" defer></script>document.addEventListener('click', (e) => {
const el = e.target.closest('[data-confirmer]');
if (el && !confirm(el.getAttribute('data-confirmer'))) e.preventDefault();
}, true);3. Le cookie de session
res.setHeader('set-cookie', cookieStrict('session', jeton, { maxAge: 7 * 86400 }));
// session=…; HttpOnly; Path=/; SameSite=Strict; Secure; Max-Age=604800SameSite=Strict a un prix : un lien suivi depuis un courriel arrive sans cookie, et l'utilisateur
se reconnecte. C'est ce qui protège entre sous-domaines ; Lax ne le fait pas.
Ce qu'il faut savoir
- Une route GET qui modifie échappe au contrôle. La faute est à la route : on modifie par POST.
- Derrière un proxy, passez l'origine publique (
https://…). Sinon tout navigateur réel est refusé — le module échoue fermé, c'est voulu. - Une route publique qui ne modifie rien et que d'autres sites appellent (vérifier un document) se met hors contrôle explicitement, par l'hôte.
includeSubDomainsengage tous les sous-domaines en https pendant un an : seulement s'ils le sont tous.
Ce qu'il ne fait pas
| besoin | module |
|---|---|
| limite de débit, essai en force | @mostajs/auth-lite |
| plafonds de corps (413) | @mostajs/http-pont |
| sessions révocables | @mostajs/auth-plug |
| clés machine | @mostajs/api-keys |
Essais
npm test # 14 essais mjs-unit — chacun nomme l'attaque qu'il empêcheLe plan Dev+Test (docs/DEVTEST-PLAN.garde-http.json) est suivi dans MostaQatraxFlow.
