Un projet headless pour un client media construit son front-end en Next.js et communique avec WordPress exclusivement via l’API REST, y compris pour permettre aux journalistes de téléverser des images depuis l’interface de rédaction externe. Le développeur en charge du point d’entrée REST personnalisé, plus habitué aux frameworks JavaScript qu’à l’écosystème WordPress, a écrit une route fonctionnelle en quelques lignes — sans savoir que WordPress applique normalement, lors d’un upload classique via l’administration, une série de vérifications qu’aucune route REST personnalisée n’hérite automatiquement.
C’est un piège récurrent dans les projets headless : l’équipe front-end maîtrise parfaitement son framework, l’équipe back-end connaît WordPress, mais la frontière entre les deux — une route REST personnalisée qui gère un upload — se retrouve parfois écrite par la personne la moins familière avec les protections que WordPress fournit ailleurs, et qui ignore donc qu’il faut les reproduire explicitement.
Ce que wp_handle_upload fait, sans qu’on y pense
Quand un fichier est envoyé depuis l’administration WordPress classique (médiathèque, champ d’upload d’un formulaire natif), il transite par wp_handle_upload(), une fonction qui regroupe silencieusement plusieurs contrôles : vérification du type MIME réel via wp_check_filetype_and_ext(), restriction aux extensions autorisées par le filtre upload_mimes, génération d’un nom de fichier unique et assaini, et exécution des hooks de sécurité qu’une extension de protection (antivirus, pare-feu applicatif) aurait pu accrocher sur ce point d’entrée standard.
Une route REST personnalisée qui manipule $_FILES directement, sans jamais appeler wp_handle_upload(), ne bénéficie d’aucune de ces protections — même si l’extension WordPress installée par ailleurs promet une protection générale des uploads, puisque cette protection est souvent accrochée précisément sur ce point d’entrée standard qui, ici, n’est jamais sollicité.
La checklist des quatre contrôles à reproduire

- Appeler
wp_handle_upload()plutôt que de traiter$_FILESà la main, pour hériter automatiquement de l’ensemble des vérifications natives plutôt que de les réinventer partiellement :add_action( 'rest_api_init', function() { register_rest_route( 'redaction/v1', '/upload-image', array( 'methods' => 'POST', 'callback' => 'moncpt_upload_image_redaction', 'permission_callback' => function() { return current_user_can( 'upload_files' ); }, ) ); } ); function moncpt_upload_image_redaction( WP_REST_Request $request ) { if ( empty( $_FILES['image'] ) ) { return new WP_Error( 'fichier_manquant', 'Aucun fichier reçu.', array( 'status' => 400 ) ); } require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $resultat = wp_handle_upload( $_FILES['image'], array( 'test_form' => false ) ); if ( isset( $resultat['error'] ) ) { return new WP_Error( 'upload_refuse', $resultat['error'], array( 'status' => 400 ) ); } return new WP_REST_Response( array( 'url' => $resultat['url'] ), 201 ); } - Ne jamais omettre
permission_callback, en vérifiant une capacité réellement adaptée à l’upload (upload_files), jamais un simple__return_truelaissé « pour tester » et oublié en production — une négligence malheureusement fréquente sur les routes développées dans l’urgence. - Limiter explicitement la taille acceptée avant même d’appeler
wp_handle_upload(), indépendamment de la limite globaleupload_max_filesizedu serveur, pour éviter qu’un point d’entrée dédié à des photos de quelques mégaoctets n’accepte silencieusement un fichier de plusieurs centaines de mégaoctets et sature l’espace disque ou la mémoire du traitement d’image :if ( $_FILES['image']['size'] > 8 * MB_IN_BYTES ) { return new WP_Error( 'fichier_trop_volumineux', 'Image limitée à 8 Mo.', array( 'status' => 413 ) ); } - Restreindre la liste des types MIME acceptés à ce qui est réellement utile pour ce point d’entrée précis, via le filtre
upload_mimesappliqué localement à cette route plutôt que globalement à tout le site, pour éviter qu’un point d’entrée pensé pour des photos n’accepte aussi, par héritage de la configuration générale, des formats sans rapport avec l’usage (archives, documents exécutables sur certains systèmes) :add_filter( 'upload_mimes', function( $mimes ) { return array( 'jpg|jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', ); }, 10, 1 ); // À restreindre au contexte de cette route précise via une vérification // de l'action REST en cours, pour ne pas affecter le reste du site.
Un test simple pour vérifier qu’une route hérite bien de ces protections
Une fois la route corrigée, un test rapide avec curl confirme le comportement attendu, en tentant volontairement d’envoyer un fichier PHP renommé en .jpg, exactement le type de test qui aurait révélé la faille avant sa mise en production :
curl -X POST "https://exemple.fr/wp-json/redaction/v1/upload-image" \
-H "Authorization: Bearer TOKEN_DE_TEST" \
-F "image=@webshell-deguise.php.jpg;type=image/jpeg"
# Doit renvoyer une erreur explicite, jamais un succès avec une URL de fichier
Sur les projets headless que nous auditons, la première question posée à propos de toute route d’upload personnalisée est invariablement la même : « appelle-t-elle
wp_handle_upload(), ou traite-t-elle$_FILESà la main ? ». La seconde réponse déclenche systématiquement une revue approfondie.
Ce que cette checklist ne couvre pas
Ce sujet ne traite pas de la sécurisation de l’authentification des routes REST elle-même (jetons d’application, JWT, OAuth), un préalable indispensable mais distinct, traité par ailleurs. Une route d’upload parfaitement protégée sur le plan des fichiers acceptés reste inutile si l’authentification qui la protège en amont est elle-même mal conçue.