Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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 :

text
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 en PUT, PATCH, DELETE), le navigateur envoie d'abord une requête OPTIONS de 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

bash
npm install cors
js
// 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.

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

ConfigurationEffetQuand l'utiliser
cors() sans optionAccess-Control-Allow-Origin: * : toute origineAPI publique en lecture seule, sans authentification
cors({ origin: 'https://frais.gsb.example' })Une seule origineUne API et son front
cors({ origin: [ … ] })Une liste blanchePlusieurs fronts connus
cors({ origin: true, credentials: true })Renvoie l'origine de chaque requête et autorise les cookiesJamais : cela revient à autoriser tous les sites, avec les cookies de l'utilisateur
credentials: true avec une listeAutorise l'envoi des cookies depuis ces originesFront 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.

bash
npm install helmet
js
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êteValeurProtection
Content-Security-Policydefault-src 'self'; … object-src 'none'; script-src 'self'; …Limite les sources de scripts et de contenus : freine le XSS
Strict-Transport-Securitymax-age=31536000; includeSubDomainsLe navigateur n'accepte plus que HTTPS pendant un an (HSTS)
X-Content-Type-OptionsnosniffLe navigateur respecte le Content-Type annoncé
X-Frame-OptionsSAMEORIGINEmpêche d'afficher l'API dans un cadre d'un autre site
Referrer-Policyno-referrerN'envoie pas l'adresse de la page d'origine
Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policysame-originIsole les ressources entre origines
X-Powered-Bysupprimé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 :

js
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 :

RessourceBorne dans l'API AppliFrais
Taille d'un corps JSONexpress.json({ limit: '100kb' }) ; au-delà, 413
Taille d'un fichierlimits.fileSize de Multer : 2 Mo
Nombre d'éléments renvoyéslimite plafonnée à 100 par le schéma Zod
Longueur des champsmax(100) sur les libellés, max(200) sur le mot de passe
Durée d'une requêteNode.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 :

js
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 audit dans 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.json versionné et npm ci partout.

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 :

RisqueCe que c'estDans le code d'AppliFrais
API1 Broken Object Level AuthorizationAccéder à l'objet d'un autre en changeant un identifiantchargerFiche vérifie le propriétaire ; 404 sinon
API2 Broken AuthenticationAuthentification contournable ou devinableargon2id, JWT court avec algorithme imposé, limitation des tentatives, message unique
API3 Broken Object Property Level AuthorizationLire une propriété sensible, ou modifier une propriété interditeColonnes nommées et DTO en sortie (jamais le hachage) ; z.strictObject en entrée
API4 Unrestricted Resource ConsumptionSaturer le serveurLimites de taille, pagination plafonnée, limitation de débit
API5 Broken Function Level AuthorizationAppeler une fonction réservée à un autre rôleexigerRole sur chaque route sensible
API6 Unrestricted Access to Sensitive Business FlowsAbuser 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 ForgeryFaire appeler par le serveur une adresse choisie par l'attaquantL'API n'appelle aucune URL fournie par le client
API8 Security MisconfigurationMauvais réglages : en-têtes, CORS, messages d'erreurhelmet, CORS en liste blanche, erreurs 500 génériques, NODE_ENV=production
API9 Improper Inventory ManagementRoutes oubliées, vieilles versions non protégéesDocumentation OpenAPI à jour, préfixe /v1, pas de route de test en production
API10 Unsafe Consumption of APIsFaire confiance aveuglément aux réponses d'une autre APIValider 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.

  1. Pourquoi y a-t-il une requête OPTIONS ?
  2. Que manque-t-il probablement dans l'API ?
  3. Un camarade propose app.use(cors({ origin: true, credentials: true })). Pourquoi refuses-tu ?
Voir la réponseMasquer la réponse de l'exercice 1
  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érification OPTIONS pour demander si l'origine http://localhost:5173 peut envoyer cet en-tête.
  2. Le middleware cors n'est pas branché, ou il est branché après les routeurs. La pré-vérification OPTIONS n'envoie jamais l'en-tête Authorization : elle traverse donc le routeur des fiches, dont le middleware authentifier répond 401. Le navigateur attend une réponse 2xx avec l'en-tête Access-Control-Allow-Origin : il bloque. Il faut app.use(cors({ origin: config.origineFront })) avant les routeurs, avec CORS_ORIGIN=http://localhost:5173 : cors répond alors lui-même 204 à la pré-vérification, avant toute authentification.
  3. origin: true renvoie l'origine de n'importe quelle requête comme autorisée, et credentials: true autorise 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.

  1. GET /v1/utilisateurs/7 renvoie le hachage du mot de passe.
  2. Un visiteur obtient 200 sur PATCH /v1/fiches/12 avec {"etat":"MP"}.
  3. GET /v1/fiches?limite=100000 renvoie toutes les fiches en une fois et prend vingt secondes.
  4. L'ancienne route /v0/export, sans authentification, répond toujours en production.
  5. 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
  1. API3 (propriété exposée) : lister explicitement les colonnes, passer par un objet de sortie sans le hachage.
  2. API5 (fonction réservée à un autre rôle) : contrôle de rôle côté serveur selon l'état demandé ; MP réservé au comptable.
  3. API4 (consommation de ressources) : plafonner limite à 100 dans le schéma de validation.
  4. API9 (inventaire) : supprimer la route, tenir la documentation OpenAPI comme inventaire de toutes les routes, et annoncer la fin des anciennes versions.
  5. 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
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: Express annonce la technologie : à retirer (helmet le fait, ou app.disable('x-powered-by')).
  • Access-Control-Allow-Origin: * avec Access-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. Un app.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é ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Logs et erreurs en production avec pinoRevoir le sommaire du cours