Chapitre 1014 minIntermédiaire
CORS et sécurité d'une API Express : helmet, limitation de débit
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- expliquer une erreur CORS et la corriger sans tout ouvrir ;
- ajouter les en-têtes de sécurité et une limitation de débit ;
- relier chaque risque de l'OWASP API Top 10 à une protection du code.
4 exercices corrigés
CORS (Cross-Origin Resource Sharing) est le mécanisme par lequel une API indique au navigateur quels sites web ont le droit de l'appeler depuis leur code JavaScript. Sans lui, le front React servi sur une adresse ne peut pas lire les réponses d'une API servie sur une autre : le navigateur bloque, et affiche la célèbre « erreur CORS ». Ce chapitre explique cette erreur et la corrige sans tout ouvrir, puis termine la sécurisation de l'API : en-têtes de sécurité avec helmet, limitation de débit, taille des requêtes, proxy, dépendances, et une checklist reliée à l'OWASP API Security Top 10.
Pourquoi le navigateur bloque : la politique de même origine
Une origine, c'est le trio schéma + hôte + port : http://localhost:5173 et http://localhost:3000 sont deux origines différentes (le port change), comme https://frais.gsb.example et https://api.gsb.example (l'hôte change).
Par défaut, le navigateur applique la politique de même origine : le JavaScript d'une page peut envoyer des requêtes vers une autre origine, mais il ne peut pas lire la réponse, sauf si le serveur l'y autorise par des en-têtes CORS. C'est une protection de l'utilisateur : sans elle, n'importe quel site que tu visites pourrait interroger ton webmail ou l'intranet de ton entreprise avec tes cookies, et lire le résultat.
Pendant le développement d'AppliFrais, le front tourne avec Vite sur http://localhost:5173 et l'API sur http://localhost:3000. Le premier fetch du front affiche dans la console :
Access to fetch at 'http://localhost:3000/v1/fiches' from origin 'http://localhost:5173'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
Trois remarques pour ne pas chercher au mauvais endroit :
- l'erreur est produite par le navigateur, pas par l'API. Les journaux de l'API montrent souvent la requête avec un code
200: l'API a répondu, le navigateur a refusé de transmettre la réponse au JavaScript ; - curl, Bruno, Postman, une application mobile ou un autre serveur ne sont pas concernés : la politique de même origine n'existe que dans les navigateurs ;
- pour les requêtes « non simples » (avec
Authorization, avec un corps JSON, ou enPUT,PATCH,DELETE), le navigateur envoie d'abord une requêteOPTIONSde pré-vérification (preflight) pour demander la permission. Le mécanisme complet est expliqué, avec un schéma, dans le cours API REST.
Le middleware cors
npm install cors
// src/app.js
import cors from 'cors';
app.use(cors({ origin: config.origineFront })); // CORS_ORIGIN=http://localhost:5173 dans le .env
Avec une seule origine, l'API envoie Access-Control-Allow-Origin: http://localhost:5173 à chaque réponse ; le navigateur de toute autre origine voit que ce n'est pas la sienne et bloque. Pour plusieurs fronts autorisés (le poste de développement et la production), passe une liste : le middleware ne renvoie l'en-tête qu'aux origines de la liste.
app.use(cors({ origin: ['http://localhost:5173', 'https://frais.gsb.example'] }));
La réponse à une pré-vérification venant d'une origine autorisée :
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
Access-Control-Allow-Headers: authorization,content-type
Vary: Origin, Access-Control-Request-Headers
app.use(cors(…)) répond lui-même aux requêtes OPTIONS de pré-vérification, pour toutes les routes. Les anciens tutoriels ajoutent app.options('*', cors()) : en Express 5, ce chemin '*' fait planter le démarrage (« Missing parameter name at index 1: * »). Supprime cette ligne, elle est inutile.
| Configuration | Effet | Quand l'utiliser |
|---|---|---|
cors() sans option | Access-Control-Allow-Origin: * : toute origine | API publique en lecture seule, sans authentification |
cors({ origin: 'https://frais.gsb.example' }) | Une seule origine | Une API et son front |
cors({ origin: [ … ] }) | Une liste blanche | Plusieurs fronts connus |
cors({ origin: true, credentials: true }) | Renvoie l'origine de chaque requête et autorise les cookies | Jamais : cela revient à autoriser tous les sites, avec les cookies de l'utilisateur |
credentials: true avec une liste | Autorise l'envoi des cookies depuis ces origines | Front qui s'authentifie par session et cookie |
Les en-têtes de sécurité avec helmet
helmet ajoute en une ligne une série d'en-têtes HTTP qui durcissent le comportement du navigateur, et retire X-Powered-By: Express, qui annonce ta technologie aux robots qui cherchent des cibles.
npm install helmet
import helmet from 'helmet';
app.use(helmet());
Les en-têtes ajoutés par helmet 8 avec ses réglages par défaut (relevés sur l'API) :
| En-tête | Valeur | Protection |
|---|---|---|
Content-Security-Policy | default-src 'self'; … object-src 'none'; script-src 'self'; … | Limite les sources de scripts et de contenus : freine le XSS |
Strict-Transport-Security | max-age=31536000; includeSubDomains | Le navigateur n'accepte plus que HTTPS pendant un an (HSTS) |
X-Content-Type-Options | nosniff | Le navigateur respecte le Content-Type annoncé |
X-Frame-Options | SAMEORIGIN | Empêche d'afficher l'API dans un cadre d'un autre site |
Referrer-Policy | no-referrer | N'envoie pas l'adresse de la page d'origine |
Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy | same-origin | Isole les ressources entre origines |
X-Powered-By | supprimé | Ne révèle pas la technologie |
Pour une API qui ne renvoie que du JSON, plusieurs de ces en-têtes ont peu d'effet, mais ils ne coûtent rien et protègent les pages HTML que l'API sert aussi (la documentation Swagger, une page d'erreur). Le détail de chaque en-tête et des attaques qu'il freine est dans le cours Sécurité des applications.
Limiter le débit de toute l'API
La route de connexion a déjà sa limite stricte (chapitre 9). Une limite plus large protège le reste de l'API contre un script qui l'inonde de requêtes, volontairement ou par bogue :
import { rateLimit } from 'express-rate-limit';
const limiteGenerale = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 300, // 300 requêtes par adresse IP et par fenêtre
standardHeaders: 'draft-8',
legacyHeaders: false,
});
app.use('/v1', limiteGenerale);
Au-delà, l'API répond 429 Too Many Requests avec un en-tête Retry-After ; les en-têtes RateLimit et RateLimit-Policy indiquent au client sa consommation. Le compteur est gardé en mémoire : avec plusieurs instances de l'API, il faut un stockage partagé (Redis) pour que la limite soit globale.
Limiter la taille et la durée des requêtes
Chaque ressource consommée par une requête doit avoir une borne :
| Ressource | Borne dans l'API AppliFrais |
|---|---|
| Taille d'un corps JSON | express.json({ limit: '100kb' }) ; au-delà, 413 |
| Taille d'un fichier | limits.fileSize de Multer : 2 Mo |
| Nombre d'éléments renvoyés | limite plafonnée à 100 par le schéma Zod |
| Longueur des champs | max(100) sur les libellés, max(200) sur le mot de passe |
| Durée d'une requête | Node.js coupe une requête dont le corps n'est pas reçu en entier après 300 secondes (requestTimeout) et attend les en-têtes 60 secondes (headersTimeout) ; le reverse proxy ajoute ses propres délais |
Le plafond de 200 caractères sur le mot de passe n'est pas un détail : sans lui, un mot de passe de plusieurs mégaoctets ferait travailler argon2 à chaque tentative.
Derrière un reverse proxy : trust proxy
En production, l'API est placée derrière Nginx (chapitre 14). Pour Node.js, toutes les requêtes viennent alors de l'adresse du proxy ; l'adresse réelle du client arrive dans l'en-tête X-Forwarded-For, ajouté par Nginx. Il faut dire à Express de faire confiance au proxy, et seulement à lui :
if (config.env === 'production') app.set('trust proxy', 1); // un seul proxy devant l'API
Avec ce réglage, req.ip donne l'adresse du client, req.protocol vaut https, et la limitation de débit compte par client au lieu de compter tout le monde comme une seule adresse. Sans proxy, ne l'active pas : n'importe quel client pourrait inventer son propre X-Forwarded-For et contourner la limite.
Les dépendances
Une API Express de taille moyenne charge plus d'une centaine de paquets. Les réflexes du chapitre 2, automatisés :
npm auditdans la CI, qui échoue sur une vulnérabilité haute ou critique en production ;- un outil de mise à jour automatique (Dependabot ou Renovate) qui propose une pull request quand une dépendance publie un correctif ;
- un
package-lock.jsonversionné etnpm cipartout.
Le cours Intégration continue montre comment brancher ces contrôles dans un pipeline.
Checklist : l'OWASP API Security Top 10 dans le code
L'OWASP publie la liste des dix risques les plus fréquents des API (édition 2023). Voici où chacun est traité dans l'API AppliFrais, ce qui fait aussi une excellente grille de relecture pour ta réalisation d'E6 et pour les questions de l'E7 :
| Risque | Ce que c'est | Dans le code d'AppliFrais |
|---|---|---|
| API1 Broken Object Level Authorization | Accéder à l'objet d'un autre en changeant un identifiant | chargerFiche vérifie le propriétaire ; 404 sinon |
| API2 Broken Authentication | Authentification contournable ou devinable | argon2id, JWT court avec algorithme imposé, limitation des tentatives, message unique |
| API3 Broken Object Property Level Authorization | Lire une propriété sensible, ou modifier une propriété interdite | Colonnes nommées et DTO en sortie (jamais le hachage) ; z.strictObject en entrée |
| API4 Unrestricted Resource Consumption | Saturer le serveur | Limites de taille, pagination plafonnée, limitation de débit |
| API5 Broken Function Level Authorization | Appeler une fonction réservée à un autre rôle | exigerRole sur chaque route sensible |
| API6 Unrestricted Access to Sensitive Business Flows | Abuser d'un parcours métier légitime (automatiser une réservation, un achat) | Limitation de débit sur les opérations métier sensibles |
| API7 Server Side Request Forgery | Faire appeler par le serveur une adresse choisie par l'attaquant | L'API n'appelle aucune URL fournie par le client |
| API8 Security Misconfiguration | Mauvais réglages : en-têtes, CORS, messages d'erreur | helmet, CORS en liste blanche, erreurs 500 génériques, NODE_ENV=production |
| API9 Improper Inventory Management | Routes oubliées, vieilles versions non protégées | Documentation OpenAPI à jour, préfixe /v1, pas de route de test en production |
| API10 Unsafe Consumption of APIs | Faire confiance aveuglément aux réponses d'une autre API | Valider avec Zod les réponses des API tierces, comme les requêtes reçues |
Les failles applicatives qui ne sont pas propres aux API (injections, XSS, CSRF) et les tests de sécurité sont traités dans le cours Sécurité des applications.
Exercices
Exercice 1 sur 4 Diagnostiquer une erreur CORS Réussi À revoir
Le front React, servi sur http://localhost:5173, appelle fetch('http://localhost:3000/v1/fiches', { headers: { Authorization: … } }). La console affiche une erreur CORS. Dans le terminal de l'API, aucune ligne de journal pour GET /v1/fiches, mais une ligne OPTIONS /v1/fiches → 401.
- Pourquoi y a-t-il une requête
OPTIONS? - Que manque-t-il probablement dans l'API ?
- Un camarade propose
app.use(cors({ origin: true, credentials: true })). Pourquoi refuses-tu ?
Voir la réponseMasquer la réponse de l'exercice 1
- La requête porte un en-tête
Authorization: ce n'est pas une requête simple. Le navigateur envoie d'abord une pré-vérificationOPTIONSpour demander si l'originehttp://localhost:5173peut envoyer cet en-tête. - Le middleware
corsn'est pas branché, ou il est branché après les routeurs. La pré-vérificationOPTIONSn'envoie jamais l'en-têteAuthorization: elle traverse donc le routeur des fiches, dont le middlewareauthentifierrépond401. Le navigateur attend une réponse2xxavec l'en-têteAccess-Control-Allow-Origin: il bloque. Il fautapp.use(cors({ origin: config.origineFront }))avant les routeurs, avecCORS_ORIGIN=http://localhost:5173:corsrépond alors lui-même204à la pré-vérification, avant toute authentification. origin: truerenvoie l'origine de n'importe quelle requête comme autorisée, etcredentials: trueautorise les cookies : n'importe quel site visité par un utilisateur connecté pourrait lire ses données. On autorise une liste précise d'origines.
Tu avais trouvé ?
Exercice 2 sur 4 Classer selon l'OWASP API Top 10 Réussi À revoir
Associe chaque constat d'audit à un risque de l'OWASP API Security Top 10, et propose une correction.
GET /v1/utilisateurs/7renvoie le hachage du mot de passe.- Un visiteur obtient
200surPATCH /v1/fiches/12avec{"etat":"MP"}. GET /v1/fiches?limite=100000renvoie toutes les fiches en une fois et prend vingt secondes.- L'ancienne route
/v0/export, sans authentification, répond toujours en production. - Un visiteur lit le détail de la fiche d'un collègue en changeant l'identifiant dans l'URL.
Voir la réponseMasquer la réponse de l'exercice 2
- API3 (propriété exposée) : lister explicitement les colonnes, passer par un objet de sortie sans le hachage.
- API5 (fonction réservée à un autre rôle) : contrôle de rôle côté serveur selon l'état demandé ;
MPréservé au comptable. - API4 (consommation de ressources) : plafonner
limiteà 100 dans le schéma de validation. - API9 (inventaire) : supprimer la route, tenir la documentation OpenAPI comme inventaire de toutes les routes, et annoncer la fin des anciennes versions.
- API1 (BOLA) : vérifier le propriétaire de la fiche dans le service, et répondre
404.
Tu avais trouvé ?
Exercice 3 sur 4 Lire des en-têtes Réussi À revoir
Voici les en-têtes d'une réponse d'une API Express en production. Que révèlent-ils, et que manque-t-il ?
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Content-Type: application/json; charset=utf-8
Voir la réponseMasquer la réponse de l'exercice 3
X-Powered-By: Expressannonce la technologie : à retirer (helmet le fait, ouapp.disable('x-powered-by')).Access-Control-Allow-Origin: *avecAccess-Control-Allow-Credentials: true: les navigateurs refusent cette combinaison pour les requêtes avec cookies, mais elle montre une configuration CORS faite au hasard. Il faut une liste blanche d'origines.- Il manque les en-têtes de sécurité :
Strict-Transport-Security,X-Content-Type-Options: nosniff,Content-Security-Policy. Unapp.use(helmet())les ajoute.
Tu avais trouvé ?
Exercice 4 sur 4 Le compteur qui bloque tout le monde Réussi À revoir
En production, derrière Nginx, toute l'entreprise reçoit des 429 au bout de quelques minutes. En développement, tout fonctionnait. Explique et corrige.
Voir la réponseMasquer la réponse de l'exercice 4
Derrière le proxy, toutes les requêtes arrivent à Node.js depuis l'adresse de Nginx : la limitation de débit compte tout le monde comme un seul client, et la limite est atteinte très vite. Il faut app.set('trust proxy', 1) en production, pour que req.ip soit lu dans l'en-tête X-Forwarded-For ajouté par Nginx ; et vérifier que Nginx transmet bien cet en-tête (proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;). Si toute l'entreprise sort par une seule adresse IP publique, il faut en plus compter par utilisateur authentifié plutôt que par adresse IP pour les routes protégées.
Tu avais trouvé ?