Sicurezza e Validazione in PHP: prevenire gli attacchi
In una subentranza su un progetto legacy PHP 5.x per un'azienda del settore servizi digitali, la prima cosa che ho trovato aprendo il repository è stata un file chiamato security.class.php. Conteneva una classe statica che, all'inizializzazione, ciclava su $_GET, $_POST e $_COOKIE e applicava a ogni valore una funzione xss_clean() lunga settanta righe, fatta di espressioni regolari che rimuovevano javascript:, gli attributi on*, i tag di script e qualche decina di altri pattern. Sopra, due righe per disattivare le magic_quotes e neutralizzare register_globals. Il commento in testa diceva che derivava da una libreria di sicurezza di un framework dell'epoca.
Quella classe, nel 2009, era considerata una buona pratica. Oggi è il primo file che rimuovo, e non perché sia scritto male: perché incarna un modello di sicurezza sbagliato in modo strutturale. Il server girava su un VPS con PHP 8.2, e metà del codice di quella classe riferiva funzionalità che il linguaggio ha eliminato da oltre dieci anni. Le magic_quotes_gpc sono state rimosse in PHP 5.4, rilasciato nel 2012; register_globals è sparita nella stessa versione; l'estensione mysql_* su cui poggiavano le query è stata cancellata in PHP 7.0 nel 2015. Quel codice "di sicurezza" non solo non proteggeva più nulla: dava al cliente la falsa certezza di essere protetto, che è peggio dell'assenza di protezione.
Voglio usare proprio quello scenario come pretesto per mettere in fila come si validano i dati e si previene il cross-site scripting in PHP nel 2026, perché la differenza fra l'approccio del 2009 e quello corretto non è una questione di funzioni nuove: è un cambio di mentalità su dove e quando si interviene.
TL;DR
- Doctrine: valida l'input al confine (con logica di allowlist) ed escapa l'output in base al contesto in cui finisce. Sono due operazioni distinte, in due momenti distinti.
- Anti-pattern: la "classe di sicurezza" che ripulisce
$_GET/$_POSTall'ingresso (laxss_clean()blacklist) non protegge: dà falsa sicurezza.- Input:
filter_var()o una libreria come Respect/Validation; rifiuta ciò che non è conforme, non tentare di "aggiustarlo".- Output:
htmlspecialchars()(default sicuro da PHP 8.1) o un motore di template con escaping automatico; mai una pulizia unica all'ingresso.- SQL: prepared statement, mai concatenazione. CSP come rete di sicurezza in profondità.
Validare l'input o sanificare l'output: qual è la differenza che conta?
Sono due operazioni distinte, in due momenti distinti, e confonderle è l'errore di fondo di ogni "classe magica" come quella che ho descritto. Validare significa decidere, nel punto in cui un dato entra nell'applicazione, se rispetta le regole che ti aspetti: un id è un intero positivo, una email ha un formato valido, un codice provincia è una di centodieci sigle ammesse. Se non le rispetta, lo rifiuti, non lo "aggiusti". Escapare (o codificare) significa trasformare un dato nel momento in cui esce dall'applicazione verso un contesto specifico (HTML, attributo, JavaScript, SQL, shell), perché in quel contesto non possa essere interpretato come codice.
La classe del 2009 sbagliava perché tentava una terza cosa che non esiste come buona pratica: sanitizzare l'input in modo generico, cioè modificare i dati in ingresso sperando che diventino "sicuri" per qualunque uso futuro. Non funziona, perché un dato sicuro per l'HTML è insicuro per un attributo, un dato sicuro per un attributo è insicuro per il JavaScript, e un dato passato a una query SQL ha bisogno di tutt'altro. Pulire all'ingresso significa scegliere un contesto a indovinare, sbagliarlo per tutti gli altri, e nel frattempo corrompere i dati legittimi: il nome O'Brien o un testo tecnico che usa i simboli di minore e maggiore in una formula vengono mutilati prima ancora di essere salvati. La regola che applico è quella che la OWASP ripete da anni nella sua Cross Site Scripting Prevention Cheat Sheet: valida l'input, escapa l'output, e fallo in base al contesto di destinazione, mai una volta sola per tutti.
La validazione al confine: allowlist, non blacklist
La validazione corretta è una decisione binaria presa il prima possibile: il dato è conforme a ciò che mi aspetto, oppure no. E si esprime sempre come allowlist (definisco la forma valida e accetto solo quella), mai come blacklist (elenco le cose cattive e blocco quelle). Una blacklist è una battaglia persa in partenza, perché l'attaccante ha infinite varianti e tu hai una lista finita.
PHP ha avuto per anni gli strumenti giusti senza che servisse copiare classi da internet. La famiglia filter_var() copre i casi più comuni con validatori nativi:
<?php
declare(strict_types=1);
// Un id che arriva dalla query string: deve essere un intero positivo.
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
if ($id === false || $id === null) {
http_response_code(400);
exit('Parametro id non valido');
}
// Una email: il formato segue la RFC, non una regex fatta a mano.
$email = filter_var($rawEmail, FILTER_VALIDATE_EMAIL);
if ($email === false) {
// input rifiutato, non "corretto"
}La differenza con la Validator class del 2009, che pure aveva le sue costanti vDIGIT, vEMAIL, vURL, è che filter_input() legge il dato direttamente dalla superglobale senza che nessuno l'abbia "pre-pulito" globalmente, restituisce false su input non valido e null su parametro assente, e usa implementazioni mantenute dal core di PHP invece di espressioni regolari artigianali. La regex per le email scritta a mano in quella classe, per dire, rifiutava i domini con TLD più lunghi di sette caratteri: nel 2009 non era un problema, oggi rifiuta un indirizzo @something.technology.
Per la validazione strutturata, quando i dati in ingresso sono molti e con regole di dominio, il salto di qualità è una libreria dedicata. Le due che uso a seconda del contesto sono Respect/Validation per il PHP "vanilla" e il componente Validator di Symfony quando il progetto è già su quell'ecosistema. Permettono di dichiarare le regole una volta sola e applicarle in modo testabile:
<?php
use Respect\Validation\Validator as v;
$utenteValido = v::key('email', v::email())
->key('eta', v::intVal()->positive()->between(18, 120))
->key('provincia', v::in(['TO', 'MI', 'RM', /* ... allowlist completa */]));
if (!$utenteValido->validate($input)) {
// raccogli gli errori e rispondi 422, non salvare niente
}Il punto che ripeto sempre a chi eredita una codebase è che la validazione va messa al confine: il controller, l'endpoint API, il comando CLI. Non sparsa in mezzo alla logica di business, non delegata a una classe globale che agisce di nascosto su tutte le superglobali. Se vuoi capire come si imposta questo lavoro su un applicativo che non l'ha mai avuto, ne ho scritto in dettaglio nella mia guida pratica all'audit di sicurezza su PHP legacy, dove la mappatura dei punti di ingresso è il primo passo concreto.
Se ti riconosci nella situazione del cliente con cui ho aperto, con una codebase PHP datata e una "classe di sicurezza" ereditata di cui nessuno conosce davvero l'effetto, nel mio profilo professionale trovi l'esperienza concreta sulla modernizzazione di applicativi PHP legacy e sull'hardening di codice che porta ancora il peso di vent'anni di evoluzione organica.
La trappola del type juggling: validare in PHP non è come validare altrove
C'è una ragione specifica per cui la validazione in PHP richiede più attenzione che in altri linguaggi, ed è una caratteristica del linguaggio stesso che molti sviluppatori dimenticano: il type juggling, cioè la conversione automatica di tipo che PHP applica nei confronti non stretti. Una validazione scritta con il confronto largo == invece di quello stretto === può essere aggirata in modi tutt'altro che intuitivi, e questo trasforma un controllo che sembra corretto in un buco di sicurezza. Il caso storico più noto riguardava il confronto di una stringa numerica con uno zero: in versioni precedenti del linguaggio espressioni come 0 == "qualsiasi cosa" potevano risultare vere, perché la stringa veniva convertita a intero. PHP 8 ha corretto questo comportamento specifico, ma il principio resta: ogni volta che validi un valore confrontandolo con un riferimento, devi usare il confronto stretto e controllare esplicitamente il tipo, altrimenti stai validando una conversione, non il dato reale.
Il punto dove questo diventa critico è la verifica di token, hash e segreti. Confrontare due hash con == espone a un attacco in cui un hash che inizia con una sequenza interpretabile come notazione scientifica (0e seguito da sole cifre) viene convertito a zero da entrambi i lati del confronto, facendo collidere hash diversi. La difesa è duplice: usare sempre === per i confronti che contano, e per i segreti veri usare le funzioni dedicate del core come hash_equals(), che confronta in tempo costante ed è immune sia al type juggling sia agli attacchi temporali. Questa è esattamente la categoria di errore che una "classe di validazione" generica del 2009 non poteva nemmeno concepire, perché ragionava in termini di "è una stringa pulita?" e non di "questo confronto è semanticamente corretto in PHP?". La validazione moderna è anche disciplina sui tipi: declare(strict_types=1) in testa a ogni file, type hint sui parametri, e confronti stretti ovunque una decisione di sicurezza dipenda da un'uguaglianza.
C'è poi un secondo fronte che nel 2009 non esisteva ma oggi è centrale: la validazione al confine del modello, cioè il problema del mass assignment. Quando un framework popola automaticamente un oggetto dai dati della richiesta, un attaccante può iniettare campi che non erano previsti nel form (per esempio is_admin o role) e ottenere privilegi non autorizzati. La validazione qui non è sui caratteri del valore, ma sulla forma dell'insieme: si dichiara esplicitamente quali campi sono accettati (di nuovo una allowlist), e tutto il resto viene scartato prima di toccare il modello. È il motivo per cui Laravel ha i $fillable e i Form Request, e Symfony i form con campi dichiarati: non sono comodità, sono confini di sicurezza.
Prevenire l'XSS: l'escaping dell'output dipende dal contesto
Il cross-site scripting non si previene all'ingresso, si previene all'uscita, e nel punto esatto in cui un dato viene scritto in una pagina. La funzione che fa il lavoro per il contesto HTML è htmlspecialchars(), e qui c'è un dettaglio importante che è cambiato di recente. Da PHP 8.1 il valore di default del parametro $flags è passato da ENT_COMPAT a ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, come documenta la pagina ufficiale di htmlspecialchars. La conseguenza pratica è duplice: ora di default vengono codificate anche le virgolette singole (prima no, ed era un buco quando si scriveva un valore dentro un attributo delimitato da apici singoli) e i caratteri non validi vengono sostituiti invece di restituire una stringa vuota.
<?php
// Contesto: testo dentro un elemento HTML.
echo '<p>Ciao ' . htmlspecialchars($nome) . '</p>';
// Su PHP 8.1+ il default include gia ENT_QUOTES e ENT_SUBSTITUTE.
// Su codebase che girano ancora su 8.0 o precedenti, va reso esplicito:
echo htmlspecialchars($nome, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');La parola chiave è contesto. htmlspecialchars() è corretta quando il dato finisce come testo o dentro un attributo quotato, ma non è la funzione giusta se il dato finisce dentro un blocco di script, dentro un URL, o in un attributo style. Inserire un valore controllato dall'utente in un contesto JavaScript con il solo htmlspecialchars() non protegge: lì serve una codifica diversa (tipicamente json_encode() con i flag JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT, oppure semplicemente non passare mai dati dinamici al codice inline). Questa è esattamente la ragione per cui la xss_clean() blacklist del 2009 era fragile: applicava una pulizia unica all'ingresso, ignorando che lo stesso valore poteva finire in cinque contesti diversi, ciascuno con regole di escaping incompatibili.
Il modo più affidabile per non sbagliare contesto è non scrivere l'escaping a mano. I motori di template moderni lo fanno per te e in modo context-aware: Blade di Laravel con la sintassi {{ $variabile }} applica l'escaping automatico, e Twig di Symfony ha l'auto-escaping attivo di default. La regola operativa che do ai team è semplice: se stai chiamando htmlspecialchars() a mano nelle view, probabilmente stai lavorando senza un motore di template, e il primo intervento di hardening è proprio introdurlo, perché elimina un'intera classe di errori per dimenticanza.
La Content Security Policy come seconda linea
L'escaping corretto previene l'XSS; la Content Security Policy lo contiene anche quando l'escaping fallisce. È una difesa in profondità, non un'alternativa: un header HTTP che dice al browser quali sorgenti di script sono lecite, così che uno script iniettato non venga eseguito anche se è riuscito a entrare nella pagina.
<?php
header("Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'");Una CSP ben fatta, basata su nonce o hash per gli script inline invece che su unsafe-inline, trasforma una vulnerabilità XSS sfruttabile in un tentativo bloccato dal browser. Non sostituisce la validazione e l'escaping, ma è la rete di sicurezza che separa un bug in una view da un incidente reale. Imposto sempre la CSP insieme agli altri header di sicurezza nella configurazione del web server o in un middleware applicativo, e verifico il risultato con strumenti come l'analizzatore di header che metto a disposizione fra i miei tool online; ne ho parlato più in generale nei principi fondamentali di architettura cybersecurity per le PMI.
E la query SQL dell'esempio originale?
Lo script da cui partiva l'articolo del 2009 aveva un secondo problema, non meno grave dell'XSS: concatenava $_GET['id'] direttamente dentro una stringa SQL. Quella è una SQL injection da manuale, e nessuna xss_clean() la avrebbe fermata, perché ripuliva i tag HTML, non i metacaratteri SQL. La soluzione non è "validare meglio l'input prima di concatenarlo": è non concatenare mai. I prepared statement separano la struttura della query dai dati, e rendono l'injection strutturalmente impossibile a prescindere da cosa contiene il valore.
<?php
// Niente concatenazione: la struttura e i dati viaggiano separati.
$stmt = $pdo->prepare('SELECT prova FROM tabella WHERE id = :id');
$stmt->execute(['id' => $id]); // $id gia validato come intero positivo
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row !== false) {
header('Content-Type: application/xml; charset=UTF-8');
echo '<?xml version="1.0" encoding="UTF-8"?>';
echo '<root><dati>' . htmlspecialchars($row['prova'], ENT_QUOTES | ENT_XML1, 'UTF-8') . '</dati></root>';
}Nota che anche nell'output XML l'escaping è context-aware: il flag ENT_XML1 produce una codifica corretta per quel contesto, diversa da quella HTML. La validazione dell'id come intero positivo resta utile (rifiuta presto le richieste palesemente malformate), ma non è ciò che ti protegge dalla injection: a proteggerti è il prepared statement. È una distinzione che faccio sempre, perché molti pensano che "validare l'input" copra anche la SQL injection, e non è così. Il tema della separazione fra dati e struttura, applicato a un caso di hardening reale, lo approfondisco nell'articolo sull'hardening urgente di MySQL su Laravel e VPS.
Validare i file caricati: la superficie che la classe del 2009 ignorava
C'è un tipo di input che la "classe di sicurezza" originale non gestiva affatto, e che oggi è una delle superfici di attacco più sfruttate: i file caricati dagli utenti. Un upload non validato è un vettore diretto per l'esecuzione di codice remoto, perché un attaccante che riesce a caricare un file .php in una directory servita dal web server ottiene di fatto una shell sul server. La validazione di un upload non si fa guardando l'estensione del nome del file, che è banalmente falsificabile, né fidandosi del Content-Type dichiarato dal client, che è sotto il controllo dell'attaccante. Si fa verificando il tipo MIME reale del contenuto con finfo_file(), controllando che corrisponda a una allowlist di tipi attesi, rigenerando un nome file casuale lato server invece di conservare quello fornito dall'utente, e soprattutto archiviando i file caricati fuori dalla document root, in una directory da cui il web server non può eseguire codice.
Il principio è lo stesso del resto dell'articolo, applicato a un input diverso: non si "ripulisce" il file sperando che diventi innocuo, si decide in anticipo cosa è ammesso e si rifiuta tutto il resto. Un upload di immagini accetta solo image/jpeg, image/png, image/webp, verifica le dimensioni, ricodifica l'immagine con la libreria grafica per scartare eventuali payload nascosti nei metadati, e la salva con un nome generato dal server in un percorso non eseguibile. Se l'applicazione serve poi quei file, lo fa con header Content-Type corretti e Content-Disposition appropriati, mai lasciando che sia il web server a indovinare come trattarli. Nei progetti legacy che ricevo, l'upload non validato è quasi sempre presente, perché era una funzionalità "che funzionava" e nessuno l'aveva messa in discussione: è uno dei primi punti che verifico in un audit, perché il rapporto fra facilità di sfruttamento e gravità è fra i più alti di tutta la superficie applicativa.
Come si smonta una "classe di sicurezza" ereditata senza rompere tutto
Tornando al file security.class.php da cui sono partito: non si cancella e basta, perché l'applicazione potrebbe dipendere dai suoi effetti collaterali, per quanto sbagliati. Il percorso che seguo è incrementale e misurabile. Prima mappo tutti i punti in cui la classe viene istanziata e tutti i punti in cui il codice legge da $_GET, $_POST, $_REQUEST, perché quella classe li ha già modificati e disattivarla cambierà i valori che il codice riceve. Poi introduco la validazione esplicita al confine, endpoint per endpoint, sostituendo l'affidamento implicito alla pulizia globale con controlli dichiarati e visibili. Sposto l'escaping nelle view, idealmente adottando un motore di template. Converto le query a prepared statement. E solo quando ogni punto di ingresso ha la sua validazione esplicita e ogni punto di uscita il suo escaping corretto, rimuovo la classe globale, che a quel punto non fa più niente di necessario.
Questo lavoro non è glamour e non si vede da fuori, ma è esattamente il tipo di intervento che separa una codebase che "sembra sicura perché ha una classe chiamata Security" da una che lo è davvero perché valida dove deve e escapa dove serve. La modernizzazione dello stack va di pari passo: una codebase ancora su PHP 5.x o 7.x non ha accesso ai default sicuri di PHP 8.1 né alle funzioni di filtro più recenti, e portarla almeno su una versione supportata, oggi la 8.4, è parte integrante della messa in sicurezza, come spiego nell'articolo sull'aggiornamento di applicazioni PHP legacy verso le versioni moderne di Laravel e Symfony.
La sicurezza in PHP, nel 2026, non è una libreria da includere all'inizio di ogni script: è una disciplina che vive in due punti precisi del flusso dei dati. Si valida l'input quando entra, con regole di allowlist espresse come decisioni binarie, e si rifiuta ciò che non rispetta la forma attesa invece di tentare di aggiustarlo. Si escapa l'output quando esce, in base al contesto esatto in cui finisce, lasciando idealmente il lavoro a un motore di template che non dimentica mai. Si separano dati e struttura nelle query con i prepared statement, e si tiene la Content Security Policy come rete quando tutto il resto fallisce. Le classi "magiche" che promettono di fare tutto all'ingresso appartengono a un'epoca in cui il linguaggio era diverso e le minacce erano meno raffinate, e oggi sono più un rischio che una protezione. Se gestisci un applicativo PHP che porta sulle spalle anni di evoluzione e sospetti che la sua "sicurezza" sia fatta di codice copiato e mai più rivisto, contattami per un confronto diretto: di solito basta una sessione di audit per capire se quella classe in testa al progetto ti sta proteggendo o ti sta solo dando l'illusione di esserlo.