Cybersécurité

Sécuriser une API REST — OWASP Top 10 2023 expliqué (Node.js + Express)

10 min de lecture

Une API est une porte d’entrée vers vos données. Quand elle est mal sécurisée, n’importe qui sur internet peut s’inviter chez vous, lire ou modifier ce qu’il ne devrait pas, et parfois prendre le contrôle entier du système. Cet article explique les neuf catégories de vulnérabilités les plus fréquentes (selon le classement OWASP API Security Top 10 publié en 2023), ce qui les cause concrètement, et comment les éviter avec du code Node.js + Express. Les principes restent valables en Python (FastAPI), Go (chi/gin), PHP (Laravel) et Java (Spring Boot) — seuls les noms des bibliothèques changent.

1 — Comprendre le classement OWASP API Top 10 2023

L’OWASP (Open Worldwide Application Security Project) publie depuis plusieurs années un classement des risques de sécurité spécifiques aux API web. La version 2023 (mise à jour de celle de 2019) est devenue la référence pour les audits et les checklists CI/CD.

Les vulnérabilités sont classées par fréquence d’apparition et par impact. Vous n’avez pas à toutes les corriger en même temps : la première (API1) explique à elle seule plus de 60 % des incidents observés selon les rapports d’audit. Commencer par là.

2 — API1 : Broken Object Level Authorization (BOLA)

BOLA est la vulnérabilité d’autorisation la plus simple à comprendre. Imaginez une application bancaire qui expose GET /api/comptes/123/solde. L’utilisateur connecté est Alice, qui a le compte 123. Que se passe-t-il si Alice change l’URL en GET /api/comptes/124/solde ?

Si l’API se contente de vérifier que l’utilisateur est connecté (token valide) sans vérifier qu’il est bien le propriétaire du compte 124, Alice voit le solde de Bob. C’est BOLA, et c’est trivialement exploitable avec un script qui itère sur tous les IDs.

La mitigation est strictement applicative : à chaque endpoint qui prend un identifiant en paramètre, vérifier que l’utilisateur connecté a le droit d’accéder à cet objet précis.

// Express + Prisma
app.get('/api/comptes/:id/solde', authMiddleware, async (req, res) => {
    const compte = await prisma.compte.findUnique({
        where: { id: req.params.id }
    });
    if (!compte) return res.status(404).json({ error: 'Not found' });
    // ⚠️ Sans cette ligne, c'est BOLA
    if (compte.proprietaireId !== req.user.id) {
        return res.status(403).json({ error: 'Forbidden' });
    }
    res.json({ solde: compte.solde });
});

Variante : préférer les UUID (longs, aléatoires) aux entiers auto-incrémentés en base. Cela ne remplace pas le contrôle d’autorisation (un attaquant peut récupérer un UUID valide d’une autre manière), mais cela complique l’énumération massive.

3 — API2 : Broken Authentication

Cette catégorie regroupe tous les défauts d’identification : mots de passe faibles, tokens JWT mal vérifiés, sessions non invalidées à la déconnexion, force brute non limité. Trois mesures cumulables couvrent l’essentiel.

Hachage des mots de passe avec Argon2id

Stocker un mot de passe en clair en base de données est la faute capitale. Le hacher en SHA-256 ou MD5 ne suffit pas non plus (un GPU consumer casse 10 milliards de hashes SHA-256 par seconde). La fonction recommandée par OWASP en 2026 est Argon2id, conçue pour résister aux attaques GPU/ASIC.

import argon2 from 'argon2';

// Création du compte
const hash = await argon2.hash(passwordPlain, {
    type: argon2.argon2id,
    memoryCost: 19456,   // 19 MiB — recommandation OWASP 2024
    timeCost: 2,
    parallelism: 1,
});
await db.user.create({ data: { email, hash } });

// Connexion
const user = await db.user.findUnique({ where: { email } });
const ok = user && await argon2.verify(user.hash, passwordPlain);
if (!ok) return res.status(401).json({ error: 'Invalid credentials' });

Important : la lenteur d’Argon2id est volontaire. Vérifier un mot de passe doit prendre 100 à 500 ms côté serveur. Cela ne ralentit pas l’utilisateur légitime, mais rend la force brute économiquement inviable.

JWT avec rotation des refresh tokens

Un JWT (JSON Web Token) est un jeton signé qui prouve qu’un utilisateur est connecté. La pratique recommandée en 2026 :

  • Access token court (15 minutes) — porté par le client à chaque requête.
  • Refresh token long (7-30 jours) — utilisé une seule fois pour obtenir un nouveau access token, puis rotated (remplacé).
  • Stocker les refresh tokens en base avec un identifiant unique pour pouvoir les révoquer (déconnexion forcée).

Le JWT est signé avec une clé secrète robuste (256 bits minimum). Algorithme : HS256 pour un service unique, RS256 ou EdDSA pour plusieurs services qui se font confiance via clé publique.

Limiter les tentatives de login

Au-delà de 5 échecs en 15 minutes pour un même compte, bloquer temporairement (15 min) ou déclencher un challenge CAPTCHA. Sans cette limite, un attaquant peut tester un dictionnaire de mots de passe en quelques heures.

4 — API3 : Broken Object Property Level Authorization (BOPLA)

Variante de BOLA mais au niveau des propriétés d’un objet. Imaginez l’endpoint PATCH /api/users/me qui met à jour le profil. Si l’API accepte naïvement tout le corps de la requête, l’utilisateur peut envoyer { "role": "admin" } et se promouvoir lui-même.

Mitigation : valider explicitement les champs autorisés avec une bibliothèque de schéma comme Zod (TypeScript) ou Pydantic (Python). Tout champ non listé est rejeté.

import { z } from 'zod';

const UpdateProfileSchema = z.object({
    firstName: z.string().min(1).max(60).optional(),
    lastName: z.string().min(1).max(60).optional(),
    avatarUrl: z.string().url().optional(),
}).strict();  // refuse les champs supplémentaires

app.patch('/api/users/me', authMiddleware, async (req, res) => {
    const parsed = UpdateProfileSchema.safeParse(req.body);
    if (!parsed.success) {
        return res.status(400).json({ errors: parsed.error.flatten() });
    }
    await prisma.user.update({
        where: { id: req.user.id },
        data: parsed.data,
    });
    res.json({ ok: true });
});

5 — API4 : Unrestricted Resource Consumption

Sans limite, un attaquant peut épuiser votre serveur ou votre quota cloud avec un script qui appelle un endpoint coûteux en boucle. Trois protections cumulables :

  • Rate limiting par IP et par token — par exemple 100 requêtes / minute avec express-rate-limit + Redis pour le compteur partagé entre instances.
  • Limite de taille de payload — refuser les requêtes au-delà de 1 Mo si vous n’attendez pas d’upload (express.json({ limit: '1mb' })).
  • Pagination obligatoire — un endpoint qui retourne une liste impose ?limit=50 maximum et un ?cursor= pour la suite.
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const apiLimiter = rateLimit({
    windowMs: 60 * 1000,
    limit: 100,
    standardHeaders: true,
    store: new RedisStore({
        sendCommand: (...args) => redis.sendCommand(args),
    }),
});

app.use('/api/', apiLimiter);

6 — API5 : Broken Function Level Authorization

Variante au niveau des opérations : un endpoint d’administration accessible à un utilisateur non-admin. Exemple typique : DELETE /api/users/:id que tout utilisateur authentifié peut appeler, alors qu’il ne devrait être réservé qu’aux administrateurs.

Mitigation : un middleware d’autorisation centralisé qui vérifie le rôle ou la permission requise pour chaque route.

function requireRole(role) {
    return (req, res, next) => {
        if (!req.user || !req.user.roles.includes(role)) {
            return res.status(403).json({ error: 'Forbidden' });
        }
        next();
    };
}

app.delete('/api/users/:id',
    authMiddleware,
    requireRole('admin'),
    async (req, res) => {
        await prisma.user.delete({ where: { id: req.params.id } });
        res.status(204).end();
    }
);

Pour un système plus mature, basculer vers le RBAC (Role-Based Access Control) ou ABAC (Attribute-Based) avec une bibliothèque dédiée comme CASL (TypeScript) ou Casbin (multi-langage).

7 — API7 : Server-Side Request Forgery (SSRF)

SSRF arrive quand votre API fait une requête HTTP sortante vers une URL fournie par l’utilisateur. Exemple : un endpoint qui télécharge une image depuis une URL qu’on lui donne pour la stocker. Un attaquant fournit une URL interne (http://169.254.169.254/latest/meta-data/ sur AWS) et récupère vos credentials cloud.

Mitigation : valider strictement l’URL et bloquer les plages IP internes.

import dns from 'dns/promises';
import ipaddr from 'ipaddr.js';

async function fetchSafe(urlStr) {
    const url = new URL(urlStr);
    if (!['http:', 'https:'].includes(url.protocol)) {
        throw new Error('Protocol non autorisé');
    }
    // Résoudre le DNS et bloquer les IPs internes
    const addrs = await dns.resolve(url.hostname);
    for (const addr of addrs) {
        const parsed = ipaddr.parse(addr);
        if (parsed.range() !== 'unicast') {
            throw new Error('IP interne refusée');
        }
    }
    return fetch(urlStr, { redirect: 'manual' });
}

8 — API8 : Security Misconfiguration

Ce sont les défauts de configuration : headers HTTP de sécurité absents, CORS trop permissif, messages d’erreur qui fuient des informations internes (stack traces en production), versions de dépendances obsolètes.

Le minimum vital en Express :

import helmet from 'helmet';
import cors from 'cors';

// Headers de sécu : CSP, HSTS, X-Content-Type-Options, etc.
app.use(helmet({
    contentSecurityPolicy: {
        directives: {
            defaultSrc: ["'self'"],
            imgSrc: ["'self'", "https:", "data:"],
            scriptSrc: ["'self'"],
        },
    },
}));

// CORS : liste blanche d'origines, jamais "*"
const allowedOrigins = ['https://app.maboutique.sn', 'https://admin.maboutique.sn'];
app.use(cors({
    origin: (origin, callback) => {
        if (!origin || allowedOrigins.includes(origin)) {
            callback(null, true);
        } else {
            callback(new Error('CORS bloqué'));
        }
    },
    credentials: true,
}));

// Cacher les détails techniques en prod
if (process.env.NODE_ENV === 'production') {
    app.use((err, req, res, next) => {
        console.error(err);
        res.status(500).json({ error: 'Internal server error' });
    });
}

9 — Validation des entrées et SQL injection

L’injection SQL classique disparaît quand on utilise un ORM (Prisma, Drizzle, TypeORM) ou des requêtes paramétrées. Le risque persiste seulement avec des requêtes brutes mal écrites.

// ❌ Vulnérable
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`;
db.query(query);

// ✅ Paramétré
db.query('SELECT * FROM users WHERE email = ?', [req.body.email]);

// ✅ Avec un ORM
prisma.user.findUnique({ where: { email: req.body.email } });

Pour les endpoints qui acceptent du texte libre (commentaire, description), valider la longueur, le format, et échapper côté affichage (jamais côté stockage). Utiliser DOMPurify si vous tolérez du HTML, sinon refuser.

10 — API9 : Improper Inventory Management

Une API mal documentée a des endpoints oubliés en production, des versions anciennes (/v1/) qui restent ouvertes alors qu’elles auraient dû être déprécies, des environnements de staging exposés sur internet sans authentification.

Mitigation organisationnelle :

  • Maintenir une documentation OpenAPI à jour, générée automatiquement depuis le code.
  • Inventorier les sous-domaines exposés (subfinder, amass, ou Cloudflare DNS panel).
  • Marquer chaque endpoint avec son statut (production, deprecated, legacy) et planifier les retraits.
  • Bloquer en production tout sous-domaine commençant par dev-, staging-, test- ou ajouter une authentification IP-based.

11 — Webhooks signés

Quand votre API reçoit des notifications d’un service tiers (Stripe, PayDunya, Wave, Meta), vérifier la signature HMAC du webhook. Sans cette vérification, un attaquant peut envoyer une fausse notification « paiement reçu » et déclencher une livraison gratuite.

import crypto from 'crypto';

app.post('/webhook/wave', express.raw({ type: 'application/json' }), (req, res) => {
    const signature = req.headers['x-wave-signature'];
    const expected = crypto
        .createHmac('sha256', process.env.WAVE_WEBHOOK_SECRET)
        .update(req.body)
        .digest('hex');

    if (signature !== expected) {
        return res.status(401).json({ error: 'Invalid signature' });
    }
    // Traiter le webhook authentifié
    const event = JSON.parse(req.body);
    // ...
    res.status(200).json({ received: true });
});

12 — Pipeline d’audit CI/CD

Trois outils gratuits à brancher dès le premier commit :

  • Dependabot (GitHub) ou Renovate — alertent sur les CVE des dépendances et proposent des PRs de mise à jour.
  • npm audit ou pnpm audit — exécuté en CI sur chaque PR ; bloque le merge si une CVE critique est introduite.
  • gitleaks ou trufflehog — détecte les secrets accidentellement commités (clés API, mots de passe). À ajouter en pre-commit hook côté local.
# .github/workflows/security.yml
name: Security
on: [pull_request]
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: pnpm install --frozen-lockfile
      - run: pnpm audit --audit-level=high
      - uses: gitleaks/gitleaks-action@v2

Références

Partager