Checklist essenziale per l'hardening di applicazioni Laravel e Symfony

Checklist essenziale per l'hardening di applicazioni Laravel e Symfony

La maggior parte delle applicazioni Laravel e Symfony che mi capita di analizzare condivide lo stesso difetto: funziona benissimo, ma non è mai stata hardenizzata. La configurazione è quella di default, le dipendenze non vengono auditate, gli header di sicurezza HTTP sono assenti, la Content Security Policy non esiste e le sessioni viaggiano con cookie senza i flag minimi. Non è negligenza: durante lo sviluppo la priorità era arrivare al go-live, e l'hardening è rimasto nella colonna "da fare dopo". Il problema è che "dopo" non arriva mai, e nel frattempo la superficie d'attacco cresce a ogni feature che aggiungi. Un'applicazione perfettamente scritta ma mal configurata è esattamente il profilo che oggi l'OWASP considera il secondo rischio applicativo più diffuso.

Questa checklist nasce da decine di interventi su applicazioni PHP per PMI italiane. Non è teoria: è la lista esatta che percorro quando faccio un security assessment, organizzata per livello logico, dai security header verso l'interno fino al database, con la configurazione concreta per ogni punto. L'angolo qui è volutamente ampio e generale: rendere l'applicazione strutturalmente resistente, indipendentemente da un quadro normativo specifico. Se invece ti serve l'allineamento puntuale alla Direttiva NIS2, con piano e scadenze, ho dedicato a quello una checklist NIS2-ready in 14 giorni: trattala come il complemento normativo di questa guida, non come un duplicato.

Perché l'hardening conta più del codice perfetto?

Perché la maggior parte delle compromissioni reali non nasce da un bug raffinato, ma da una configurazione mancante. Nell'edizione 2025 della classifica di riferimento del settore, l'OWASP Top 10:2025, la Security Misconfiguration è salita dal quinto al secondo posto (A02:2025), seconda solo al Broken Access Control. Tradotto: header assenti, debug attivo in produzione, credenziali di default, interfacce di amministrazione esposte. Tutte cose che non si vedono leggendo il codice applicativo, ma che un attaccante trova in pochi minuti con uno scanner automatico.

La Security Misconfiguration non è un bug nel codice: è ciò che manca attorno al codice. È invisibile in code review e ovvia in un penetration test. Questa è la ragione per cui l'hardening è un'attività a sé, non un sottoprodotto dello sviluppo.

C'è poi una seconda novità della classifica 2025 che pesa su ogni progetto PHP moderno: l'ingresso, direttamente al terzo posto, della categoria Software Supply Chain Failures (A03:2025). È la conferma che il rischio non è più solo nel tuo codice, ma in tutto ciò che composer trascina dentro al progetto. Torno su questo punto più avanti, ma tienilo presente: è il motivo per cui in questa checklist l'audit delle dipendenze ha lo stesso peso della configurazione del web server.

Se stai cercando un consulente che faccia esattamente questo lavoro sulla tua infrastruttura, prima di automatizzarne pezzi, nel mio profilo professionale trovi l'esperienza concreta su hardening di applicazioni Laravel e Symfony, audit di sicurezza e recupero di applicativi in produzione.

I security header e la Content Security Policy

I security header HTTP sono il punto da cui parto sempre, perché hanno il miglior rapporto tra impatto e sforzo: poche righe di configurazione e l'applicazione smette di rispondere a un'intera famiglia di attacchi lato browser. Un'applicazione Laravel o Symfony appena installata non li imposta da sola: vanno aggiunti deliberatamente, e questo è il primo blocco che applico su ogni progetto:

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
server_tokens off;

Ogni header ha un compito preciso: Strict-Transport-Security (HSTS) forza HTTPS e previene gli attacchi di downgrade, X-Content-Type-Options blocca il MIME sniffing, X-Frame-Options previene il clickjacking, Referrer-Policy limita le informazioni che il browser propaga ai domini terzi. Il server_tokens off nasconde la versione di Nginx: non è sicurezza reale, ma riduce il rumore nelle scansioni automatizzate. Su Apache gli stessi header vanno in un blocco <IfModule mod_headers.c> del VirtualHost, con la direttiva Header always set.

Manca volutamente da quel blocco l'header più importante e più delicato: la Content Security Policy. È la difesa più efficace contro il Cross-Site Scripting (XSS), perché istruisce il browser a eseguire solo il codice proveniente da sorgenti che hai dichiarato. Il problema è che impostarla come header statico nel web server porta quasi sempre allo stesso errore: per far funzionare gli script inline si finisce per aggiungere 'unsafe-inline', che è esattamente la direttiva che svuota la CSP del suo significato. Se qualsiasi script inline può girare, allora può girare anche quello che un attaccante è riuscito a iniettare.

L'errore più comune sulla CSP non è dimenticarla: è renderla inutile con 'unsafe-inline' pur di non rompere gli script. Una CSP con 'unsafe-inline' su script-src è teatro della sicurezza, non sicurezza.

L'approccio corretto, soprattutto in un'applicazione Laravel con Livewire, Alpine.js o asset compilati con Vite, è la CSP basata su nonce: un token casuale generato a ogni richiesta dal server e applicato solo agli script che ti fidi. In Laravel questo si gestisce dentro l'applicazione, non nel web server, ed è il caso in cui consiglio una libreria mantenuta invece del fai-da-te. Il pacchetto spatie/laravel-csp genera i nonce automaticamente e li espone tramite l'helper csp_nonce():

// bootstrap/app.php - registra il middleware CSP sul gruppo web
->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        \Spatie\Csp\AddCspHeaders::class,
    ]);
})

// nel template Blade: ogni script di fiducia porta il nonce di richiesta
// <script nonce="{{ csp_nonce() }}"> ... </script>

La regola operativa è una sola: partire stretti e allargare solo il necessario. Si comincia con default-src 'self', si attiva la policy in modalità Content-Security-Policy-Report-Only per raccogliere le violazioni senza rompere nulla, e solo dopo aver verificato i report si passa all'enforcement. È un lavoro di un paio d'ore che chiude la porta a una delle classi di vulnerabilità più sfruttate del web.

Autenticazione, autorizzazione e rate limiting

L'autenticazione fallita è il settimo rischio della classifica 2025 (A07:2025), e nelle applicazioni che analizzo il problema raramente è la libreria di auth in sé: è ciò che manca attorno. Tre cose vanno verificate sempre, in entrambi i framework.

La prima è il rate limiting sugli endpoint sensibili. Un form di login senza throttling è una macchina per il credential stuffing. In Laravel 13 lo definisco con un limiter dedicato, distinto tra login e API:

// AppServiceProvider::boot() - limiter per login e API
RateLimiter::for('login', function (Request $request) {
    return Limit::perMinute(5)->by($request->input('email').'|'.$request->ip());
});

RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});

In Symfony lo stesso risultato si ottiene in modo dichiarativo, ed è uno dei vantaggi del framework: il comportamento di sicurezza è visibile e auditabile nel security.yaml, non sparso nel codice. La documentazione ufficiale di Symfony sulla sicurezza descrive il login_throttling integrato nel firewall:

# config/packages/security.yaml
security:
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    firewalls:
        main:
            lazy: true
            login_throttling:
                max_attempts: 5
                interval: '15 minutes'
            remember_me:
                secret: '%kernel.secret%'
                secure: true
                httponly: true
                samesite: lax
    access_control:
        - { path: ^/admin, roles: ROLE_ADMIN }

La seconda cosa è l'autorizzazione per risorsa, non per ruolo. "L'utente è autenticato" non è autorizzazione, e nemmeno "l'utente ha il ruolo ADMIN". La domanda corretta è: questo utente può modificare questa risorsa specifica? In Laravel si esprime con Policy e Gate, in Symfony con i Voter. Saltare questo livello è la strada più diretta verso un IDOR (Insecure Direct Object Reference), il sottotipo di Broken Access Control che resta al primo posto dell'OWASP Top 10 da anni.

La terza, se esponi un'API con token, è non improvvisare la firma. Per i JWT le insidie classiche, dall'algorithm confusion alla mancata validazione di scadenza e audience, fanno danni proprio dove sembra tutto a posto: ho raccolto i dettagli e i fix in un articolo dedicato alla sicurezza dei JWT in PHP. Per Laravel, se ti basta l'autenticazione a token interna usa Sanctum; per Symfony, il riferimento per i JWT è il LexikJWTAuthenticationBundle, sempre con throttling e autorizzazione per risorsa davanti.

Input e output: validare tutto, non fidarsi di niente

Le Injection restano in classifica (A05:2025) e il principio difensivo non è cambiato: ogni dato che arriva dall'esterno è ostile finché non hai dimostrato il contrario. Sia Laravel sia Symfony partono avvantaggiati, perché Eloquent e Doctrine usano prepared statement per default e quindi la SQL injection classica è chiusa già in partenza, a patto di non costruire query con concatenazione di stringhe. Il punto debole non è l'ORM: è il DB::raw() con interpolazione, il whereRaw() con variabili non bindate, la query nativa scritta a mano per "fare in fretta". Quelli vanno cercati e parametrizzati.

Sull'output vale la regola speculare. Blade fa auto-escaping con {{ }}, Twig lo fa con {{ }}, e questo neutralizza la maggior parte dell'XSS riflesso. I due punti dove la difesa si rompe sono identici nei due framework: {!! $html !!} in Blade e il filtro |raw in Twig. Su input utente, mai. Se devi davvero stampare HTML proveniente dall'utente, prima passa per un sanitizer come HTML Purifier; non c'è scorciatoia accettabile.

> La regola che ripeto in ogni audit: valida in ingresso con regole esplicite, fai escaping in uscita, e tratta {!! !!} e |raw come funzioni pericolose da giustificare riga per riga, non come comodità.

La validazione, infine, non è opzionale e non si fa "a mano" con catene di if. In Laravel ogni input passa per le validation rule, compresi gli ID nelle URL, che vanno verificati come esistenti e di proprietà dell'utente, non solo come interi. In Symfony il componente Validator applica i constraint direttamente sui DTO e sui form. Validare per davvero significa rifiutare ciò che non rispetta la forma attesa, non ripulirlo silenziosamente.

I secret e l'audit delle dipendenze

Qui entra la parte che il 2025 ha reso più urgente. Comincio dai secret, perché l'errore più banale è anche il più frequente: la chiave applicativa o le credenziali del database finite nel version control. In Laravel APP_KEY non si committa mai, il .env sta nel .gitignore e il .env.example non contiene valori reali. In Symfony c'è di più: il sistema dei Secret cifrati permette di versionare i secret di produzione in forma cifrata, con la chiave di decifratura tenuta fuori dal repository. È preferibile alle variabili d'ambiente in chiaro nei file di deploy.

Poi le dipendenze, che è dove l'OWASP 2025 ha alzato l'asticella. Con l'ingresso di Software Supply Chain Failures al terzo posto, il rischio non è più solo "una libreria con una CVE nota": è l'intera catena di build e distribuzione, dai pacchetti transitivi al registro da cui scarichi. Gli incidenti che hanno motivato la categoria nella documentazione OWASP, dal worm npm autopropagante alla compromissione di software di wallet, parlano una lingua sola: chi attacca oggi punta anche le dipendenze e le macchine degli sviluppatori, non solo il codice in produzione.

La contromisura di base, in PHP, costa cinque minuti ed è il singolo intervento con il miglior rapporto costo/beneficio dell'intera checklist: composer audit come gate in CI, che blocca il deploy se una dipendenza ha una vulnerabilità nota.

# in CI: fallisce la pipeline se una dipendenza ha una CVE nota
composer audit --no-dev --format=plain

# Symfony espone anche un check dedicato sul lock file
symfony security:check

A questo si aggiunge la disciplina che l'OWASP raccomanda per la supply chain: ottenere i pacchetti solo da sorgenti ufficiali, committare e rispettare il composer.lock per avere build riproducibili, aggiornare in modo deliberato e non inseguire ciecamente l'ultima versione. Una pipeline che non audita le dipendenze sta ignorando il rischio applicativo cresciuto più in fretta degli ultimi quattro anni. Integrare questo controllo nel flusso di sviluppo senza rallentare il team è un tema a sé, che ho affrontato nella guida su come integrare la sicurezza nello sviluppo per le PMI.

Sessione, runtime PHP e database

Restano i tre livelli di base, quelli che il framework non blinda da solo. La sessione è il primo: in produzione i cookie vanno cifrati e protetti, e mai serviti su file in un'architettura multi-server. La configurazione che applico in Laravel:

// config/session.php
'driver'     => env('SESSION_DRIVER', 'redis'),  // mai 'file' in multi-server
'lifetime'   => 60,                              // 60 minuti, non 120
'encrypt'    => true,                            // cifra il payload di sessione
'secure'     => true,                            // cookie solo su HTTPS
'http_only'  => true,                            // non leggibile da JavaScript
'same_site'  => 'lax',

In Symfony gli stessi flag stanno nel framework.yaml (cookie_secure, cookie_httponly, cookie_samesite, handler_id puntato a Redis). Il principio è identico: un cookie di sessione senza httponly e secure è un furto di sessione che aspetta solo l'occasione.

Il runtime PHP va portato a una configurazione da produzione, perché il php.ini di default è pensato per lo sviluppo. Le direttive che cambio sempre:

expose_php = Off
display_errors = Off
log_errors = On
allow_url_fopen = Off
allow_url_include = Off
session.cookie_httponly = On
session.cookie_secure = On
session.use_strict_mode = On
open_basedir = /var/www/app:/tmp

L'open_basedir è sottovalutato ma potente: confina l'accesso al filesystem alle sole directory necessarie, così che una RCE o un upload malevolo non possano leggere /etc/passwd o uscire dalla directory dell'applicazione. Un'avvertenza che molti tutorial ignorano: se usi i comandi Artisan in produzione (queue worker, scheduler), funzioni come exec e proc_open non possono essere disabilitate nel contesto CLI. La soluzione è tenere due php.ini separati, restrittivo per php-fpm e permissivo quanto basta per php-cli. Sul fronte versioni, lo stack di riferimento oggi è PHP 8.4 (active, con la 8.5 come current stable) per girare Laravel 13 o un Symfony 7.4 LTS: girare ancora su un PHP in sola sicurezza è già un debito da pianificare.

Il database, infine, è dove vivono i dati che contano, e le regole sono poche e non negoziabili:

  • Utente dedicato con privilegi minimi: l'applicazione non si connette mai come root. Un utente con SELECT, INSERT, UPDATE, DELETE sulle sole tabelle necessarie, senza DROP, CREATE, GRANT.
  • Binding su localhost: MySQL o PostgreSQL ascoltano solo su 127.0.0.1, mai su 0.0.0.0. Per l'accesso remoto, un tunnel SSH.
  • Credenziali forti e uniche: una password diversa per ogni applicazione, almeno 32 caratteri casuali.
  • Backup testati: un backup mai ripristinato non è un backup. Il ripristino è metà del lavoro, come spiego nella guida al piano di disaster recovery.

Se usi Redis come cache o session store (e in produzione dovresti), valgono le stesse precauzioni: requirepass con password forte, binding su 127.0.0.1, e i comandi distruttivi rinominati via rename-command FLUSHALL "". Un Redis esposto su Internet senza password è uno dei vettori più banali e più sfruttati che trovo nelle mie analisi.

Quanto vale, in concreto, questa lista

Il costo dell'hardening su un'applicazione tipica è di due-quattro giornate di lavoro. Il costo di un incidente per una PMI, tra fermo operativo, ripristino, notifica al Garante e danno reputazionale, si misura in decine di migliaia di euro, come documenta annualmente l'ENISA nel suo Threat Landscape. Il rapporto tra le due cifre è la ragione per cui questa non è una spesa, ma un investimento difensivo con un ritorno tra i più alti che conosca nell'IT.

C'è poi il livello normativo. Con la Direttiva NIS2, il cui termine di recepimento europeo era il 17 ottobre 2024, l'hardening non è più una best practice opzionale per i soggetti in perimetro: è un requisito con sanzioni reali. La differenza con questa checklist è di angolo, non di sostanza. Qui ti ho dato la resistenza strutturale generale; per il piano operativo allineato agli obblighi NIS2, con tempi e priorità, resta valida la checklist dedicata in 14 giorni.

Se la tua applicazione Laravel o Symfony è in produzione senza che nessuno abbia mai testato se l'hardening tiene davvero, il primo passo non è applicare alla cieca tutte queste configurazioni: è capire dove sei realmente esposto, perché la metà degli interventi spesso non serve e l'altra metà è urgente. È esattamente il lavoro che faccio in un assessment, partendo da fuori come farebbe un attaccante e arrivando fino al codice. Se vuoi una valutazione concreta della tua esposizione, contattami: di solito tra la prima scansione e una mappa chiara di cosa chiudere subito passano ore, non settimane, e da lì la priorità degli interventi diventa una decisione informata invece che un elenco generico copiato da un articolo.

Ultima modifica: