Développement Web

Securiser une API REST : rate limiting, CORS, validation

14 min de lecture

📍 Guide du parcours : Concevoir une API REST — le guide de conception. Cet article approfondit une brique du parcours ; pour la vue d’ensemble, commencez par le guide.

Une API ouverte est une API exposée

Le jour où l’API Catalogue devient publique, elle cesse d’être un projet sage. Un script peut la marteler de milliers de requêtes par seconde et faire tomber le serveur ; un navigateur refusera de l’appeler depuis un autre domaine si elle ne l’autorise pas explicitement ; et le moindre champ entrant non vérifié devient une porte pour des données aberrantes, voire une injection. Authentifier ne suffit pas : même un appelant identifié peut abuser, se tromper ou être malveillant. Il faut une couche de défense qui encadre le trafic, les origines et les données.

Ce tutoriel ajoute cette couche à l’API Catalogue : un rate limiting qui plafonne les requêtes et répond proprement quand on dépasse, une configuration CORS qui autorise les bons navigateurs sans ouvrir la porte à tous, et une validation d’entrée qui refuse les données malformées avec les bons codes de statut. Trois défenses indépendantes, complémentaires, qu’on retrouve sur toute API sérieuse.

🎯 Ce que vous allez apprendre

  • Limiter le débit des requêtes et répondre 429 avec un en-tête Retry-After exploitable.
  • Configurer CORS correctement, et comprendre pourquoi ce n’est pas une protection contre tous les clients.
  • Valider les données entrantes et distinguer 400 (malformé) de 422 (invalide).
  • Situer ces défenses dans le paysage des risques d’API (OWASP API Security Top 10).

🛠️ Ce que vous allez construire

Une API Catalogue durcie : GET /livres plafonné à un nombre de requêtes par fenêtre de temps et par client, POST /livres accessible depuis le site officiel mais refusé aux origines inconnues, et une validation qui rejette un ISBN malformé ou un titre manquant avec un message exploitable par le client.

Prérequis

  • Un serveur Express, idéalement déjà authentifié (voir Authentifier une API REST).
  • Les codes 400, 422 et 429, présentés dans le guide du parcours.
  • Node.js 20+ et npm.
  • ⏱️ Temps estimé : environ 50 minutes.

Étape 1 — Plafonner le débit (rate limiting)

Sans limite, un seul client — par erreur de boucle ou par malveillance — peut consommer toute la capacité du serveur. Le rate limiting fixe un quota : « au plus N requêtes par fenêtre de temps et par client ». Au-delà, le serveur répond 429 Too Many Requests (défini par la RFC 6585) au lieu de traiter la requête. On l’implémente avec express-rate-limit (version 8.x au moment d’écrire) :

npm install express-rate-limit
const rateLimit = require('express-rate-limit');

const limiteur = rateLimit({
  windowMs: 15 * 60 * 1000,   // fenêtre de 15 minutes
  limit: 100,                 // 100 requêtes par fenêtre et par IP
  standardHeaders: true,      // émet les en-têtes RateLimit standard
  legacyHeaders: false,       // retire les anciens X-RateLimit-*
  message: { titre: 'Trop de requêtes, réessayez plus tard.' }
});

app.use('/api/', limiteur);   // s'applique à toute l'API

L’option standardHeaders: true ajoute à chaque réponse des en-têtes qui annoncent le quota et ce qu’il en reste, de sorte qu’un client bien élevé ralentit avant de se faire bloquer. Le format de ces en-têtes (RateLimit et RateLimit-Policy) suit une spécification IETF encore à l’état de brouillon ; en attendant sa finalisation, beaucoup d’API exposent aussi les en-têtes historiques X-RateLimit-Limit, X-RateLimit-Remaining et X-RateLimit-Reset, devenus un standard de fait.

Quand le quota est dépassé, complétez la réponse 429 par un en-tête Retry-After (RFC 9110) qui indique au client combien de secondes attendre : c’est ce qui transforme un blocage brutal en coopération. Côté algorithme, express-rate-limit utilise par défaut une fenêtre fixe ; pour un lissage plus fin, on parle de fenêtre glissante ou de « seau à jetons » (token bucket), qui autorise des pics courts tout en tenant une moyenne.

Point d’étape — Appelez un endpoint en boucle rapide. Après 100 appels en 15 minutes, vous devez recevoir 429 et un corps « Trop de requêtes ». Inspectez les en-têtes : un compteur de quota restant doit décroître à chaque appel réussi.

Étape 2 — Comprendre CORS avant de le configurer

CORS (Cross-Origin Resource Sharing) est la source d’erreur numéro un des développeurs front qui débutent avec les API. La confusion vient d’un malentendu sur qui impose CORS. Ce n’est pas votre serveur qui bloque : c’est le navigateur. Par défaut, le navigateur applique la « politique de même origine » : une page servie depuis app.exemple.org ne peut pas appeler en JavaScript une API sur catalogue.exemple.org, à moins que l’API ne déclare explicitement qu’elle l’autorise.

Conséquence souvent mal comprise : CORS ne protège pas votre API contre curl ou un script serveur. Un client non-navigateur ignore complètement ces règles. CORS protège l’utilisateur d’un navigateur contre des requêtes inter-sites abusives ; ce n’est pas un pare-feu pour votre serveur. La vraie protection de l’API, c’est l’authentification et le rate limiting ; CORS gère un problème distinct : quelles pages web ont le droit d’appeler l’API depuis le navigateur de l’utilisateur.

Pour les requêtes « non simples » (un POST avec un en-tête Authorization, par exemple), le navigateur envoie d’abord une requête préliminaire (preflight) en OPTIONS pour demander la permission ; ce n’est que si le serveur répond favorablement qu’il envoie la vraie requête.

Étape 3 — Configurer CORS, sans tout ouvrir

Avec ce modèle en tête, la configuration devient logique. On utilise le paquet cors et on déclare précisément quelles origines, méthodes et en-têtes sont autorisés :

npm install cors
const cors = require('cors');

app.use(cors({
  origin: 'https://app.exemple.org',          // le front officiel, et lui seul
  methods: ['GET', 'POST', 'PATCH', 'DELETE'],
  allowedHeaders: ['Authorization', 'Content-Type'],
  maxAge: 86400                                // cache du preflight : 24 h
}));

La tentation du débutant pressé est d’écrire origin: '*' pour « que ça marche ». C’est à éviter sur une API authentifiée : une origine universelle est incompatible avec l’envoi de cookies d’authentification, et elle autorise n’importe quelle page web à appeler l’API depuis le navigateur de vos utilisateurs. Déclarez une liste d’origines explicites. L’option maxAge dit au navigateur de mémoriser la réponse de preflight pendant 24 heures : il n’a plus à redemander la permission à chaque requête, ce qui économise un aller-retour — appréciable sur réseau lent.

Point d’étape — Depuis une page sur https://app.exemple.org, un fetch vers l’API réussit. Depuis une autre origine, le navigateur bloque l’appel avec une erreur CORS dans la console — alors que le même appel en curl passe sans problème. Cette asymétrie est normale : elle confirme que CORS vit dans le navigateur.

Étape 4 — Ne jamais faire confiance à l’entrée

Toute donnée qui entre dans l’API est suspecte jusqu’à preuve du contraire, même celle d’un client authentifié. La validation n’est pas une politesse : c’est la barrière qui empêche les données aberrantes d’entrer en base et les injections de prospérer. La règle est de tout vérifier — types, formats, plages, présence des champs obligatoires — et de rejeter au moindre écart. Écrivons un validateur pour la création d’un livre :

function validerLivre(corps) {
  const erreurs = [];

  if (!corps.titre || typeof corps.titre !== 'string') {
    erreurs.push({ champ: 'titre', message: 'Titre obligatoire (chaîne).' });
  }
  if (!/^97[89]-[\d-]+$/.test(corps.isbn || '')) {
    erreurs.push({ champ: 'isbn', message: 'ISBN-13 attendu (préfixe 978/979).' });
  }
  if (corps.annee !== undefined &&
      (!Number.isInteger(corps.annee) || corps.annee < 0)) {
    erreurs.push({ champ: 'annee', message: 'Année : entier positif.' });
  }
  return erreurs;
}

Côté endpoint, on distingue deux cas d’erreur, avec deux codes différents — la nuance vue dans le guide. Si le corps n’est même pas du JSON valide, c’est 400 Bad Request (la requête est malformée). Si le JSON est correct mais viole une règle métier, c’est 422 Unprocessable Content (compris, mais inacceptable) :

app.post('/api/livres', express.json(), (req, res) => {
  const erreurs = validerLivre(req.body);
  if (erreurs.length > 0) {
    return res.status(422)
      .type('application/problem+json')
      .json({
        type: 'https://catalogue.exemple.org/erreurs/validation',
        title: 'Données du livre invalides',
        status: 422,
        erreurs                         // le détail, champ par champ
      });
  }
  // … données saines : on peut créer en base …
  res.status(201).json({ /* le livre créé */ });
});

Express renvoie automatiquement 400 si express.json() ne parvient pas à parser le corps. Renvoyer la liste des erreurs champ par champ — plutôt qu’un vague « données invalides » — permet au client d’afficher un message utile en face de chaque champ fautif.

Point d’étape — Un POST avec un JSON cassé renvoie 400 ; un JSON correct mais sans titre renvoie 422 avec la liste des erreurs ; un livre valide renvoie 201. Les trois codes sont distincts et justes.

Étape 5 — Durcir le reste : la liste blanche et les en-têtes

Deux réflexes complètent la validation. Le premier vise une faille discrète, l’affectation de masse (mass assignment) : si vous recopiez aveuglément le corps de la requête dans l’objet enregistré, un client malin pourrait ajouter un champ role: "admin" ou id: 1 et l’écrire en base. La parade est la liste blanche : ne recopier que les champs explicitement attendus.

// On ne garde QUE les champs autorisés, jamais le corps brut
const { titre, isbn, annee } = req.body;
const livre = { titre, isbn, annee };   // 'role', 'id' & co. sont ignorés

Le second réflexe est d’ajouter des en-têtes de sécurité HTTP (par exemple via le paquet helmet), qui durcissent le comportement du navigateur, et bien sûr de tout servir en HTTPS : un jeton ou une clé d’API qui transite en clair n’est plus un secret. Ces mesures s’inscrivent dans un cadre plus large : l’OWASP publie un « API Security Top 10 » (édition 2023) qui recense les risques les plus fréquents des API. Son numéro un est instructif : la Broken Object Level Authorization — autrement dit, oublier de vérifier que l’utilisateur a le droit d’accéder à cet objet précis. Un adhérent authentifié qui demande GET /api/adherents/16/emprunts alors qu’il est l’adhérent 15 ne doit jamais voir les emprunts d’un autre : l’authentification ne dispense pas de vérifier la propriété de chaque ressource.

Point d’étape final — Votre API plafonne le débit, n’accepte les appels navigateur que des origines déclarées, valide chaque entrée avec le bon code de statut, et ne recopie jamais le corps brut en base. Les trois couches de défense sont en place et indépendantes.

🐞 Pièges fréquents

Symptôme Cause probable Correctif
« CORS error » dans la console, mais curl marche Origine non déclarée côté serveur Ajouter l’origine dans la config cors
On croit CORS cassé alors que c’est le preflight Requête OPTIONS non autorisée (méthode/en-tête) Déclarer methods et allowedHeaders
Un client est bloqué sans savoir combien attendre 429 sans en-tête Retry-After Ajouter Retry-After et les en-têtes de quota
Un champ role injecté finit en base Corps de requête recopié en bloc Liste blanche des champs attendus
Tout est renvoyé en 400, même les erreurs métier Confusion 400/422 400 = malformé, 422 = invalide

🌍 Réalités du terrain

Le rate limiting a une vertu économique directe quand le budget serveur est compté : il transforme une charge potentiellement illimitée en une charge bornée et prévisible. Sans plafond, un seul client mal codé ou un robot d’indexation agressif peut faire grimper la facture de bande passante et de calcul ; avec un plafond, le coût maximal est connu d’avance. Régler la fenêtre et la limite, c’est en partie régler la facture.

Le choix de la clé de limitation mérite attention quand beaucoup d’utilisateurs partagent une même adresse IP — c’est fréquent derrière les passerelles des opérateurs mobiles, où des milliers d’abonnés sortent par une poignée d’adresses. Limiter strictement par IP pénaliserait alors des utilisateurs légitimes les uns à cause des autres. Sur une API authentifiée, mieux vaut limiter par identité (le sub du jeton) que par IP : la limite suit l’utilisateur réel, pas l’adresse qu’il partage avec d’autres.

✅ Récapitulatif

Vous avez ajouté à l’API Catalogue trois défenses qui répondent à trois menaces distinctes. Le rate limiting borne le débit et répond 429 avec Retry-After, protégeant le serveur de l’abus et la facture de l’emballement. CORS, bien compris comme une protection du navigateur de l’utilisateur (et non du serveur), n’autorise les appels web que des origines déclarées. La validation refuse les données malformées (400) ou invalides (422), pratique la liste blanche contre l’affectation de masse, et s’inscrit dans le cadre des risques OWASP — dont le premier reste la vérification d’autorisation au niveau de chaque objet. Sécuriser une API, ce n’est pas une option finale : c’est une couche pensée dès la conception.

🧾 Aide-mémoire

Élément Rôle
express-rate-limit Plafonner les requêtes, émettre 429
Retry-After Indiquer le délai d’attente (RFC 9110)
cors({ origin: […] }) Autoriser des origines précises (navigateur)
Preflight OPTIONS Demande de permission avant requête non simple
400 / 422 Corps malformé / données invalides
Liste blanche des champs Contre l’affectation de masse
OWASP API Security Top 10 Référentiel des risques d’API (2023)

💪 À vous de jouer

Appliquez un rate limiting plus strict au seul endpoint de connexion (par exemple 5 tentatives par 15 minutes), pour freiner les attaques par force brute, tout en gardant la limite générale plus large sur le reste de l’API.

Voir une solution
const limiteurConnexion = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 5,                         // bien plus strict
  message: { titre: 'Trop de tentatives de connexion.' }
});

// Limite serrée sur la connexion uniquement
app.post('/connexion', limiteurConnexion, connexion);
// Limite large ailleurs (déjà posée à l'étape 1)

On cible la route sensible avec un limiteur dédié : la connexion, point d’entrée des attaques par mot de passe, mérite un plafond bien plus bas que la lecture du catalogue.

Tutoriels frères

Pour aller plus loin

FAQ

CORS suffit-il à sécuriser mon API ? Non, et c’est un contresens fréquent. CORS ne protège que les utilisateurs de navigateurs contre certaines requêtes inter-sites ; il n’a aucun effet sur un client non-navigateur. La sécurité réelle de l’API repose sur l’authentification, l’autorisation par objet, le rate limiting, la validation et HTTPS.

Où placer le rate limiting : dans l’application ou en amont ? Les deux se complètent. Un plafond applicatif (comme ici) est simple et porte la logique métier ; un plafond en amont, sur un reverse proxy ou une passerelle d’API, protège même quand l’application est saturée. Pour commencer, l’applicatif suffit largement.

Faut-il valider à la fois côté client et côté serveur ? La validation côté client améliore l’expérience (retour immédiat), mais elle ne sécurise rien : elle se contourne en deux clics. La validation côté serveur est la seule qui compte pour la sécurité ; celle du client est un confort, jamais une garantie.

Quel code pour une limite dépassée : 403 ou 429 ? 429 Too Many Requests, sans hésiter. 403 dirait « interdit pour toujours » ; 429 dit « trop, pour l’instant — réessaie après Retry-After ». La nuance guide correctement le comportement du client.

Mots-clés : sécurité API REST, rate limiting, 429 Too Many Requests, CORS, preflight, validation, 400 422, OWASP API Security Top 10, mass assignment.

Partager