TP8 : API Express sécurisée et testée (Zod, Helmet, rate limiting, Vitest)

Une API exposée sur un réseau reçoit n’importe quoi : champs manquants, types inattendus, JSON cassé, scripts, tentatives d’injection SQL, rafales de requêtes. Ce TP part d’une petite API de formulaire de contact volontairement fragile et la rend robuste, puis prouve cette robustesse par des tests automatisés que la CI pourra exécuter.


Fichiers de départ : api-starter.zip

1. Objectifs pédagogiques

À l’issue du TP, vous serez capable de :

  • situer les risques d’une API à l’aide de l’OWASP API Security Top 10 ;
  • valider strictement les entrées avec Zod et uniformiser les erreurs avec un middleware d’erreur centralisé ;
  • ajouter les headers de sécurité (Helmet), limiter la taille des requêtes et le débit (rate limiting) ;
  • écrire des tests de routes avec Vitest + Supertest, y compris des cas négatifs (injection, entrées malformées) ;
  • écrire un test d’intégration contre un vrai PostgreSQL lancé avec Docker Compose, et appliquer des migrations.

Les images de cette page sont des schémas explicatifs, pas des captures d’écran.

2. Environnement du TP

  • VM de lab Ubuntu Server 26.04 avec Node.js 24, npm, Docker et Compose (TP7).

  • Copier api-starter/ sur la VM puis :

    cd api-starter
    npm install
    npm test          # 2 tests passent, les autres sont marqués "todo"
Fichier fourni Rôle
src/app.js Fabrique createApp({ contactRepository }) (injection de dépendance)
src/routes/contact.js POST /api/contact sans validation
src/schemas/contact.js Schéma Zod à compléter
src/middlewares/validate.js, errorHandler.js Middlewares à compléter (l’actuel divulgue la stack trace !)
src/repositories/contactRepository.js Repository PostgreSQL (requêtes paramétrées) et repository en mémoire
migrations/, src/migrate.js Migrations SQL et script qui les applique
tests/ Un test modèle + des it.todo à transformer en tests

3. Partie 1 : OWASP API Security Top 10

Risque OWASP Concerne notre API ? Mesure dans ce TP
API1 Broken Object Level Authorization
API2 Broken Authentication
API3 Broken Object Property Level Authorization .strict() refuse les champs en trop (mass assignment)
API4 Unrestricted Resource Consumption rate limiting, taille max du corps
API8 Security Misconfiguration Helmet, pas de stack trace, pas de x-powered-by
…
  • Démarrer l’API (partie 5 pour la base) ou la lire, puis lister trois attaques que la version actuelle laisse passer.

4. Partie 2 : Validation et gestion des erreurs

Chaîne de middlewares : helmet, rate limit, json, validation, route, erreur Chaîne de middlewares : helmet, rate limit, json, validation, route, erreur

  • Compléter src/schemas/contact.js : name (trim, 2–100), email (format email, ≤ 254), message (10–2000), .strict().

  • Compléter src/middlewares/validate.js : en cas d’échec, répondre 400 avec un corps du type :

    {
      "error": "VALIDATION_ERROR",
      "details": [{ "field": "email", "message": "Invalid email" }]
    }
  • Brancher validate(contactSchema) dans src/routes/contact.js.

  • Réécrire src/middlewares/errorHandler.js : INVALID_JSON (400), PAYLOAD_TOO_LARGE (413), INTERNAL_ERROR (500), jamais de stack trace côté client (on la journalise côté serveur).

  • Ajouter une route 404 JSON ({ "error": "NOT_FOUND" }) avant le middleware d’erreur.

5. Partie 3 : Durcissement HTTP

  • app.disable("x-powered-by") puis app.use(helmet()). Comparer les headers avant/après :

    curl -I http://localhost:4000/api/health
  • Limiter le corps JSON : express.json({ limit: "10kb" }).

  • Ajouter express-rate-limit uniquement sur /api/contact (par exemple 5 requêtes par minute et par IP, headers RateLimit-* standard).

    for i in $(seq 1 7); do
      curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:4000/api/contact \
        -H 'Content-Type: application/json' \
        -d '{"name":"Ada","email":"[email protected]","message":"Bonjour le monde"}'
    done

    Derrière un reverse proxy (Nginx, Ingress), pensez à app.set("trust proxy", 1), sinon toutes les requêtes semblent venir de la même IP.

6. Partie 4 : Tests de routes et cas négatifs

Tests du TP : unitaires, routes avec Supertest, intégration avec PostgreSQL Tests du TP : unitaires, routes avec Supertest, intégration avec PostgreSQL

Outil Rôle
Vitest Lanceur de tests et assertions (describe, it, expect), compatible avec l’API de Jest.
Supertest Envoie des requêtes HTTP à l’application Express sans ouvrir de port.
Repository en mémoire Remplace PostgreSQL dans les tests de routes : rapides et sans dépendance.
  • Transformer chaque it.todo de tests/contact.test.js en test réel. Exemple de requête mal formée :

    const res = await request(app)
      .post("/api/contact")
      .set("Content-Type", "application/json")
      .send('{"name": "Ada",');          // JSON tronqué
    expect(res.status).toBe(400);
    expect(res.body.error).toBe("INVALID_JSON");
  • Pour le test « erreur 500 », injecter un repository dont create() lève une exception, et vérifier que la réponse ne contient ni stack ni le message d’origine.

  • Mesurer la couverture :

    npm run test:coverage

7. Partie 5 : Intégration avec PostgreSQL et migrations

  • Démarrer la base et appliquer les migrations :

    cp .env.example .env            # puis changer le mot de passe
    set -a; . ./.env; set +a
    docker compose up -d --wait
    npm run migrate                 # applique migrations/001_create_contacts.sql
    npm run migrate                 # 2e exécution : rien à faire (idempotent)
  • Ajouter une migration 002_add_contacts_email_index.sql (index sur email) et l’appliquer.

  • Compléter tests/integration/contact.int.test.js puis lancer :

    npm run test:integration
  • Démarrer l’API (npm start) et tester à la main avec curl : une requête valide, une invalide, une injection.

Défi

  1. Écrire un workflow GitHub Actions qui lance npm test, puis npm run test:integration avec un service container postgres:17-alpine.
  2. Ajouter une route GET /api/contact (liste paginée) avec validation des paramètres ?page=&limit= par Zod (z.coerce.number()), limitée à 50 éléments par page (API4).

8. Livrables

Captures à déposer dans captures/ :

Fichier Contenu attendu
validation-400.png Requête invalide et réponse VALIDATION_ERROR détaillée
helmet-headers.png curl -I avant / après Helmet
rate-limit-429.png Boucle curl se terminant par des 429
tests-ok.png npm test : tous les tests passent, plus aucun todo
coverage.png Rapport de couverture
integration-ok.png npm run test:integration contre PostgreSQL
ci-green.png (défi) workflow GitHub Actions réussi

Ainsi que le code complété et le tableau OWASP rempli.