Upload di file in PHP: validazione sicura e immagini base64

Upload di file in PHP: validazione sicura e immagini base64
Risposta rapida

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

public/formulaire.html
<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.

php
$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:

code
type annoncé   : image/png
type réel      : text/x-php
getimagesize() : false

Il 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:

code
'../../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.pHp

Il 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.

src/upload.php
<?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:

upload.php
<?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.

stockage/.htaccess
<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"
nginx.conf
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

php
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:

php
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.

code
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.

php
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.

src/base64.php
<?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:

php
$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 null

E il nome di file generato con uniqid() non è imprevedibile: la funzione deriva dall’orologio, e i suoi valori successivi si indovinano.

code
6a987fd6cde92 | 6a987fd6cde95 | 6a987fd6cde96

bin2hex(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.
  • finfo da 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

Il tipo MIME di $_FILES viene dal client Un invio fabbricato dichiara image/png per uno script PHP. Il tipo reale si legge con finfo, mai in $_FILES['x']['type'].
Nome originale riutilizzato 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.
finfo da solo non basta Un file che inizia con GIF89a seguito da codice PHP viene identificato come image/gif. Solo il controllo delle dimensioni lo rifiuta.
uniqid() per dare un nome a un file La funzione deriva dall'orologio e i suoi valori si indovinano. Usa bin2hex(random_bytes(16)).
base64_decode() non strict Senza il secondo argomento a true, la funzione accetta qualsiasi cosa e restituisce byte arbitrari invece di false.
post_max_size superato $_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.
tempnam() seguito da rename() Fallisce se la cartella temporanea e lo storage stanno su due filesystem diversi, cosa comune in container. Meglio scrivere direttamente a destinazione.
SVG accettato come immagine È XML e può contenere JavaScript. Da escludere, oppure da sanificare e servire da un altro dominio.

Base64ImagesPHPSécuritéUpload

Damien Flandrin Sviluppatore web dal 2010, creatore di Gekkode e di Email Impact. Ogni articolo è testato su un progetto reale prima della pubblicazione. Contatti
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.