Validazione input in Symfony 7: difendere le applicazioni business dall'injection
C'è un equivoco diffuso secondo cui la validazione dell'input serve a migliorare l'esperienza utente, a dire "questo campo è obbligatorio" o "l'email non è valida". È vero, ma è la metà meno importante. La validazione dell'input è, prima di tutto, la prima linea di difesa contro l'injection, ed è il punto in cui un'applicazione business decide se fidarsi o no di ciò che le arriva da fuori. In un'attività di penetration testing su un gestionale, il primo posto dove guardo non è il login, è dove l'applicazione accetta dati e cosa ci fa: perché un input non validato che finisce in una query, in una pagina o in un comando è la porta da cui passano SQL injection, cross-site scripting e una lunga lista di attacchi che hanno in comune una sola radice, la fiducia mal riposta in un dato esterno. Vediamo come si fa bene la validazione in Symfony 7, con i constraint del componente Validator e con un approccio a più livelli, e soprattutto vediamo il problema dal lato di chi quella validazione cerca di aggirarla, perché è l'unico modo per chiuderla davvero.
Validazione non è sanitizzazione, e dato valido non è dato sicuro
La distinzione da cui parte tutto, e che vedo confusa di continuo, è tra validazione e sanitizzazione, e tra "dato valido" e "dato sicuro". Validare significa verificare che un dato abbia la forma attesa: è un'email ben formata? è un numero nell'intervallo giusto? è una delle opzioni ammesse? Sanitizzare o, meglio, codificare per il contesto significa rendere un dato sicuro per l'uso specifico che ne farai: lo metto in una query, in una pagina HTML, in un'intestazione, in un comando di shell? Sono cose diverse, e confonderle è all'origine di molte vulnerabilità.
Il punto cruciale è che un dato può essere perfettamente valido e comunque pericoloso. La stringa Robert'); DROP TABLE students;-- è un nome valido sul piano della forma, lettere e punteggiatura; diventa un'arma solo nel contesto di una query SQL costruita male. Un commento contenente <script> è testo valido; diventa XSS solo quando lo renderizzi in una pagina senza escaping. La validazione riduce la superficie, scartando ciò che non ha la forma attesa, ma non rende un dato sicuro per ogni contesto: la sicurezza è contestuale, e si applica nel punto in cui il dato viene usato, non solo quando entra.
La regola che porto in ogni audit: valida all'ingresso per scartare ciò che non ha senso, ma codifica all'uscita per il contesto specifico. La validazione da sola non ferma l'injection, perché l'injection è un problema di come usi il dato, non solo di che forma ha.
I constraint del Validator di Symfony
Symfony ha uno dei sistemi di validazione più solidi dell'ecosistema PHP, il componente Validator, e il modo idiomatico di usarlo in Symfony 7 sono gli attributi #[Assert\...] direttamente sugli oggetti che rappresentano i dati in ingresso, tipicamente dei DTO o delle entità.
use Symfony\Component\Validator\Constraints as Assert;
class CreateInvoiceDto
{
#[Assert\NotBlank]
#[Assert\Length(max: 200)]
public string $customerName;
#[Assert\NotBlank]
#[Assert\Regex('/^[0-9]{11}$/', message: 'Partita IVA non valida')]
public string $vatNumber;
#[Assert\NotNull]
#[Assert\Positive]
public float $amount;
#[Assert\Choice(['draft', 'sent', 'paid'])]
public string $status;
}Questo approccio dichiarativo ha due virtù di sicurezza. La prima è che i vincoli sono espliciti e centralizzati sull'oggetto, non sparsi in if dentro i controller dove è facile dimenticarne uno. La seconda è la whitelist: con Assert\Choice accetti solo i valori previsti e rifiuti tutto il resto, che è infinitamente più sicuro di provare a elencare i valori cattivi. Il principio dell'allow-list contro la deny-list è uno dei pochi assoluti della sicurezza applicativa: definisci cosa è permesso e scarta il resto, perché l'elenco delle cose cattive non lo finisci mai. Symfony permette anche i gruppi di validazione, per applicare regole diverse in contesti diversi (creazione contro aggiornamento), e i constraint custom per le regole di dominio che le costanti predefinite non coprono.
Se gestisci un'applicazione business che tratta dati sensibili e vuoi una valutazione di come gestisce l'input dal punto di vista di un attaccante, nel mio profilo professionale trovi l'esperienza concreta di penetration testing e hardening applicativo su sistemi Symfony e PHP in produzione.
La validazione a più livelli: difesa in profondità
Un solo livello di validazione è un single point of failure: se quel livello ha un buco, l'attacco passa. La difesa seria è a più livelli, dove ciascuno cattura ciò che il precedente ha lasciato passare. In un'applicazione Symfony ben costruita i livelli sono quattro. Il primo è la validazione di forma e tipo al confine: il DTO con i suoi constraint, che rifiuta ciò che non ha la struttura attesa prima ancora che tocchi la logica. Il secondo è la validazione delle regole di business: un importo positivo è valido come forma, ma forse la regola dice che non può superare il plafond del cliente, e questo lo verifica il dominio. Il terzo è il layer di accesso ai dati con query parametrizzate, che neutralizza l'SQL injection a prescindere da cosa è passato sopra. Il quarto è l'escaping in output, che neutralizza l'XSS nel momento della renderizzazione.
La forza di questa stratificazione è che non si affida a un controllo perfetto, si affida alla ridondanza. Anche se un payload malevolo superasse la validazione di forma, troverebbe la query parametrizzata a fermarlo prima del database e l'escaping a fermarlo prima della pagina. È lo stesso principio di difesa in profondità che applico all'infrastruttura, portato a livello applicativo, e si lega alle classi di rischio della OWASP Top 10 e alle difese per le applicazioni PHP, dove injection e XSS restano voci centrali anno dopo anno.
Dal lato attaccante: come si prova ad aggirare la validazione
Qui sta il valore di un occhio offensivo, e dove la maggior parte delle guide si ferma. Per chiudere davvero la validazione bisogna sapere cosa prova chi vuole aggirarla. Il primo trucco è l'encoding: un payload bloccato in chiaro può passare codificato (URL-encoding, doppia codifica, entità HTML, sequenze unicode), per poi essere decodificato a valle dove il filtro non guarda più. La difesa è validare sulla forma canonica del dato, dopo la decodifica, e non fidarsi di un filtro che opera sulla stringa grezza.
Il secondo è il type juggling, storica debolezza di PHP: i confronti larghi tra tipi diversi possono produrre uguaglianze inattese, e un attaccante le sfrutta per bypassare controlli scritti con ==. In Symfony 7, con i tipi nativi sui DTO e i confronti stretti, questa superficie si riduce molto, ma va chiusa con disciplina, usando === e tipizzando le proprietà. Il terzo, il più sottile, è l'injection di seconda mano (second-order): un payload che è innocuo quando entra, supera la validazione perché ha forma valida, viene salvato, e diventa pericoloso più tardi, quando un'altra parte dell'applicazione lo rilegge e lo usa in un contesto sensibile senza riapplicare le difese. È insidioso perché il punto di ingresso e il punto di detonazione sono separati nel tempo e nel codice. La difesa è non assumere mai che un dato "già nel database" sia fidato: le query parametrizzate e l'escaping in output vanno applicati anche ai dati che arrivano dal tuo stesso storage, non solo a quelli che arrivano dalla richiesta.
SQL injection: la validazione aiuta, ma i prepared statement risolvono
Va detto senza ambiguità, perché è il punto dove si fanno i danni peggiori: contro l'SQL injection la validazione non è la difesa primaria, lo sono le query parametrizzate. La validazione riduce il rumore, ma la garanzia la dà il fatto che il dato dell'utente non venga mai concatenato nella stringa della query. Con Doctrine, lo strumento standard di Symfony, questo significa usare sempre i parametri:
// MAI: concatenazione diretta, vulnerabile a SQL injection
$dql = "SELECT i FROM Invoice i WHERE i.customer = '" . $input . "'";
// SEMPRE: parametro associato, il valore non diventa mai codice
$query = $em->createQuery('SELECT i FROM Invoice i WHERE i.customer = :customer')
->setParameter('customer', $input);La differenza è strutturale: con il parametro, il database tratta il valore come dato e mai come parte della struttura del comando, e l'injection diventa impossibile per costruzione, non per filtro. È lo stesso confine tra codice e dati che manca negli LLM e che invece nei database esiste e va usato. Doctrine rende questo il percorso naturale, ed è una delle ragioni per cui scrivere query a mano per concatenazione su un progetto Symfony non ha alcuna giustificazione, come ho approfondito parlando di Doctrine ORM avanzato, query builder e DQL.
XSS: è il contesto di output a decidere la difesa
Per l'XSS vale il principio speculare: la difesa non sta all'ingresso, sta all'uscita, e dipende dal contesto in cui il dato viene renderizzato. Twig, il motore di template di Symfony, applica l'auto-escaping di default sull'output HTML, ed è la ragione per cui la maggior parte delle applicazioni Symfony non ha XSS banali: il dato dell'utente viene automaticamente codificato per il contesto HTML quando lo stampi. Il footgun, l'arma che si punta ai piedi da soli, è il filtro |raw, che disabilita l'escaping: usarlo su un dato che proviene, anche indirettamente, dall'utente, riapre esattamente la porta che Twig aveva chiuso. La regola è non usare mai |raw su dati non fidati, e diffidare di ogni punto in cui l'output bypassa l'escaping automatico. Il controllo degli accessi che decide chi può inserire cosa è l'altra metà del lavoro, ed è il terreno del componente Security di Symfony con i voter.
Il mass assignment: l'attacco che passa dai campi che non aspettavi
C'è una classe di vulnerabilità che non è injection in senso stretto ma vive nello stesso territorio della fiducia mal riposta nell'input: il mass assignment. Succede quando l'applicazione prende i dati della richiesta e li assegna in blocco a un oggetto, e l'attaccante aggiunge un campo che non era previsto, per esempio un role o un is_admin, che finisce assegnato perché nessuno ha definito quali campi sono accettabili. È il modo in cui un utente normale si autopromuove ad amministratore senza toccare un solo controllo di autenticazione.
La difesa in Symfony è strutturale e coincide con la buona pratica del DTO: invece di legare la richiesta direttamente a un'entità, la si lega a un oggetto che espone solo i campi che l'utente ha il diritto di impostare. Quel DTO è di fatto una allow-list dei campi bindabili: un role mandato dall'attaccante non trova una proprietà corrispondente e viene semplicemente ignorato. Mappare poi i campi validati dell'oggetto verso l'entità è un passaggio esplicito, sotto il controllo dello sviluppatore, dove si decide cosa può cambiare e cosa no. È la stessa logica di whitelist della validazione, applicata al binding: non lasciare che sia la richiesta a decidere quali campi del tuo dominio toccare.
La validazione lato server basta da sola?
No, e chi lo crede ha capito metà del problema. La validazione lato server è necessaria ma non sufficiente: è il primo livello, non l'unico. La validazione lato client non conta nulla per la sicurezza, perché un attaccante non usa il tuo form, manda la richiesta come vuole; quindi tutto si gioca sul server. Ma anche la validazione server, da sola, non ferma l'injection, perché un dato di forma valida può comunque essere pericoloso nel contesto sbagliato. La difesa completa è la somma dei livelli: validazione di forma e business all'ingresso, query parametrizzate al database, escaping contestuale in output, e il principio del minimo privilegio sotto a tutto, perché anche se un attacco passasse, un'utenza di database con permessi ridotti limita il danno. È l'insieme a proteggere, non il singolo controllo.
Tirando le somme, la validazione dell'input in Symfony 7 è una difesa potente proprio perché il framework la rende dichiarativa, centralizzata e basata su allow-list, ma va capita per quello che è: il primo anello di una catena, non l'intera catena. Si validano forma e regole con i constraint del Validator, si stratifica la difesa perché nessun livello sia un punto di rottura unico, e si pensa come l'attaccante per chiudere i vettori che la validazione ingenua lascia aperti, dall'encoding al type juggling all'injection di seconda mano. Sotto a tutto resta il principio non negoziabile: contro l'SQL injection comandano i prepared statement, contro l'XSS comanda l'escaping in output, e la validazione è ciò che riduce il rumore prima che quelle difese facciano il loro lavoro. Se gestisci un'applicazione business in Symfony che tratta dati di valore e vuoi sapere quanto regge davvero a chi prova ad aggirarne i controlli, contattami per una consulenza diretta: nella mia esperienza, la differenza tra un'applicazione che sembra sicura e una che lo è sta quasi sempre nei livelli che non si vedono dall'esterno.