Si vous avez déjà vu passer dans vos logs un POST vers admin-ajax.php avec un fichier nommé shell.php (ou pire, image.jpg.php), vous avez déjà frôlé le scénario classique : l’upload “pratique” qui devient une exécution de code à distance.
La menace
Une vulnérabilité d’upload de fichiers permet à un attaquant de déposer un fichier sur votre serveur. Le pire cas n’est pas “un fichier indésirable”, c’est un fichier exécutable (ou interprétable) qui ouvre la porte à :
- RCE (Remote Code Execution) : exécution de PHP si le fichier finit dans un répertoire exécutable et accessible publiquement.
- Webshell persistante : l’attaquant revient quand il veut, même après changement de mot de passe.
- Défacement, redirections SEO : injection de pages spam, redirections conditionnelles, cloaking.
- Exfiltration : vol de
wp-config.php, de la base (via accès DB), des clés API, des exports WooCommerce. - Mouvement latéral : pivot vers d’autres vhosts sur le même serveur mal isolé.
La fréquence réelle est élevée, parce que l’upload est partout : formulaires de contact, “envoyez votre avatar”, “déposez un PDF”, import CSV, import de templates, import de bibliothèques Divi/Elementor, etc. Dans mon expérience, les incidents les plus coûteux viennent rarement du cœur WordPress (WordPress 6.9.4 est plutôt strict sur l’upload média), mais de plugins “maison” ou snippets copiés qui contournent les garde-fous de wp_handle_upload().
Le risque en langage simple : vous croyez accepter “une image”, mais vous acceptez en réalité “un fichier” — et un fichier peut être un programme. La sécurité consiste à faire coïncider ce que vous pensez recevoir avec ce que le serveur va réellement stocker et servir.
Résumé rapide
- Ne faites jamais confiance à
$_FILES['name'], à l’extension, ni auContent-Typeenvoyé par le navigateur. - Validez côté serveur : MIME réel + extension attendue + taille + dimensions (si image) + contenu (si besoin).
- Utilisez les APIs WordPress :
wp_handle_upload(),wp_check_filetype_and_ext(),media_handle_upload()et les nonces/capabilities. - Stockez hors webroot si possible, sinon bloquez l’exécution dans
uploads(Apache/Nginx) et servez en lecture seule. - Journalisez l’upload (qui, quoi, IP, taille, résultat) et surveillez les extensions interdites.
- Protégez les endpoints (REST/AJAX) : nonce, capability, rate limiting, et interdisez l’upload aux visiteurs anonymes sauf besoin réel.
Code vulnérable — ce qu’il ne faut PAS faire
Voici un exemple réaliste que je croise encore en 2026 : un endpoint AJAX “simple” qui prend un fichier et le déplace dans wp-content/uploads. Ça marche… jusqu’au jour où ça ne marche plus.
<?php
/**
* Exemple VULNÉRABLE : ne copiez pas ce code.
* Problèmes : pas de nonce, pas de capability, validation naïve par extension,
* chemin prévisible, et move_uploaded_file() sans contrôle WordPress.
*/
add_action('wp_ajax_nopriv_bpcab_upload_avatar', 'bpcab_upload_avatar_vulnerable');
add_action('wp_ajax_bpcab_upload_avatar', 'bpcab_upload_avatar_vulnerable');
function bpcab_upload_avatar_vulnerable() {
// Aucune vérification d'intention (nonce) : n'importe qui peut appeler l'endpoint
// Aucune vérification de droits : même un visiteur non connecté
if ( empty($_FILES['file']) ) {
wp_send_json_error(['message' => 'Aucun fichier reçu'], 400);
}
$file = $_FILES['file'];
// Validation dangereuse : se base sur l'extension déclarée
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ( ! in_array($ext, ['jpg', 'jpeg', 'png', 'gif'], true) ) {
wp_send_json_error(['message' => 'Extension non autorisée'], 400);
}
$uploads = wp_upload_dir();
$dest = trailingslashit($uploads['basedir']) . 'avatars/' . $file['name'];
wp_mkdir_p(dirname($dest));
// move_uploaded_file() sans validation MIME réelle, ni contrôle de collisions
if ( ! move_uploaded_file($file['tmp_name'], $dest) ) {
wp_send_json_error(['message' => 'Échec de l’upload'], 500);
}
// Retourne une URL publique immédiatement
$url = trailingslashit($uploads['baseurl']) . 'avatars/' . rawurlencode($file['name']);
wp_send_json_success(['url' => $url]);
}
Comment l’attaque fonctionne (sans “mode d’emploi” d’exploitation)
Le problème vient de la confiance accordée à l’extension. Un attaquant peut :
- Envoyer un fichier dont le contenu n’est pas une image (ex : PHP) mais dont le nom finit par
.jpgou une variante. - Jouer sur les doubles extensions (
avatar.jpg.php) si votre validation est mal faite (ici, elle prend la dernière extension, doncphpserait bloqué… mais beaucoup de snippets prennent la première extension ou font unstrpos). - Profiter d’un serveur mal configuré qui exécute du PHP dans
uploads(ça arrive encore sur des hébergements mutualisés ou des stacks “custom”). - Écraser des fichiers existants (collision) ou déposer des fichiers avec des noms spéciaux (
.htaccess,index.php) si vous ne contrôlez pas le nom final.
Et même si le serveur n’exécute pas PHP dans uploads, l’attaquant peut déposer des HTML/JS (si vous les autorisez par erreur) et viser le vol de session via XSS stockée servie depuis votre domaine.
Code sécurisé — la bonne implémentation
Objectif : même fonctionnalité (upload d’avatar), mais avec une chaîne complète de protections. Je vous montre une approche “plugin-ready” compatible WordPress 6.9.4+ et PHP 8.1+, avec des points d’extension propres.
Architecture : service + endpoint REST (plutôt qu’AJAX) + validation stricte
J’ai tendance à préférer l’API REST pour ce type de fonctionnalité : c’est plus testable, plus clair côté auth, et vous pouvez brancher des contrôles (capabilities, nonces) proprement. Si vous devez rester sur AJAX (Divi/Avada le font parfois pour des modules), les mêmes principes s’appliquent.
1) Un “service” d’upload (validation + stockage)
<?php
/**
* Plugin: BPCAB Secure Uploads
* Cible: WordPress 6.9.4+, PHP 8.1+
*/
namespace BPCABSecurity;
use WP_Error;
final class Upload_Service {
/**
* Types autorisés pour un avatar.
* Note : évitez SVG pour un avatar si vous n'avez pas une chaîne de sanitization dédiée.
*/
private array $allowed_mimes = [
'jpg|jpeg|jpe' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
];
private int $max_bytes;
public function __construct(int $max_bytes = 2_000_000) {
$this->max_bytes = $max_bytes;
}
/**
* Upload sécurisé d'un avatar utilisateur.
*
* @param array $file Un élément de $_FILES (ex: $_FILES['file'])
* @param int $user_id ID utilisateur cible
* @return array|WP_Error Résultat wp_handle_upload + métadonnées
*/
public function handle_avatar_upload(array $file, int $user_id) {
if ( $user_id <= 0 ) {
return new WP_Error('bpcab_invalid_user', 'Utilisateur invalide.');
}
// 1) Vérifications basiques PHP upload
if ( empty($file['tmp_name']) || ! is_uploaded_file($file['tmp_name']) ) {
return new WP_Error('bpcab_invalid_upload', 'Upload invalide (tmp_name manquant).');
}
if ( ! empty($file['error']) ) {
return new WP_Error('bpcab_upload_error', 'Erreur PHP lors de l’upload : ' . (int) $file['error']);
}
// 2) Taille max
$size = (int) ($file['size'] ?? 0);
if ( $size <= 0 || $size > $this->max_bytes ) {
return new WP_Error('bpcab_file_too_large', 'Fichier trop volumineux.');
}
// 3) Vérification MIME + extension par WordPress (basée sur le contenu + nom)
// wp_check_filetype_and_ext() compare extension/mime déclarés vs détection.
$check = wp_check_filetype_and_ext(
$file['tmp_name'],
$file['name'],
$this->allowed_mimes
);
if ( empty($check['type']) || empty($check['ext']) ) {
return new WP_Error('bpcab_disallowed_type', 'Type de fichier non autorisé.');
}
// 4) Vérification image réelle (dimensions + entête)
$img = @getimagesize($file['tmp_name']);
if ( $img === false ) {
return new WP_Error('bpcab_not_an_image', 'Le fichier ne semble pas être une image valide.');
}
[$width, $height] = $img;
if ( $width < 64 || $height < 64 || $width > 4000 || $height > 4000 ) {
return new WP_Error('bpcab_bad_dimensions', 'Dimensions d’image non acceptées.');
}
// 5) Renommage : ne jamais réutiliser le nom fourni
$final_name = sprintf('avatar-%d-%s.%s', $user_id, wp_generate_password(12, false, false), $check['ext']);
$file['name'] = $final_name;
// 6) Utiliser wp_handle_upload() (déplace, gère collisions, applique filtres)
require_once ABSPATH . 'wp-admin/includes/file.php';
$overrides = [
'test_form' => false, // On gère l'auth nous-mêmes (REST/nonce), pas via $_POST['action']
'mimes' => $this->allowed_mimes,
];
add_filter('upload_dir', [$this, 'filter_avatar_upload_dir']);
$result = wp_handle_upload($file, $overrides);
remove_filter('upload_dir', [$this, 'filter_avatar_upload_dir']);
if ( isset($result['error']) ) {
return new WP_Error('bpcab_upload_failed', $result['error']);
}
// 7) Belt & suspenders : permissions fichier (dépend de l'hébergement)
@chmod($result['file'], 0644);
// 8) Enregistrer la référence (ex: user meta)
update_user_meta($user_id, 'bpcab_avatar_url', esc_url_raw($result['url']));
update_user_meta($user_id, 'bpcab_avatar_path', sanitize_text_field($result['file']));
return $result;
}
/**
* Isoler les avatars dans un sous-dossier dédié.
* Avantage : règles serveur plus strictes sur /uploads/avatars/.
*/
public function filter_avatar_upload_dir(array $dirs): array {
$subdir = '/avatars';
$dirs['subdir'] = $dirs['subdir'] . $subdir;
$dirs['path'] = $dirs['path'] . $subdir;
$dirs['url'] = $dirs['url'] . $subdir;
return $dirs;
}
}
2) Endpoint REST avec nonce + capability
Pour un utilisateur connecté, vous pouvez utiliser le nonce REST (header X-WP-Nonce). Côté permissions, j’applique une capability stricte : l’utilisateur doit pouvoir éditer son profil (ou une capability dédiée).
<?php
namespace BPCABSecurity;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
final class Upload_Controller {
public function __construct(
private Upload_Service $service
) {}
public function register_hooks(): void {
add_action('rest_api_init', function () {
register_rest_route('bpcab/v1', '/avatar', [
'methods' => 'POST',
'callback' => [$this, 'handle'],
'permission_callback' => [$this, 'permissions_check'],
'args' => [
'user_id' => [
'type' => 'integer',
'required' => false,
'sanitize_callback' => 'absint',
],
],
]);
});
}
public function permissions_check(WP_REST_Request $request): bool|WP_Error {
if ( ! is_user_logged_in() ) {
return new WP_Error('bpcab_auth_required', 'Authentification requise.', ['status' => 401]);
}
$user_id = (int) ($request->get_param('user_id') ?: get_current_user_id());
// Autoriser uniquement l'utilisateur lui-même, ou un admin qui peut éditer l'utilisateur.
if ( $user_id !== get_current_user_id() && ! current_user_can('edit_user', $user_id) ) {
return new WP_Error('bpcab_forbidden', 'Accès refusé.', ['status' => 403]);
}
// Protection CSRF : le nonce REST est géré par WP via X-WP-Nonce.
// Si vous appelez depuis un front custom, utilisez wp_create_nonce('wp_rest').
return true;
}
public function handle(WP_REST_Request $request): WP_REST_Response {
$user_id = (int) ($request->get_param('user_id') ?: get_current_user_id());
$files = $request->get_file_params();
if ( empty($files['file']) ) {
return new WP_REST_Response(['message' => 'Champ fichier manquant.'], 400);
}
$result = $this->service->handle_avatar_upload($files['file'], $user_id);
if ( is_wp_error($result) ) {
return new WP_REST_Response([
'code' => $result->get_error_code(),
'message' => $result->get_error_message(),
], 400);
}
return new WP_REST_Response([
'url' => esc_url_raw($result['url']),
'type' => sanitize_text_field($result['type']),
], 200);
}
}
3) Bootstrap “propre” avec mini-container (DI)
Sur des sites avancés, je vois souvent le code d’upload collé dans functions.php. Ça marche, puis quelqu’un change de thème, et vous perdez vos protections. Mettez ça en plugin, et injectez vos dépendances.
<?php
/**
* Fichier principal du plugin (ex: bpcab-secure-uploads.php)
*/
use BPCABSecurityUpload_Service;
use BPCABSecurityUpload_Controller;
if ( ! defined('ABSPATH') ) {
exit;
}
require_once __DIR__ . '/src/Upload_Service.php';
require_once __DIR__ . '/src/Upload_Controller.php';
add_action('plugins_loaded', function () {
$service = new Upload_Service(max_bytes: 2_000_000);
$controller = new Upload_Controller($service);
$controller->register_hooks();
});
Pourquoi cette implémentation tient la route (simple puis technique)
En langage simple : vous limitez qui peut uploader, vous vérifiez que le fichier est vraiment une image, vous lui donnez un nom sûr, vous le stockez dans un endroit contrôlé, et vous empêchez les “surprises” (types bizarres, tailles énormes, collisions).
Techniquement :
is_uploaded_file()+$_FILES['error']protègent contre des entrées non issues de PHP upload.wp_check_filetype_and_ext()est le pivot : WordPress tente de faire correspondre extension/MIME et peut corriger l’extension si besoin. Référence : developer.wordpress.org — wp_check_filetype_and_ext().getimagesize()empêche beaucoup de faux fichiers “image/*” (sans être une preuve cryptographique). Pour aller plus loin, vous pouvez re-générer l’image via GD/Imagick (re-encode) et jeter l’original.wp_handle_upload()applique la logique WordPress (dossier d’upload, collisions, hooks). Référence : developer.wordpress.org — wp_handle_upload().- Le renommage casse les attaques basées sur le nom (traversal, collisions, dépôt de
.htaccess, etc.).
Cas “PDF / ZIP / CSV” : ne copiez pas la stratégie image
Pour des documents, getimagesize() ne sert à rien. Votre validation doit devenir “format-aware” :
- PDF : vérifier MIME réel, taille, et idéalement analyser côté serveur (mais attention aux parseurs vulnérables).
- CSV : traiter comme texte, rejeter les encodages inattendus, et protéger contre l’injection CSV (formules Excel) si vous ré-exportez.
- ZIP : danger élevé (zip bombs, traversal à l’extraction). Évitez d’accepter ZIP si vous n’avez pas une extraction sécurisée.
Compatibilité Divi 5 / Elementor / Avada (points concrets)
Les page builders déclenchent souvent des uploads via AJAX/REST internes. Deux pièges :
- Nonces : Elementor et Divi utilisent des nonces propres pour certaines actions, mais si vous exposez votre endpoint REST, utilisez le nonce REST standard (
wp_rest) pour rester compatible. - Cache : Avada/Divi peuvent mettre en cache des scripts. Si votre front envoie un nonce expiré, vous aurez des 401 intermittents. J’ai souvent résolu ça en forçant le rafraîchissement du nonce via une requête REST légère.
Si vous devez fournir un shortcode pour intégrer un formulaire d’upload dans un module Divi/Elementor, faites-le, mais laissez l’upload réel au endpoint REST sécurisé. Ne réimplémentez pas la validation dans le JS.
Configuration serveur
Le code applicatif ne suffit pas. La règle que j’applique : même si un fichier PHP arrive dans uploads, il ne doit pas pouvoir s’exécuter.
Apache (.htaccess) : bloquer l’exécution PHP dans uploads
À placer dans wp-content/uploads/.htaccess (ou mieux, dans wp-content/uploads/avatars/.htaccess si vous isolez).
# Bloquer l'exécution de scripts dans uploads (Apache)
# À déposer dans wp-content/uploads/.htaccess
<IfModule mod_php.c>
php_flag engine off
</IfModule>
# Empêcher l'exécution via handlers (selon config)
RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .phar
RemoveType .php .phtml .php3 .php4 .php5 .php7 .phar
<FilesMatch ".(php|phtml|phar|cgi|pl|asp|aspx)$">
Require all denied
</FilesMatch>
# Optionnel : empêcher l'upload de .htaccess via un mauvais code
<FilesMatch "^.ht">
Require all denied
</FilesMatch>
Attention : selon l’hébergement, php_flag peut être interdit. Dans ce cas, la partie FilesMatch reste utile. Testez toujours après mise en place.
Nginx : location pour uploads (exemple)
À adapter à votre vhost. L’idée : refuser toute exécution de scripts dans /wp-content/uploads/.
# Nginx : bloquer l'exécution de scripts dans uploads
location ^~ /wp-content/uploads/ {
location ~* .(php|phtml|phar|cgi|pl|asp|aspx)$ {
return 403;
}
add_header X-Content-Type-Options "nosniff" always;
}
wp-config.php : durcissement utile (sans casser l’admin)
Ces réglages ne “réparent” pas l’upload, mais réduisent l’impact.
<?php
// Empêche l'édition de fichiers depuis l'admin (réduit l'impact si compte compromis)
define('DISALLOW_FILE_EDIT', true);
// Optionnel : bloque l'installation/maj de plugins/thèmes depuis l'admin (workflow via CI/CD)
define('DISALLOW_FILE_MODS', false); // Mettez true seulement si vous maîtrisez votre pipeline
// Forcer SSL admin si disponible
define('FORCE_SSL_ADMIN', true);
Headers HTTP : limiter les dégâts côté navigateur
Sur les fichiers servis depuis votre domaine (y compris uploads), nosniff limite certains contournements MIME. Ajoutez aussi une CSP cohérente (au moins sur l’admin et les pages sensibles).
# Apache (extrait vhost ou .htaccess si autorisé)
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Vérifier si votre site est vulnérable
Signaux dans la base : pièces jointes suspectes
Si vous utilisez la médiathèque, les uploads finissent souvent en attachment. Cherchez des extensions interdites et des noms bizarres.
# Liste des pièces jointes avec GUID suspect (WP-CLI)
wp db query "
SELECT ID, post_date, post_title, guid
FROM wp_posts
WHERE post_type='attachment'
AND (
guid REGEXP '\\.(php|phtml|phar|js|html)(\\?|$)'
OR post_title REGEXP '\\.(php|phtml|phar)$'
)
ORDER BY post_date DESC
LIMIT 200;
"
Limite : un upload vulnérable peut déposer des fichiers sans passer par la médiathèque. Dans ce cas, la DB est propre… et le disque ne l’est pas.
Scan ciblé du dossier uploads (sans “outil d’attaque”)
Je préfère un scan simple par extension, puis une inspection manuelle. Sur un serveur, cherchez les fichiers exécutables dans uploads :
# Rechercher des fichiers potentiellement exécutables dans uploads
find wp-content/uploads -type f ( -iname "*.php" -o -iname "*.phtml" -o -iname "*.phar" -o -iname "*.cgi" ) -print
# Rechercher des doubles extensions fréquentes
find wp-content/uploads -type f -regextype posix-extended -regex ".*.(jpg|png|gif|webp).(php|phtml|phar)$" -print
Logs : ce qu’il faut chercher
- POST vers
admin-ajax.php,wp-json/ou des endpoints plugin, avec des tailles de requête élevées. - Réponses 200 suivies d’un GET immédiat vers un fichier fraîchement uploadé (pattern “upload puis exécution/lecture”).
- Codes 403/404 sur
/wp-content/uploads/*.php: paradoxalement, c’est bon signe si vous avez bloqué l’exécution, mais c’est aussi un indicateur d’attaque.
Tableau de diagnostic (symptômes réalistes)
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
Fichiers .php trouvés dans wp-content/uploads |
Upload non filtré via plugin/snippet, ou site compromis | find + logs d’accès autour de la date de création |
Bloquer exécution (Apache/Nginx), supprimer fichiers, corriger code d’upload, rotation secrets |
| Images “cassées” après ajout de validation | MIME détecté différent (ex: HEIC, AVIF) ou proxy/CDN altère headers | Inspecter wp_check_filetype_and_ext() et le type retourné |
Autoriser explicitement les formats nécessaires (avec prudence) ou convertir côté serveur |
| 401/403 intermittents sur endpoint upload | Nonce expiré, cache agressif (Divi/Avada), ou permission_callback trop strict | Vérifier header X-WP-Nonce, horloge serveur, cache |
Rafraîchir nonce côté front, exclure endpoint du cache, ajuster permissions |
| Erreur “Sorry, you are not allowed to upload this file type.” | MIME non autorisé / extension non mappée | Comparer type réel et liste mimes |
Ajouter MIME strictement nécessaire via upload_mimes (si justifié) |
| Upload OK mais fichier inaccessible (403) | Règles serveur trop strictes sur sous-dossier | Tester accès direct, vérifier règles Nginx/Apache | Ajuster règles pour autoriser lecture des images tout en bloquant scripts |
Erreurs de sécurité fréquentes
| Erreur | Risque | Solution |
|---|---|---|
Valider uniquement l’extension (.jpg) |
Bypass via contenu non-image, double extension, polyglots | Utiliser wp_check_filetype_and_ext() + contrôles format (ex: getimagesize()) |
Faire confiance à $_FILES['type'] |
MIME falsifiable côté client | Détecter côté serveur (WP/PHP) et limiter la liste mimes |
| Oublier nonce/capabilities sur AJAX/REST | CSRF / upload anonyme | Nonce REST (X-WP-Nonce) + permission_callback stricte |
| Copier le snippet dans le mauvais endroit (thème au lieu de plugin) | Perte de la protection au changement de thème | Mettre la logique dans un plugin, versionné |
Utiliser un hook inadapté (ex: exécuter wp_handle_upload avant plugins_loaded) |
Fonctions non chargées, comportement inconsistent | Charger wp-admin/includes/file.php au bon moment, utiliser rest_api_init |
| Tester sur production sans sauvegarde | Blocage d’uploads légitimes, perte de contenu | Tester en staging, sauvegarde fichiers + DB, plan de rollback |
| Oublier un point-virgule/parenthèse dans un plugin de snippets | Fatal error, site down | Déployer via Git + CI, ou au minimum activer un snippet en staging d’abord |
| Conflit cache / oublier de vider le cache navigateur/CDN | Nonce expiré, JS ancien qui envoie mauvais header | Bypass cache sur endpoints, purge CDN, versionner assets |
| Permaliens non régénérés après ajout de routes | 404 sur endpoints | Vérifier wp-json, réenregistrer les permaliens si besoin |
| Autoriser SVG “parce que c’est pratique” | XSS stockée via SVG (script, foreignObject) | Éviter SVG ou passer par une sanitization robuste + servir avec headers stricts |
| Code d’ancien tutoriel incompatible WP 6.9+ / PHP 8.1 | Contrôles absents, warnings, comportements inattendus | Réviser : REST + wp_check_filetype_and_ext + types stricts + tests |
Checklist de durcissement
- Endpoints : tous les endpoints d’upload ont un nonce + une vérification de capability.
- Validation : vous utilisez
wp_check_filetype_and_ext()avec une listemimesminimale. - Renommage : vous ignorez le nom original et générez un nom non prédictible.
- Taille : limite en octets côté code + limites serveur cohérentes (PHP/NGINX/Apache).
- Images : contrôle dimensions + option de re-encodage (re-génération) si le risque est élevé.
- Stockage : sous-dossier dédié (
/uploads/avatars) avec règles serveur spécifiques. - Exécution : exécution de scripts interdite dans
uploads(Apache/Nginx). - Permissions : fichiers 0644, dossiers 0755 (à adapter), pas de 0777.
- Journalisation : logs applicatifs (user_id, IP, taille, type, résultat), alertes sur extensions interdites.
- Revue plugins : tout plugin qui “upload” est audité (forms, sliders, importers, builders).
- Backups : sauvegarde quotidienne fichiers + DB, test de restauration.
- WAF/CDN : règles sur endpoints upload (rate limiting, blocage patterns évidents).
Que faire si le site est déjà compromis ?
- Mettre le site en maintenance (ou au minimum bloquer l’admin) pour éviter la réinfection pendant l’analyse.
- Isoler : coupez les accès FTP partagés, changez les mots de passe d’hébergement, et vérifiez qu’il n’y a pas d’autres sites sur le même compte affectés.
- Sauvegarder pour analyse : faites une copie complète (fichiers + DB) avant suppression. Vous en aurez besoin pour comprendre le vecteur.
- Identifier le point d’entrée :
- logs d’accès autour de la date de création des fichiers suspects,
- plugins récemment ajoutés/mis à jour,
- endpoints d’upload custom.
- Supprimer les fichiers malveillants :
- supprimez tout exécutable dans
uploads, - vérifiez
wp-content/mu-plugins,wp-content/plugins,wp-content/themespour des backdoors, - cherchez des fichiers récemment modifiés.
- supprimez tout exécutable dans
- Réinstaller WordPress core (fichiers) depuis une source propre (sans toucher
wp-config.php), puis réinstaller plugins/thèmes depuis les ZIP officiels. - Rotation complète des secrets :
- mots de passe WP (admins en priorité),
- mots de passe DB, FTP/SSH,
- clés et salts dans
wp-config.php(via WordPress.org secret-key service), - tokens API (SMTP, CDN, paiements).
- Vérifier la base :
- admins inconnus,
- options injectées (ex:
siteurl,home, contenu danswp_options), - contenu spam dans posts/pages.
- Durcir serveur : appliquez les règles “uploads non exécutable” et ajoutez des headers
nosniff. - Remettre en ligne progressivement : surveillez logs, activez un monitoring d’intégrité (hash), et gardez un œil sur les nouveaux fichiers créés dans
uploads.
Conseils de maintenance et compatibilité
Sur WordPress 6.9.4, les APIs d’upload et la REST API sont stables. Le risque vient surtout de vos couches : plugins, snippets, intégrations builder.
Évitez les “allowlists” trop larges
J’ai souvent vu des sites ajouter upload_mimes pour autoriser “tout et n’importe quoi” (SVG, HTML, JS) parce qu’un builder ou un client “en a besoin”. C’est une dette de sécurité immédiate. Si vous devez autoriser un type :
- faites-le uniquement sur un endpoint authentifié,
- stockez dans un sous-dossier dédié,
- servez avec headers stricts (et idéalement via un domaine statique séparé).
Performance/SEO : servir les uploads via CDN ne doit pas casser la sécurité
Un CDN peut modifier des headers ou faire du “content sniffing”. Gardez X-Content-Type-Options: nosniff côté origine et vérifiez la cohérence des Content-Type servis. Un mauvais Content-Type peut transformer une erreur d’upload en XSS.
Edge cases : race conditions et collisions
Si vous générez des noms prédictibles (ex: avatar-123.jpg), deux uploads simultanés peuvent se marcher dessus. Utilisez un suffixe aléatoire (comme dans l’exemple), ou stockez par date/UUID. wp_handle_upload() gère certaines collisions, mais je préfère ne pas dépendre d’un nom stable.
Compatibilité builders : où ça casse vraiment
- Divi 5 : si vous encapsulez l’upload dans un module, ne mettez pas la logique PHP dans le rendu du module. Gardez un endpoint REST et appelez-le en JS.
- Elementor : attention aux conflits avec des security plugins qui bloquent
wp-json. Testez l’upload en mode connecté et en mode éditeur. - Avada : certains setups minifient/concatènent agressivement. Versionnez vos scripts et excluez les endpoints d’upload du cache.
Ressources
- developer.wordpress.org — wp_handle_upload()
- developer.wordpress.org — wp_check_filetype_and_ext()
- developer.wordpress.org — REST API Handbook
- developer.wordpress.org — Plugin Security
- WordPress.org — Hardening WordPress
- core.trac.wordpress.org — WordPress Core Trac (recherche tickets upload/security)
- GitHub — WordPress/wordpress-develop (code source et PRs)
- php.net — Handling file uploads
- php.net — finfo_file() (détection MIME)
FAQ
Est-ce que wp_handle_upload() suffit à lui seul ?
Non. wp_handle_upload() est une brique, pas une politique. Vous devez cadrer qui peut uploader, quoi (liste MIME), où (dossier), et comment (renommage, limites, logs).
Pourquoi ne pas utiliser uniquement media_handle_upload() ?
media_handle_upload() est excellent si vous voulez créer une pièce jointe (attachment) et générer les métadonnées. Pour un avatar, vous ne voulez pas forcément polluer la médiathèque. Si vous voulez la médiathèque, utilisez-le, mais gardez la validation (mimes, taille, droits) en amont. Référence : developer.wordpress.org — media_handle_upload().
Puis-je autoriser SVG si je le “sanitize” ?
Techniquement oui, pratiquement c’est une source d’incidents. La sanitization SVG fiable est non-triviale (XSS, liens externes, scripts, foreignObject). Si vous devez le faire, servez les SVG avec des headers stricts et envisagez un domaine statique séparé. Pour un avatar, je déconseille.
Pourquoi renommer le fichier si WordPress sait déjà gérer les collisions ?
Le renommage casse les attaques basées sur le nom et réduit les collisions logiques (ex: écraser l’avatar d’un autre). Même si WordPress renomme en -1, vous gardez un nom contrôlé et non prédictible.
Que vaut la détection MIME par PHP (fileinfo) par rapport à WordPress ?
WordPress s’appuie sur des mécanismes PHP et sur des règles internes. Si vous avez un cas sensible (documents), vous pouvez ajouter une couche finfo_file() et comparer avec votre allowlist. Référence : php.net — finfo_open().
Mon endpoint REST renvoie 401 alors que je suis connecté : pourquoi ?
Dans 80% des cas : nonce manquant/expiré, ou cache qui sert une page avec un ancien nonce. Vérifiez le header X-WP-Nonce et excluez l’endpoint du cache (plugin, serveur, CDN).
Est-ce une bonne idée de stocker les uploads hors webroot ?
Oui, si votre infra le permet. C’est l’une des meilleures mesures : même si un fichier dangereux est uploadé, il n’est pas accessible directement. Vous devrez alors mettre en place un script de “download” qui vérifie les droits et stream le fichier (avec readfile / streams) — et ça doit être fait proprement.
Que faire si un plugin tiers gère l’upload et je ne peux pas modifier le code ?
Commencez par la couche serveur : bloquez l’exécution dans uploads. Ensuite, réduisez la surface : désactivez les uploads anonymes, limitez les types via réglages du plugin, et mettez un WAF/rate limit sur l’endpoint concerné. Si le plugin est maintenu, ouvrez un ticket avec un PoC défensif (description + logs) plutôt qu’un exploit.
Pourquoi test_form => false dans wp_handle_upload ?
Parce que dans un contexte REST, vous n’avez pas forcément le champ action ou la structure “form” attendue. Vous compensez en faisant vous-même la vérification d’auth (nonce/capability) et les validations strictes.
J’ai copié le code et j’ai une fatal error “Class not found”
Classique : fichier mal inclus, namespace non respecté, ou code collé dans functions.php sans autoload. Mettez le code dans un plugin, vérifiez les require_once, et assurez-vous que le namespace correspond au chemin. Testez en staging, pas en production.
Est-ce que WordPress 6.9.4 change quelque chose aux uploads par rapport aux versions plus anciennes ?
Le cœur a durci progressivement la validation MIME/extension au fil des versions, mais le modèle reste le même : si vous contournez les APIs (ex: move_uploaded_file() direct), vous perdez les protections. La “nouveauté” en pratique en 2026, c’est surtout l’usage plus systématique de REST et des stacks headless — donc plus d’endpoints custom à auditer.