Sicurezza dei file upload in Symfony: validazione profonda e archiviazione sicura

Sicurezza dei file upload in Symfony: validazione profonda e archiviazione sicura

A marzo 2025, durante un assessment di sicurezza su un portale di gestione documentale Symfony per un'azienda del settore legale, ho trovato una vulnerabilità che avrebbe permesso a qualsiasi utente autenticato di ottenere l'esecuzione di codice PHP sul server. Il portale permetteva l'upload di documenti PDF, Word e immagini per i fascicoli dei casi legali - funzionalità fondamentale per il workflow degli avvocati. La validazione dell'upload controllava solo l'estensione del file: se il nome finiva con .pdf, .docx, .jpg o .png, l'upload veniva accettato. Il file veniva salvato nella directory public/uploads/ con il nome originale e servito direttamente da Nginx.

Il problema: bastava rinominare un file PHP in shell.pdf.php, caricarlo, e accedere a https://portale.it/uploads/shell.pdf.php per eseguire codice arbitrario sul server - un attacco noto come unrestricted file upload che è nella OWASP Top 10 da oltre 15 anni ma che continuo a trovare nel 15-20% delle applicazioni PHP che audito. La variante con doppia estensione (file.php.jpg) è ancora più comune perché alcune configurazioni di Apache e Nginx interpretano il file in base al content-type inviato nell'header piuttosto che all'estensione - e se l'header dice application/x-httpd-php, il file viene eseguito indipendentemente dall'estensione.

In questo articolo ti mostro la catena di validazione completa che implemento su ogni portale Symfony che gestisce upload di file - dalla validazione del tipo MIME reale alla sanitizzazione del nome file, dall'isolamento dello storage all'integrazione con ClamAV per la scansione antivirus. Ogni livello di validazione blocca una classe specifica di attacco, e nessun livello da solo è sufficiente - servono tutti insieme.

Perché la validazione dell'estensione non è sufficiente e cosa la sostituisce?

L'estensione del file è un dato controllato dall'utente - l'attaccante può nominare il file come vuole. Verificare che l'estensione sia .pdf non garantisce che il file sia effettivamente un PDF: potrebbe essere uno script PHP, un file HTML con JavaScript malevolo, o un eseguibile con estensione rinominata. La validazione dell'estensione è al massimo un primo filtro che riduce il rumore - ma non è mai una misura di sicurezza affidabile.

La validazione corretta usa il MIME type reale del file - determinato dal contenuto del file (i magic bytes nell'header), non dal nome o dall'estensione. PHP fornisce due metodi per determinare il MIME type reale: finfo_file() (che usa la libreria libmagic per leggere i magic bytes) e mime_content_type() (che è un alias semplificato). In Symfony, il validator File include la validazione del MIME type con l'opzione mimeTypes:

// Validazione upload in Symfony con MIME type reale
use Symfony\Component\Validator\Constraints as Assert;

class DocumentUploadType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('file', FileType::class, [
            'constraints' => [
                new Assert\File([
                    // Validazione MIME type reale (non estensione)
                    'mimeTypes' => [
                        'application/pdf',
                        'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
                        'image/jpeg',
                        'image/png',
                    ],
                    'mimeTypesMessage' => 'Formato non consentito. Ammessi: PDF, DOCX, JPG, PNG.',
                    'maxSize' => '10M',
                ]),
            ],
        ]);
    }
}

La validazione mimeTypes di Symfony usa finfo_file() internamente - il che significa che verifica il contenuto del file, non il nome. Un file PHP rinominato in .pdf ha magic bytes diversi da un PDF reale (i PDF iniziano con %PDF-, i PHP iniziano con <?php o con un tag HTML) e viene rifiutato. Ma anche la validazione MIME non è infallibile: un attaccante esperto può creare un file polyglot - un file che è contemporaneamente un PDF valido (perché inizia con %PDF-) e uno script PHP valido (perché contiene <?php in un punto che il PDF parser ignora ma il PHP parser esegue). Per questo motivo, la validazione MIME è necessaria ma non sufficiente - serve anche l'isolamento dello storage.

Nel mio profilo professionale trovi il dettaglio dell'esperienza offensiva e difensiva che porto in questi assessment - la conoscenza dei bypass techniques (polyglot file, double extension, null byte injection) è il prerequisito per costruire una difesa che resiste ai tentativi di evasione.

Isolamento dello storage: mai salvare file nella directory pubblica

Il secondo livello di difesa - e quello più critico - è l'isolamento dello storage: i file uploadati non devono mai essere salvati in una directory accessibile direttamente dal web server. Se il file è in public/uploads/, qualsiasi utente (o attaccante) può accedervi via URL. Se il file PHP riesce a superare la validazione MIME (con un polyglot o con un bug nel validator), viene eseguito quando qualcuno ne visita l'URL.

La soluzione è salvare i file in una directory fuori dalla document root - ad esempio var/storage/uploads/ - e servirli attraverso un controller PHP che verifica l'autorizzazione dell'utente prima di restituire il file. In questo modo, anche se un attaccante riesce a caricare un file PHP, non può eseguirlo direttamente via URL perché Nginx non serve file dalla directory var/storage/.

Il controller di download in Symfony verifica che l'utente sia autorizzato a scaricare il file specifico (non solo autenticato - autorizzato per quel file specifico) e restituisce il file con gli header corretti:

// Controller di download sicuro con autorizzazione
#[Route('/documenti/{id}/download', name: 'documento_download')]
public function download(Documento $documento): BinaryFileResponse
{
    // Verifica autorizzazione: l'utente può accedere a questo documento?
    $this->denyAccessUnlessGranted('VIEW', $documento);

    $filePath = $this->getParameter('kernel.project_dir')
        . '/var/storage/uploads/' . $documento->getFilename();

    if (!file_exists($filePath)) {
        throw $this->createNotFoundException('File non trovato');
    }

    // Restituisci il file con nome originale e content-type corretto
    return $this->file($filePath, $documento->getOriginalName());
}

Il metodo $this->file() di Symfony restituisce una BinaryFileResponse con gli header corretti - incluso Content-Disposition: attachment che forza il download del file invece di mostrarlo nel browser. Questo è un dettaglio di sicurezza importante: se un utente carica un HTML con JavaScript malevolo e un altro utente lo "visualizza" nel browser, il JavaScript viene eseguito nel contesto del dominio del portale (XSS stored via upload). Con Content-Disposition: attachment, il file viene scaricato, non renderizzato - eliminando il rischio di XSS via upload.

Sanitizzazione del nome file e prevenzione del path traversal

Il terzo livello di difesa è la sanitizzazione del nome file. Un attaccante può caricare un file con nome ../../../etc/crontab - e se l'applicazione salva il file concatenando il nome originale al path di storage (var/storage/uploads/../../../etc/crontab), il file viene scritto in /etc/crontab sovrascrivendo il crontab di sistema. Questo attacco - noto come path traversal - è banale da prevenire ma sorprendentemente frequente nelle applicazioni che non sanitizzano il nome file.

La sanitizzazione che implemento ha tre passaggi: primo, genero un nome file random (UUID v4) che non ha nessuna relazione con il nome originale - eliminando qualsiasi possibilità di path traversal, collision con file esistenti e information leakage (il nome originale potrebbe rivelare informazioni sensibili sul contenuto). Secondo, salvo il nome originale nel database come metadato - per mostrarlo all'utente nel download e nelle liste. Terzo, salvo l'estensione originale come metadato per il content-type - ma uso il MIME type rilevato (non l'estensione dichiarata) per determinare il content-type della risposta.

Scansione antivirus con ClamAV: l'ultimo livello di difesa

Il quarto livello - opzionale per applicazioni a basso rischio ma obbligatorio per portali che gestiscono documenti di clienti esterni - è la scansione antivirus dei file caricati. ClamAV è un antivirus open source che supporta la scansione on-demand di file individuali via CLI (clamscan) o via daemon (clamdscan - più veloce perché il database delle firme è già caricato in memoria). L'integrazione con Symfony è un event listener che scansiona il file dopo l'upload e prima del salvataggio: se ClamAV rileva un threat, il file viene rifiutato e l'evento viene loggato per l'audit.

La scansione ClamAV aggiunge 50-200 ms al tempo di upload (a seconda della dimensione del file e della velocità del server), un overhead accettabile per un portale di gestione documentale dove la sicurezza è prioritaria rispetto alla velocità di upload. Per i clienti che gestiscono documenti legali o sanitari, la scansione antivirus è un requisito di compliance - sia per le normative di settore sia per i requisiti NIS2 che includono la protezione contro il malware nella lista delle misure tecniche obbligatorie - e ClamAV soddisfa questo requisito senza costi di licenza e con un'integrazione nel workflow di upload che richiede meno di un'ora di lavoro.

La configurazione Nginx: l'ultimo anello della catena

Anche con validazione MIME, storage isolato e nome sanitizzato, una misconfiguration di Nginx può vanificare tutte le difese. Se Nginx è configurato per passare i file .php a PHP-FPM con un pattern troppo permissivo - ad esempio location ~ \.php$ senza restrizione sulla directory - qualsiasi file PHP caricato in qualsiasi directory del server verrà eseguito. La configurazione sicura di Nginx per un'applicazione Symfony deve limitare l'esecuzione PHP esclusivamente al file index.php nella directory public/:

La regola nella configurazione Nginx è: location ~ ^/index\.php(/|$) con internal; che permette l'esecuzione PHP solo per il front controller Symfony, e nessun'altra location che passi file .php a FPM. Qualsiasi file PHP uploadato in qualsiasi directory non sarà mai eseguito perché Nginx non lo passerà a FPM - restituirà un errore 404 o servirà il file come download. Questa configurazione è il complemento infrastrutturale della validazione applicativa - e senza di essa, un file PHP che supera tutti i livelli di validazione applicativa verrebbe comunque eseguito se un attaccante riesce a indovinarne l'URL.

Un altro aspetto della configurazione Nginx che impatta la sicurezza degli upload è l'header X-Content-Type-Options: nosniff - un header che impedisce al browser di "indovinare" il MIME type del file basandosi sul contenuto (MIME sniffing). Senza questo header, un browser che riceve un file con content-type text/plain ma contenuto HTML con JavaScript potrebbe decidere di renderizzare l'HTML invece di mostrare il testo - un vettore di XSS stored che l'header nosniff blocca completamente. L'header va aggiunto nella configurazione Nginx per tutte le risposte del server, non solo per i file uploadati - ma la sua assenza è particolarmente pericolosa nel contesto degli upload dove l'attaccante controlla il contenuto del file.

Per i clienti che operano in settori regolamentati (legal tech, fintech, healthcare), aggiungo anche l'header Content-Security-Policy: default-src 'none' alle risposte di download dei file - un header che impedisce al browser di eseguire qualsiasi script, caricare qualsiasi risorsa esterna, o renderizzare qualsiasi contenuto attivo nel contesto del file scaricato. Questo header è l'ultima linea di difesa contro gli attacchi XSS via upload - e il suo costo è zero (una riga di configurazione Nginx) mentre la protezione è totale contro un'intera classe di attacchi.

L'upload di file è una delle superfici di attacco più sottovalutate nelle applicazioni web - e nelle applicazioni Symfony e Laravel che audito, è il punto dove trovo la percentuale più alta di vulnerabilità exploitabili. La catena di validazione che ho descritto - MIME type reale, storage isolato, nome file sanitizzato, scansione antivirus - blocca tutte le classi di attacco note via upload. Ho documentato le vulnerabilità di upload anche nel contesto dell'OWASP Top 10 per applicazioni PHP e nel mio articolo sul penetration testing di applicazioni Laravel. Se la tua applicazione Symfony o Laravel gestisce upload di file senza validazione del MIME type reale e con storage nella directory pubblica, contattami per un assessment di sicurezza mirato: in mezza giornata verifico la catena di upload, implemento i livelli di validazione mancanti, e testo la resilienza con payload di attacco reali - inclusi polyglot file, double extension, path traversal, e MIME spoofing - per verificare che ogni livello della catena di validazione funzioni correttamente sotto pressione offensiva. La sicurezza degli upload non è un problema che si risolve una volta - è un processo continuo che richiede test periodici e aggiornamento delle regole di validazione man mano che emergono nuove tecniche di bypass.

Ultima modifica: