
Il tipo MIME e il nome del file dichiarati dal browser non valgono nulla: leggi il tipo reale con finfo, controlla le dimensioni, ricodifica l'immagine e rigenera il nome con random_bytes. Archivia fuori dalla document root, in una cartella che non esegue nulla. Per un'immagine in base64, decodifica in modalità strict e controlla la dimensione prima di decodificare.
Ricevere un file inviato da un visitatore è una delle funzionalità più rischiose di un’applicazione web. Basta un controllo messo male e il server esegue il file ricevuto. Questo tutorial mostra il form, l’elaborazione e poi le verifiche che reggono davvero, con i test che dimostrano quali invece non reggono. Copre anche l’invio di immagini in base64, frequente con i cropper JavaScript.
Il form
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="MAX_FILE_SIZE" value="5242880">
<label for="photo">Photo</label>
<input type="file" name="photo" id="photo" accept="image/jpeg,image/png,image/gif,image/webp" required>
<button type="submit">Envoyer</button>
<p>Formats acceptés : JPEG, PNG, GIF, WebP. 5 Mo maximum.</p>
</form>L’attributo enctype="multipart/form-data" è obbligatorio: senza, il browser invia solo il nome del file e $_FILES resta vuoto. L’attributo accept e il campo MAX_FILE_SIZE migliorano l’esperienza lato browser, non sono controlli di sicurezza, visto che una richiesta si può fabbricare senza passare dal form.
Cosa contiene $_FILES
Un file inviato dal campo photo arriva in $_FILES['photo'], un array associativo di cinque voci. Due di queste vengono dal client e non devono mai servire da controllo.
| Chiave | Contenuto | Origine |
|---|---|---|
name | Nome originale del file, estensione compresa | il client |
type | Tipo MIME dichiarato | il client |
size | Dimensione in byte del file ricevuto | il server |
tmp_name | Percorso del file temporaneo sul server | il server |
error | Codice di stato dell’upload, 0 se è andato tutto bene | il server |
Il file ricevuto viene scritto in una cartella temporanea ed eliminato alla fine dello script. Per conservarlo bisogna spostarlo con move_uploaded_file(), oppure averlo letto prima che la richiesta finisca. La funzione is_uploaded_file() verifica che il percorso indichi davvero un file arrivato da un upload HTTP, il che impedisce di puntare l’elaborazione su un file di sistema.
Il controllo che non protegge da niente
Lo schema che segue si trova nella maggior parte dei tutorial, comprese le versioni precedenti di questo.
$autorises = ['jpg' => 'image/jpg', 'png' => 'image/png'];
$type = $_FILES['photo']['type']; // <- dichiarato dal browser
if (in_array($type, $autorises)) {
move_uploaded_file($_FILES['photo']['tmp_name'], 'upload/' . $_FILES['photo']['name']);
}Due falle, misurate su PHP 8.5.10.
Il valore $_FILES['photo']['type'] viene dal client. PHP non lo verifica mai. Un invio fabbricato può dichiarare qualsiasi cosa:
type annoncé : image/png
type réel : text/x-php
getimagesize() : falseIl file è uno script PHP, e il controllo lo lascia passare.
Anche il nome originale viene dal client. Riutilizzarlo così com’è per costruire un percorso apre la porta a diversi abusi:
'../../config.php' -> extension : php basename : config.php
'photo.php.png' -> extension : png basename : photo.php.png
'photo.png.php' -> extension : php basename : photo.png.php
'photo.pHp' -> extension : pHp basename : photo.pHpIl caso photo.php.png è il più subdolo: l’estensione vista da pathinfo() è davvero png, quindi il controllo passa, ma un Apache configurato con AddHandler su .php può eseguire il file per via dell’estensione intermedia. Il caso photo.pHp ricorda che il confronto fra estensioni è sensibile alle maiuscole.
La validazione che regge
Tre principi: il tipo si legge nel contenuto, non nella dichiarazione, il nome si rigenera, non si riusa mai, il file immagine si ricodifica.
<?php
declare(strict_types=1);
const TYPES_AUTORISES = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
/**
* @return array{0: bool, 1: string} successo e nome del file, oppure fallimento e motivo
*/
function validerImage(
string $chemin,
int $maxOctets = 5_242_880,
int $maxPixels = 50_000_000,
): array {
if (!is_file($chemin)) {
return [false, 'fichier absent'];
}
$taille = filesize($chemin);
if ($taille === false || $taille === 0) {
return [false, 'fichier vide'];
}
if ($taille > $maxOctets) {
return [false, 'trop volumineux'];
}
// Il tipo reale, letto nei byte del file
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($chemin);
if (!isset(TYPES_AUTORISES[$mime])) {
return [false, "type refusé : {$mime}"];
}
$infos = @getimagesize($chemin);
if ($infos === false) {
return [false, 'image illisible'];
}
[$largeur, $hauteur] = $infos;
if ($largeur < 1 || $hauteur < 1) {
return [false, "dimensions invalides ({$largeur}x{$hauteur})"];
}
if ($largeur * $hauteur > $maxPixels) {
return [false, 'bombe de décompression'];
}
// Ricodifica: unica difesa affidabile contro i file poliglotti
$image = @imagecreatefromstring(file_get_contents($chemin));
if ($image === false) {
return [false, 'décodage impossible'];
}
// Nome imprevedibile, e scrittura diretta nella cartella di storage.
// Passare da tempnam() e poi rename() fallirebbe se /tmp e lo storage
// stanno su due filesystem diversi, cosa che succede spesso
// in container.
$nom = bin2hex(random_bytes(16)) . '.' . TYPES_AUTORISES[$mime];
$destination = __DIR__ . '/../stockage/' . $nom;
$ok = match (TYPES_AUTORISES[$mime]) {
'jpg' => imagejpeg($image, $destination, 85),
'png' => imagepng($image, $destination),
'gif' => imagegif($image, $destination),
'webp' => imagewebp($image, $destination, 85),
};
if (!$ok) {
return [false, 'réencodage impossible'];
}
return [true, $nom];
}L’elaborazione della richiesta, con i codici di errore:
<?php
declare(strict_types=1);
require __DIR__ . '/src/upload.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Méthode non autorisée');
}
if (!isset($_FILES['photo'])) {
http_response_code(400);
exit('Aucun fichier reçu');
}
$erreur = $_FILES['photo']['error'];
if ($erreur !== UPLOAD_ERR_OK) {
$message = match ($erreur) {
UPLOAD_ERR_INI_SIZE => 'Fichier plus grand que upload_max_filesize',
UPLOAD_ERR_FORM_SIZE => 'Fichier plus grand que MAX_FILE_SIZE',
UPLOAD_ERR_PARTIAL => 'Envoi interrompu',
UPLOAD_ERR_NO_FILE => 'Aucun fichier sélectionné',
UPLOAD_ERR_NO_TMP_DIR => 'Dossier temporaire absent sur le serveur',
UPLOAD_ERR_CANT_WRITE => 'Écriture impossible sur le disque',
UPLOAD_ERR_EXTENSION => 'Envoi bloqué par une extension PHP',
default => 'Erreur inconnue',
};
http_response_code(400);
exit($message);
}
// Garantisce che il file arrivi davvero da un upload HTTP
if (!is_uploaded_file($_FILES['photo']['tmp_name'])) {
http_response_code(400);
exit('Envoi invalide');
}
[$succes, $resultat] = validerImage($_FILES['photo']['tmp_name']);
if (!$succes) {
http_response_code(422);
exit('Fichier refusé : ' . $resultat);
}
echo 'Enregistré sous ', htmlspecialchars($resultat, ENT_QUOTES, 'UTF-8');Cosa intercetta ogni controllo
Quattro file passati a validerImage(), su PHP 8.5.10 con GD 2.0.35:
| File inviato | Risultato | Controllo scattato |
|---|---|---|
Script PHP rinominato in .png | rifiutato | tipo reale: text/x-php |
| Poliglotta: header GIF + codice PHP | rifiutato | dimensioni (0×0 o assurde) |
| PNG autentico 120×120 | accettato | — |
| PNG autentico con del PHP accodato | accettato | ricodificato, il payload sparisce |
La seconda riga merita una pausa. Un file che inizia con GIF89a seguito da codice PHP viene identificato come image/gif da finfo. La libreria di rilevamento si fida della firma dei primi byte, e quella firma è corretta. Solo i controlli sulle dimensioni lo rifiutano: a seconda dei byte che seguono l’intestazione, getimagesize() legge 0×0 o dimensioni assurde, ed è allora il tetto di pixel a respingere il file. Una validazione che si ferma a finfo lo lascia passare.
La quarta riga è il caso più comune: un’immagine perfettamente valida a cui è stato concatenato un payload. Viene accettata, ed è giusto così, perché è una vera immagine, ma il file archiviato è il risultato della ricodifica, non l’originale. Il payload non sopravvive.
Lo storage conta quanto la validazione
Nessuna validazione sostituisce una cartella di storage che non esegue nulla. La cartella deve stare fuori dalla document root, e va servita da uno script che invia il content type giusto.
<IfModule mod_php.c>
php_flag engine off
</IfModule>
RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
RemoveType .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
Header set X-Content-Type-Options "nosniff"location ^~ /stockage/ {
location ~ \.php$ { return 403; }
add_header X-Content-Type-Options "nosniff" always;
}L’header X-Content-Type-Options: nosniff impedisce al browser di indovinare il tipo di un file e di eseguire l’HTML o il JavaScript trovato dentro un’immagine.
I limiti di php.ini decidono prima del tuo codice
foreach (['file_uploads','upload_max_filesize','post_max_size','max_file_uploads','memory_limit'] as $d) {
printf("%-20s %s\n", $d, ini_get($d));
}Un file più grande di post_max_size arriva con $_FILES e $_POST completamente vuoti: il test isset($_FILES['photo']) fallisce e il messaggio di errore è fuorviante. Questo caso va rilevato a parte:
if ($_SERVER['REQUEST_METHOD'] === 'POST' && empty($_POST) && empty($_FILES)
&& (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
http_response_code(413);
exit('Envoi plus grand que post_max_size (' . ini_get('post_max_size') . ')');
}Il blocco ha una condizione di applicazione che abbiamo scoperto provandolo: funziona solo se display_errors è disattivato. PHP emette il proprio warning prima ancora di eseguire una riga dello script, e se quel warning viene mostrato gli header sono già partiti.
display_errors = On -> HTTP 200 + « Warning: PHP Request Startup: POST Content-Length
of 8388808 bytes exceeds the limit of 6291456 bytes »
puis « Cannot set response code - headers already sent »
display_errors = Off -> HTTP 413 « Envoi plus grand que post_max_size (6M) »In produzione display_errors deve comunque stare su Off. È un motivo in più.
Con upload_max_filesize = 5M e post_max_size = 6M, un file da 5,5 MB supera la prima barriera e viene fermato dalla seconda, con UPLOAD_ERR_INI_SIZE e un codice 400. post_max_size deve restare superiore a upload_max_filesize, altrimenti il secondo valore non ha alcun effetto.
Ricevere un’immagine in base64
I cropper JavaScript e le catture da un <canvas> spesso inviano l’immagine come stringa data:image/png;base64,… invece che come file. Il principio di validazione non cambia, ma si aggiungono due trappole.
La prima: base64_decode() senza il suo secondo argomento accetta qualsiasi cosa e restituisce byte casuali invece di un errore.
var_dump(base64_decode('ceci n est pas du base64 !!')); // string(9) "q..." — byte arbitrari
var_dump(base64_decode('ceci n est pas du base64 !!', true)); // bool(false)La seconda: una stringa base64 pesa circa un terzo in più del dato che contiene. Controllare la dimensione prima di decodificare evita di allocare 40 MB per poi rifiutare un file da 30.
<?php
declare(strict_types=1);
/**
* @return array{0: bool, 1: string}
*/
function enregistrerImageBase64(
string $entree,
string $dossier,
int $maxOctets = 2_097_152,
): array {
if (!preg_match('#^data:image/(jpeg|png|gif|webp);base64,#', $entree, $m)) {
return [false, 'préfixe data: absent ou type non autorisé'];
}
$encode = substr($entree, strlen($m[0]));
// Il base64 pesa 4/3 del dato: si filtra prima di decodificare
if (strlen($encode) > (int) ceil($maxOctets * 4 / 3)) {
return [false, 'trop volumineux (avant décodage)'];
}
$binaire = base64_decode($encode, true); // strict
if ($binaire === false) {
return [false, 'base64 invalide'];
}
if (strlen($binaire) > $maxOctets) {
return [false, 'trop volumineux'];
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($binaire);
$extension = match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => null,
};
if ($extension === null) {
return [false, "type réel refusé : {$mime}"];
}
$infos = @getimagesizefromstring($binaire);
if ($infos === false || $infos[0] < 1 || $infos[1] < 1) {
return [false, 'image illisible'];
}
$nom = bin2hex(random_bytes(16)) . '.' . $extension;
if (file_put_contents(rtrim($dossier, '/') . '/' . $nom, $binaire, LOCK_EX) === false) {
return [false, 'écriture impossible'];
}
return [true, $nom];
}Risultati misurati su cinque input:
| Input | Risultato | Motivo |
|---|---|---|
| PNG 1×1 valido | accettato | — |
Codice PHP dichiarato image/png | rifiutato | tipo reale: text/x-php |
SVG con onload | rifiutato | tipo non consentito |
| Stringa qualsiasi | rifiutato | prefisso data: assente |
| Base64 corrotto | rifiutato | decodifica strict fallita |
L’SVG merita di essere escluso di proposito. È un formato XML che può contenere JavaScript e riferimenti esterni. Se proprio va accettato, deve passare da un sanitizer dedicato e non essere mai servito dallo stesso dominio dell’applicazione.
Due trappole del codice che si trova ovunque
Lo split ingenuo di una stringa data: dà per scontato che l’input sia ben formato:
$parties = explode(';base64,', $img);
$aux = explode('image/', $parties[0]);
$type = $aux[1]; // Undefined array key 1 se l'input è una stringa qualsiasi
$binaire = base64_decode($parties[1]); // Undefined array key 1, poi deprecation su nullE il nome di file generato con uniqid() non è imprevedibile: la funzione deriva dall’orologio, e i suoi valori successivi si indovinano.
6a987fd6cde92 | 6a987fd6cde95 | 6a987fd6cde96bin2hex(random_bytes(16)) usa il generatore crittografico del sistema. È l’unica forma corretta quando il nome non deve essere indovinabile.
Da ricordare
- Il tipo e il nome dichiarati dal client non valgono nulla: leggi il tipo nel contenuto, rigenera il nome.
finfoda solo non basta, un poliglotta GIF lo inganna. Aggiungi il controllo delle dimensioni e la ricodifica.- Archivia fuori dalla document root, in una cartella che non esegue nulla.
- In base64: decodifica strict, controllo della dimensione prima di decodificare, SVG rifiutato.
Per restituire all’interfaccia il nome del file salvato senza aprire falle, vedi passare variabili da PHP a JavaScript. Un file ricevuto di diverse decine di megabyte non si elabora tutto in un colpo: vedi leggere file di grandi dimensioni in PHP. Dopo il salvataggio, un redirect 303 evita il reinvio del form al ricaricamento. E la classe di validazione presentata qui segue i principi della programmazione a oggetti in PHP.
Per il seguito, vedi manipolare immagini con GD e Imagick e l’hub Sviluppo web.
Errori frequenti
image/png per uno script PHP. Il tipo reale si legge con finfo, mai in $_FILES['x']['type'].photo.php.png ha davvero l'estensione png per pathinfo(), ma un Apache configurato male può eseguirlo per via dell'estensione intermedia. Il nome va rigenerato.GIF89a seguito da codice PHP viene identificato come image/gif. Solo il controllo delle dimensioni lo rifiuta.bin2hex(random_bytes(16)).true, la funzione accetta qualsiasi cosa e restituisce byte arbitrari invece di false.$_FILES e $_POST arrivano vuoti. E il codice di rilevamento funziona solo se display_errors è su Off: altrimenti PHP stampa il suo warning prima del tuo script e gli header sono già partiti.

