Chapitre 1022 minIntermédiaire
CORS et sécurité des API : OWASP API Top 10, limitation de débit
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- expliquer la politique de même origine et corriger une erreur CORS ;
- configurer CORS sans ouvrir de faille ;
- reconnaître les risques de l'OWASP API Security Top 10 ;
- corriger une faille BOLA et poser une limitation de débit.
6 exercices corrigés1 schéma
CORS (Cross-Origin Resource Sharing) est le mécanisme par lequel une API indique au navigateur quelles pages d'autres origines ont le droit de lire ses réponses ; sans lui, le navigateur bloque ces lectures au nom de la politique de même origine. CORS n'est pas une protection de l'API : celle-ci repose sur le contrôle d'accès à chaque objet, la validation des entrées et la limitation de débit, que l'OWASP résume dans son API Security Top 10. Ce chapitre explique CORS et ses erreurs, puis les dix risques des API et la façon de limiter le débit.
La politique de même origine
Une origine, c'est le triplet schéma + hôte + port. Deux URL ont la même origine si les trois sont identiques ; le chemin ne compte pas.
URL comparée à https://frais.gsb.example | Même origine ? | Pourquoi |
|---|---|---|
https://frais.gsb.example/fiches/12 | Oui | Seul le chemin change |
http://frais.gsb.example | Non | Schéma différent (http) |
https://api.gsb.example | Non | Hôte différent (un sous-domaine est une autre origine) |
https://frais.gsb.example:8443 | Non | Port différent (443 par défaut) |
http://localhost:5173 | Non | Tout est différent |
Par défaut, le navigateur applique la politique de même origine (Same-Origin Policy) : un script chargé depuis une origine peut envoyer une requête vers une autre origine, mais ne peut pas lire la réponse. Sans cette règle, n'importe quel site que tu visites pourrait, avec ton navigateur et tes cookies, lire ta messagerie ou ton espace bancaire ouverts dans un autre onglet.
Le front d'AppliFrais est servi par https://frais.gsb.example et appelle l'API à https://api.gsb.example : deux origines différentes. Sans autorisation explicite de l'API, le navigateur refuse au front l'accès aux réponses. C'est là qu'intervient CORS.
CORS : l'API autorise des origines
Le navigateur ajoute à chaque requête vers une autre origine l'en-tête Origin, qui indique d'où vient la page. L'API répond avec Access-Control-Allow-Origin : si la valeur correspond à l'origine de la page (ou vaut *), le navigateur laisse le script lire la réponse ; sinon, il la bloque et affiche une erreur CORS dans la console.
GET /v1/fiches HTTP/1.1
Host: api.gsb.example
Origin: https://frais.gsb.example
Authorization: Bearer eyJhbGci…
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://frais.gsb.example
Vary: Origin
Content-Type: application/json; charset=utf-8
{"donnees":[ … ]}
Vary: Origin indique aux caches que la réponse dépend de l'en-tête Origin : sans lui, un cache pourrait resservir à une autre origine une réponse qui ne lui est pas destinée.
Requête simple ou pré-vérification
Pour certaines requêtes, le navigateur demande d'abord la permission à l'API, avant d'envoyer la vraie requête : c'est la requête de pré-vérification (preflight), une requête OPTIONS.
Une requête est dite simple, sans pré-vérification, si elle remplit toutes ces conditions : méthode GET, HEAD ou POST ; seulement des en-têtes ordinaires (Accept, Accept-Language, Content-Language, Content-Type) ; et un Content-Type limité à application/x-www-form-urlencoded, multipart/form-data ou text/plain. Ce sont les requêtes qu'un formulaire HTML pouvait déjà envoyer avant l'existence de CORS.
Les appels d'une API REST ne sont presque jamais simples : un corps en application/json, un en-tête Authorization, ou une méthode PUT, PATCH ou DELETE déclenchent la pré-vérification. Voici celle qu'envoie le navigateur avant que le front clôture la fiche 12, et la réponse de l'API de démonstration :
OPTIONS /v1/fiches/12 HTTP/1.1
Host: api.gsb.example
Origin: https://frais.gsb.example
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: authorization, content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://frais.gsb.example
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 600
Vary: Origin
Le navigateur vérifie que l'origine, la méthode PATCH et les en-têtes demandés sont autorisés, puis envoie la vraie requête. Access-Control-Max-Age: 600 lui permet de garder cette autorisation en cache dix minutes, sans refaire de pré-vérification pour chaque appel.
| En-tête de réponse | Rôle |
|---|---|
Access-Control-Allow-Origin | L'origine autorisée (une seule valeur), ou * pour toutes |
Access-Control-Allow-Methods | Les méthodes permises (pré-vérification) |
Access-Control-Allow-Headers | Les en-têtes de requête permis (pré-vérification) |
Access-Control-Allow-Credentials | true pour que le script puisse lire la réponse d'une requête envoyée avec les cookies (fetch(…, { credentials: 'include' })) ; interdit avec Access-Control-Allow-Origin: * |
Access-Control-Max-Age | Durée de mise en cache de la pré-vérification, en secondes |
Access-Control-Expose-Headers | Les en-têtes de réponse que le script peut lire en plus des en-têtes de base (Location, ETag, par exemple) |
Configurer CORS correctement
Avec Express, le paquet cors gère tous ces en-têtes et répond aux pré-vérifications :
const cors = require('cors');
app.use(cors({
origin: ['https://frais.gsb.example'], // liste blanche des origines
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type'],
exposedHeaders: ['Location', 'ETag'],
maxAge: 600,
}));
En développement, ajoute l'origine du serveur de développement du front (http://localhost:5173 pour Vite) dans la configuration de développement seulement, ou fais passer les appels par le proxy de développement du front, qui rend l'API « de même origine ».
Les erreurs à ne pas commettre :
- renvoyer l'origine reçue, quelle qu'elle soit, avec
Access-Control-Allow-Credentials: true(cors({ origin: true, credentials: true })) : n'importe quel site peut alors lire les données d'un utilisateur connecté par cookie. C'est une mauvaise configuration de sécurité, classée API8 par l'OWASP (le cas détaillé) ; - comparer l'origine avec une expression trop large : un test « l'origine contient
gsb.example» acceptehttps://gsb.example.site-pirate.example; - autoriser l'origine
null, envoyée par des fichiers locaux et des pages en bac à sable, que n'importe qui peut produire ; - croire que
*suffit partout :Access-Control-Allow-Origin: *est acceptable pour une API publique sans authentification par cookie, mais le navigateur l'interdit avecAccess-Control-Allow-Credentials: true.
L'OWASP API Security Top 10
L'OWASP (Open Worldwide Application Security Project) publie une liste des dix risques les plus critiques propres aux API. L'édition en vigueur est celle de 2023. Elle diffère du Top 10 des applications web (édition 2025, détaillée dans le cours Sécurité des applications) : les API exposent directement les objets et les fonctions, et leurs failles dominantes sont des failles de contrôle d'accès.
| Code | Risque | En une phrase | Parade principale |
|---|---|---|---|
| API1:2023 | Broken Object Level Authorization (BOLA) | L'API ne vérifie pas que l'objet demandé appartient à l'appelant | Vérifier le propriétaire à chaque accès à un objet |
| API2:2023 | Broken Authentication | Authentification contournable, jetons mal vérifiés, pas de limite sur la connexion | verify avec algorithme imposé, expiration courte, limitation des tentatives |
| API3:2023 | Broken Object Property Level Authorization (BOPLA) | L'API renvoie ou accepte des propriétés qu'elle ne devrait pas | Champs renvoyés choisis explicitement ; liste blanche des champs modifiables |
| API4:2023 | Unrestricted Resource Consumption | Pas de limite de débit, de taille ou de pagination | Limitation de débit, taille maximale des corps, pagination plafonnée |
| API5:2023 | Broken Function Level Authorization (BFLA) | Une fonction réservée (administration, validation) est accessible à tous | Contrôle de rôle sur chaque route, refus par défaut |
| API6:2023 | Unrestricted Access to Sensitive Business Flows | Un processus métier peut être automatisé à l'excès | Limites métier, détection d'automatisation, vérifications supplémentaires |
| API7:2023 | Server Side Request Forgery (SSRF) | L'API va chercher une URL fournie par le client sans la contrôler | Liste blanche des destinations, pas d'accès au réseau interne |
| API8:2023 | Security Misconfiguration | Réglages dangereux : CORS trop ouvert, traces d'erreur, HTTPS absent | Configuration durcie et revue, erreurs génériques |
| API9:2023 | Improper Inventory Management | Versions et routes oubliées, non documentées, non protégées | Inventaire par OpenAPI, retrait des anciennes versions |
| API10:2023 | Unsafe Consumption of APIs | Confiance aveugle dans les réponses d'une API tierce | Valider ce qui vient d'un tiers comme une saisie d'utilisateur |
Le chapitre Sécuriser une API REST et une application mobile du cours Sécurité applicative reprend chaque risque avec des exemples de code vulnérable et corrigé. Les trois premiers méritent qu'on s'y arrête ici.
BOLA : la faille n° 1 des API
L'API d'AppliFrais vérifie le jeton de Lucas, puis renvoie la fiche demandée :
// VULNÉRABLE : n'importe quel utilisateur authentifié lit n'importe quelle fiche
router.get('/v1/fiches/:id', authentifier, async (req, res) => {
const [lignes] = await pool.execute('SELECT id, mois, etat, montant_valide FROM fiche_frais WHERE id = ?', [req.params.id]);
if (lignes.length === 0) return res.status(404).end();
res.json(lignes[0]);
});
Il suffit à Lucas de remplacer 12 par 13, 14, 15… pour lire les fiches de tous ses collègues : c'est exactement l'énumération du journal d'accès analysé dans les exercices du chapitre 3. L'authentification est correcte ; c'est l'autorisation sur l'objet qui manque. La correction place la condition dans la requête elle-même :
router.get('/v1/fiches/:id', authentifier, async (req, res) => {
const estComptable = req.utilisateur.role === 'comptable';
const [lignes] = await pool.execute(
`SELECT id, mois, etat, montant_valide FROM fiche_frais
WHERE id = ? AND (id_visiteur = ? OR ?)`,
[req.params.id, req.utilisateur.sub, estComptable]
);
if (lignes.length === 0) return res.status(404).end(); // inexistante OU fiche d'un autre
res.json(lignes[0]);
});
Un identifiant non devinable (UUID) rend l'énumération plus difficile, mais ne remplace pas ce contrôle : un identifiant finit toujours par fuiter (dans une URL partagée, un journal, une capture d'écran).
BOPLA : trop de champs, dans les deux sens
En lecture, l'API renvoie plus de champs que nécessaire, en comptant sur le front pour ne pas les afficher. SELECT * sur la table des utilisateurs renvoie le hash du mot de passe ; l'onglet Réseau le montre à qui veut le voir. En écriture, l'API recopie le corps reçu dans la base : un visiteur qui ajoute "montantValide": 9999 ou "role": "comptable" à sa requête modifie un champ qu'il ne devrait pas toucher. C'est l'affectation de masse (mass assignment). Les parades, déjà vues : sélectionner explicitement les champs renvoyés (ou un DTO), et valider le corps par liste blanche avec additionalProperties: false (JSON Schema).
BFLA : la route oubliée
La route POST /v1/fiches/:id/validations est réservée aux comptables, mais le développeur a seulement caché le bouton dans l'interface des visiteurs. N'importe quel visiteur qui connaît la route (elle est dans le code JavaScript du front, et dans la documentation) valide sa propre fiche. La parade : un contrôle de rôle côté serveur sur chaque route, et une règle de refus par défaut (une route sans règle explicite est refusée).
router.post('/v1/fiches/:id/validations', authentifier, exigerRole('comptable'), validerFiche);
Limiter le débit
La limitation de débit (rate limiting) plafonne le nombre de requêtes qu'un client peut envoyer dans un intervalle de temps. Elle protège la disponibilité (un script ou une attaque ne sature pas le serveur), freine la force brute sur la connexion et l'énumération d'identifiants, et maîtrise les coûts quand l'API appelle des services payants. Au-delà de la limite, l'API répond 429 Too Many Requests, avec Retry-After :
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/problem+json
{
"type": "https://api.gsb.example/problemes/limite-debit",
"title": "Trop de requêtes",
"status": 429,
"detail": "Limite de 100 requêtes par minute atteinte. Réessaie dans 30 secondes."
}
Par quoi compter ? Par jeton ou par utilisateur pour les routes authentifiées (plusieurs utilisateurs peuvent partager une adresse IP, derrière la box d'une entreprise) ; par clé d'API pour les partenaires ; par adresse IP pour les routes publiques et la connexion. Les routes sensibles ont des limites plus strictes : 5 tentatives de connexion par minute et par compte, quelques envois de courriel de réinitialisation par heure.
Trois algorithmes courants :
| Algorithme | Principe | Défaut ou qualité |
|---|---|---|
| Fenêtre fixe | Un compteur par minute calendaire (12 h 00, 12 h 01…), remis à zéro à chaque minute | Effet de bord : 100 requêtes à 12 h 00 min 59 s, puis 100 à 12 h 01 min 00 s, soit 200 en deux secondes |
| Fenêtre glissante | On compte les requêtes des 60 dernières secondes, quel que soit l'instant | Pas d'effet de bord, un peu plus coûteux à calculer |
| Seau à jetons (token bucket) | Un seau de capacité N se remplit d'un jeton par intervalle ; chaque requête consomme un jeton ; seau vide, requête refusée | Autorise de courtes rafales (jusqu'à N) tout en imposant un débit moyen |
Exemple de seau à jetons : capacité 10, un jeton ajouté chaque seconde. Un client qui n'a rien envoyé depuis longtemps a 10 jetons ; il envoie 15 requêtes d'un coup : 10 passent, 5 reçoivent 429. Trois secondes plus tard, il a 3 jetons, donc 3 requêtes possibles. Sur la durée, il ne dépasse pas une requête par seconde en moyenne.
Où la placer ? Le plus en amont possible : dans la passerelle d'API ou le reverse proxy (le module limit_req de nginx), ou dans un middleware de l'application (express-rate-limit en Node.js, le RateLimiter de Symfony ou de Laravel). Avec plusieurs serveurs d'application, les compteurs doivent être partagés (dans Redis, par exemple), sinon chaque serveur applique sa propre limite.
Beaucoup d'API informent le client de son quota restant avec des en-têtes. Les plus répandus, X-RateLimit-Limit, X-RateLimit-Remaining et X-RateLimit-Reset, ne sont pas normalisés ; l'IETF prépare des en-têtes standard RateLimit et RateLimit-Policy, encore à l'état de projet.
Les autres protections de base
- HTTPS partout, avec l'en-tête
Strict-Transport-Security(HSTS) pour que le navigateur refuse ensuite toute connexion en HTTP. - Des réponses qui ne se laissent pas réinterpréter : un
Content-Typeexact etX-Content-Type-Options: nosniff, pour que le navigateur n'exécute pas une réponse JSON comme un script. - Pas de mise en cache des données personnelles :
Cache-Control: no-storesur les réponses sensibles, en particulier celles qui contiennent des jetons. - Une taille maximale des corps (
express.json({ limit: '100kb' }),client_max_body_sizedans nginx) et des fichiers envoyés. - Des requêtes préparées pour toute requête SQL, y compris celles qu'alimentent les filtres de la chaîne de requête (injections).
- Une journalisation utile : qui a fait quoi, sur quelle ressource, avec quel résultat, pour détecter une énumération ou une force brute, sans jamais journaliser les jetons, les mots de passe ni les clés d'API (journalisation).
- Des erreurs génériques côté client, le détail dans le journal (chapitre 6).
Exercices
Exercice 1 sur 6 Même origine ? Réussi À revoir
La page est servie par https://frais.gsb.example. Pour chaque appel, dis si la politique de même origine s'applique (autre origine) et pourquoi.
https://frais.gsb.example/api/ficheshttps://api.gsb.example/v1/ficheshttp://frais.gsb.example/api/ficheshttps://frais.gsb.example:3000/api/fiches
Voir la réponseMasquer la réponse de l'exercice 1
- Même origine : schéma, hôte et port identiques ; seul le chemin change. Pas besoin de CORS.
- Autre origine : l'hôte diffère (
apiau lieu defrais). - Autre origine : le schéma diffère (
http). En plus, une page HTTPS qui appelle du HTTP est bloquée comme contenu mixte. - Autre origine : le port diffère (3000 au lieu de 443).
Tu avais trouvé ?
Exercice 2 sur 6 Pré-vérification ou pas ? Réussi À revoir
Le front de https://frais.gsb.example appelle https://api.gsb.example. Pour chaque requête, dis si le navigateur envoie une pré-vérification OPTIONS.
GET /v1/communes?codePostal=69001, sans en-tête particulier.GET /v1/fichesavecAuthorization: Bearer ….POST /v1/fichesavecContent-Type: application/json.DELETE /v1/frais-hors-forfait/58.POST /v1/contactavecContent-Type: application/x-www-form-urlencoded, sans autre en-tête.
Voir la réponseMasquer la réponse de l'exercice 2
- Non : requête simple (
GET, pas d'en-tête particulier). - Oui : l'en-tête
Authorizationn'est pas un en-tête simple. - Oui :
application/jsonne fait pas partie des trois types de contenu des requêtes simples. - Oui :
DELETEn'est pas une méthode simple. - Non :
POSTavec un type de formulaire classique et sans en-tête particulier est une requête simple. La réponse ne sera lisible que si l'API renvoie le bonAccess-Control-Allow-Origin, mais la requête, elle, part et s'exécute : CORS ne l'empêche pas. C'est pourquoi une route qui modifie des données ne doit pas compter sur CORS pour se protéger (voir le CSRF).
Tu avais trouvé ?
Exercice 3 sur 6 Corriger une configuration CORS Réussi À revoir
Un développeur a écrit, pour « régler les erreurs CORS » de son API qui authentifie par cookie de session :
app.use((req, res, next) => {
res.set('Access-Control-Allow-Origin', req.headers.origin);
res.set('Access-Control-Allow-Credentials', 'true');
next();
});
- Quel est le danger ?
- Réécris la configuration pour n'autoriser que
https://frais.gsb.example.
Voir la réponseMasquer la réponse de l'exercice 3
- L'API autorise toutes les origines, cookies compris : un site malveillant visité par un utilisateur connecté peut appeler l'API avec le cookie de session de la victime et lire les réponses (ses fiches, ses données personnelles). C'est une mauvaise configuration de sécurité (API8:2023). Il manque aussi
Vary: Originet la réponse aux pré-vérifications. -
Le paquet
const cors = require('cors'); app.use(cors({ origin: ['https://frais.gsb.example'], credentials: true, methods: ['GET', 'POST', 'PATCH', 'DELETE'], allowedHeaders: ['Content-Type'], }));corscompare l'origine reçue à la liste, ne renvoieAccess-Control-Allow-Originque si elle y figure, ajouteVary: Originet répond aux requêtesOPTIONS. Avec des cookies, la protection contre le CSRF reste nécessaire (SameSite, jeton anti-CSRF).
Tu avais trouvé ?
Exercice 4 sur 6 Classer dans l'OWASP API Top 10 Réussi À revoir
Donne le risque de l'OWASP API Security Top 10 (2023) qui correspond à chaque constat dans l'API d'AppliFrais.
- Un visiteur obtient
200surPOST /v1/fiches/12/validations. GET /v1/moirenvoie le hash du mot de passe et l'IBAN.- La route de connexion accepte des milliers d'essais de mot de passe par minute.
- L'import de justificatif par URL permet de demander
http://169.254.169.254/(le service de métadonnées du serveur cloud). - Un script réserve en boucle tous les créneaux de rendez-vous avec les médecins pour les revendre.
- L'ancienne route
/v0/fiches, sans authentification, répond toujours. - Un visiteur modifie
montantValideen ajoutant ce champ à sa requêtePATCH.
Voir la réponseMasquer la réponse de l'exercice 4
- API5:2023 Broken Function Level Authorization : une fonction réservée aux comptables est accessible à un visiteur.
- API3:2023 Broken Object Property Level Authorization : des propriétés sensibles sont exposées en lecture.
- API2:2023 Broken Authentication (absence de protection contre la force brute) ; on peut aussi citer API4:2023.
- API7:2023 Server Side Request Forgery.
- API6:2023 Unrestricted Access to Sensitive Business Flows : un flux métier automatisé à l'excès.
- API9:2023 Improper Inventory Management.
- API3:2023, côté écriture : l'affectation de masse.
Tu avais trouvé ?
Exercice 5 sur 6 Seau à jetons Réussi À revoir
La route de connexion est protégée par un seau à jetons de capacité 5, qui reçoit un jeton toutes les 12 secondes, par compte.
- Quel est le débit moyen maximal, en tentatives par minute ?
- Un attaquant envoie 20 tentatives d'un coup sur un compte dont le seau est plein. Combien passent ? Combien reçoivent
429? - Combien de temps doit-il attendre pour pouvoir faire 2 nouvelles tentatives ?
- Pourquoi compter par compte et par adresse IP ?
Voir la réponseMasquer la réponse de l'exercice 5
- Un jeton toutes les 12 secondes, soit 60 ÷ 12 = 5 tentatives par minute en moyenne.
- 5 passent (le seau est vidé), 15 reçoivent
429. - Deux jetons, soit 2 × 12 = 24 secondes.
- Par compte, on empêche de tester beaucoup de mots de passe sur un compte, même depuis de nombreuses adresses IP. Par adresse IP, on empêche une seule machine de tester un mot de passe courant sur des milliers de comptes (la pulvérisation de mots de passe, password spraying), que la limite par compte ne voit pas.
Tu avais trouvé ?
Exercice 6 sur 6 Corriger une faille BOLA Réussi À revoir
Cette route supprime un frais hors forfait. Identifie la faille et corrige-la : seul le propriétaire de la fiche peut supprimer le frais, et seulement si la fiche est à l'état CR.
router.delete('/v1/frais-hors-forfait/:id', authentifier, async (req, res) => {
await pool.execute('DELETE FROM frais_hors_forfait WHERE id = ?', [req.params.id]);
res.status(204).end();
});
Voir la réponseMasquer la réponse de l'exercice 6
La route vérifie l'authentification mais pas que le frais appartient au demandeur : n'importe quel visiteur supprime les frais des autres (BOLA), et même ceux d'une fiche validée.
router.delete('/v1/frais-hors-forfait/:id', authentifier, async (req, res) => {
const [resultat] = await pool.execute(
`DELETE h FROM frais_hors_forfait h
JOIN fiche_frais f ON f.id = h.id_fiche
WHERE h.id = ? AND f.id_visiteur = ? AND f.etat = 'CR'`,
[req.params.id, req.utilisateur.sub]
);
if (resultat.affectedRows === 0) return res.status(404).end();
res.status(204).end();
});
Les conditions de propriété et d'état sont dans la requête elle-même : il n'y a pas de fenêtre entre un contrôle et la suppression. 404 couvre « inexistant » et « pas à toi ». Pour distinguer une fiche non modifiable (409), il faudrait d'abord lire le frais avec sa fiche, vérifier le propriétaire, puis l'état.
Tu avais trouvé ?