CSRF: cos'è e come difendere le tue applicazioni web
Il Cross-Site Request Forgery (CSRF) è un attacco che inganna il browser della vittima inducendolo a eseguire richieste HTTP non volute verso un'applicazione su cui l'utente è autenticato. La vittima non si accorge di nulla. Il server non distingue la richiesta legittima da quella "forgiata".
Nonostante sia una vulnerabilità vecchia e ben documentata, continua ad apparire nella OWASP Top 10 e nei report di bug bounty. È facile da sottovalutare perché non richiede di rubare credenziali, non ha bisogno di XSS e non lascia tracce evidenti nel log dell'applicazione.
Indice
- Come funziona il CSRF
- Esempio pratico di attacco
- Tipi di CSRF
- Come difendersi
- Implementazione in PHP, Django e API REST
- Errori comuni
- Testing
Come funziona il CSRF
Il browser allega automaticamente i cookie di sessione a ogni richiesta verso un dominio, indipendentemente da dove quella richiesta origina. Questo comportamento, pensato per facilitare la navigazione, è esattamente quello che il CSRF sfrutta.
In uno scenario classico l'utente accede a sitovulnerabile.it, il browser salva il cookie di sessione. Senza fare logout, l'utente visita una pagina malevola su un altro dominio. Quella pagina contiene codice che esegue una richiesta verso sitovulnerabile.it. Il browser allega il cookie di sessione, sitovulnerabile.it riceve una richiesta autenticata e la esegue.
Dal punto di vista del server, tutto sembra normale. Non ha modo di distinguere questa richiesta da una partita dall'interfaccia legittima.
Perché l'attacco funzioni servono alcune condizioni: la vittima deve essere autenticata nel momento dell'attacco, l'applicazione deve usare cookie di sessione, l'operazione target deve essere eseguibile con una singola richiesta prevedibile, e non devono essere attivi meccanismi di difesa.
Esempio pratico di attacco
Supponiamo che sitovulnerabile.it abbia un endpoint per i bonifici:
POST /bonifico HTTP/1.1
Host: sitovulnerabile.it
Cookie: sessione=abc123
importo=500&iban=IT60X0542811101000000123456&causale=Affitto
Un attaccante che conosce questa struttura crea un file HTML:
<html>
<body onload="document.forms[0].submit()">
<form action="https://sitovulnerabile.it/bonifico" method="POST" style="display:none">
<input type="hidden" name="importo" value="500">
<input type="hidden" name="iban" value="IT60X0542811101000000654321">
<input type="hidden" name="causale" value="Regalo">
</form>
</body>
</html>
Quando la vittima apre questo link (via email, messaggio, redirect da un sito legittimo compromesso), il form si invia automaticamente con il cookie di sessione allegato. Il bonifico parte senza che la vittima abbia cliccato nulla di esplicito.
Variante con tag img
Per richieste GET su applicazioni mal progettate, basta un tag immagine invisibile:
<img src="https://target.com/admin/delete-user?id=42" width="0" height="0">
Il browser tenta di caricare l'immagine, eseguendo la richiesta GET con i cookie allegati.
Tipi di CSRF
CSRF classico via form è il più diffuso. Le form HTML possono inviare richieste cross-origin senza restrizioni CORS, perché CORS si applica alle risposte JavaScript, non ai submit di form. Questo apre la porta a qualsiasi applicazione che non implementa token CSRF.
Login CSRF: l'attaccante forgia una richiesta di login usando le proprie credenziali, così la vittima si ritrova autenticata nell'account dell'attaccante. Tutto quello che l'utente inserisce dopo (indirizzi, numeri di carta, dati personali) va all'attaccante.
Stored CSRF: il payload malevolo viene salvato nell'applicazione stessa, ad esempio in un campo profilo o in un commento, e si attiva quando un altro utente visualizza quella pagina. Se chi visualizza è un amministratore, le conseguenze sono molto più gravi di un attacco one-shot.
Come difendersi
Token CSRF (Synchronizer Token Pattern)
Il server genera un token casuale per ogni sessione, lo include nel form come campo nascosto, e verifica che sia presente e corretto alla ricezione della richiesta. Se non corrisponde, la richiesta viene rifiutata con 403.
L'attaccante non può leggere il token dalla pagina legittima a causa della Same-Origin Policy, quindi non può includerlo nella richiesta "forgiata".
Requisiti minimi perché il meccanismo funzioni: il token deve essere generato con un CSPRNG, avere almeno 128 bit di entropia (32 caratteri hex), essere legato alla sessione e invalidato al logout. Un token debole o prevedibile rende il meccanismo inutile.
Cookie SameSite
Attributo del cookie che istruisce il browser a non allegarlo alle richieste cross-site. Supportato da tutti i browser moderni.
Set-Cookie: sessione=abc123; SameSite=Strict; Secure; HttpOnly
SameSite=Strict blocca il cookie su qualsiasi richiesta cross-site, compresa la navigazione da link esterni. SameSite=Lax (default su Chrome dal 2020) lo invia solo per navigazione top-level via GET. SameSite=None rimuove qualsiasi protezione e va usato solo quando strettamente necessario (es. widget di terze parti che richiedono autenticazione).
SameSite va usato insieme ai token CSRF, non al posto. Ci sono scenari edge (browser vecchi, configurazioni particolari di proxy) dove SameSite da solo non basta.
Verifica dell'header Origin
Il server controlla l'header Origin della richiesta e la rifiuta se non proviene dal dominio atteso:
def verifica_origin(request):
origin = request.headers.get('Origin') or request.headers.get('Referer', '')
if not origin.startswith('https://mia-app.com'):
raise PermissionError("Origin non autorizzata")
Alcuni browser e proxy rimuovono l'header Referer, quindi questo controllo da solo non è sufficiente.
Double Submit Cookie
Utile per API stateless. Il server genera un token casuale, lo manda sia come cookie che nel body/header della richiesta, e verifica che i due coincidano. L'attaccante non può leggere il valore del cookie dallo stesso dominio a causa della Same-Origin Policy, quindi non può costruire una richiesta valida.
Implementazione in PHP, Django e API REST
PHP nativo
// Generazione del token
session_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$csrf_token = $_SESSION['csrf_token'];
<form action="/bonifico" method="POST">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<!-- altri campi -->
<button type="submit">Conferma</button>
</form>
// Validazione alla ricezione
session_start();
function valida_csrf(string $token_ricevuto): void {
if (
empty($_SESSION['csrf_token']) ||
!hash_equals($_SESSION['csrf_token'], $token_ricevuto)
) {
http_response_code(403);
die('Richiesta non valida');
}
}
valida_csrf($_POST['csrf_token'] ?? '');
hash_equals() al posto di === previene timing attacks, dove un attaccante può inferire la lunghezza del token misurando i tempi di risposta del confronto.
Django
Django ha la protezione CSRF attiva di default. Non disabilitarla.
# settings.py - middleware già incluso per default
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
# ...
]
<!-- In ogni form POST -->
<form method="post" action="/bonifico/">
{% csrf_token %}
<!-- altri campi -->
</form>
Per le chiamate fetch/axios dal frontend JavaScript:
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
fetch('/api/bonifico/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken'),
},
body: JSON.stringify({ importo: 500, iban: '...' })
});
API REST con JWT
Le API stateless che usano JWT negli header Authorization non soffrono di CSRF classico: il browser non allega automaticamente gli header personalizzati come allega i cookie. Il problema nasce quando il JWT viene salvato in un cookie per comodità, pattern abbastanza diffuso. In quel caso l'applicazione è di nuovo esposta, e servono SameSite=Strict sul cookie o il Double Submit Cookie pattern.
// Pattern sicuro: JWT nell'header, non nel cookie
fetch('/api/bonifico', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('jwt')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
});
Errori comuni
Usare GET per operazioni con effetti. Le richieste GET devono essere idempotenti. Qualsiasi operazione che modifica lo stato dell'applicazione (cancellazioni, aggiornamenti, trasferimenti) deve passare per POST, PUT o DELETE.
// ❌
// /admin/delete-user?id=42
if (isset($_GET['id'])) {
$db->deleteUser($_GET['id']);
}
Token generato male. Un token derivato da dati prevedibili come il session ID vanifica il meccanismo:
$token = md5(session_id()); // ❌ prevedibile
$token = bin2hex(random_bytes(32)); // ✅ 256 bit di entropia casuale
@csrf_exempt usato a casaccio. In Django (e negli equivalenti degli altri framework), questa decorazione va usata solo per endpoint che ricevono richieste da sistemi esterni non-browser, come webhook o integrazioni server-to-server. Mai su endpoint accessibili da utenti autenticati via browser.
Testing
Test manuale con Burp Suite: intercetta una richiesta POST autenticata, rimuovi il token CSRF dal body, ripeti la richiesta. Se il server risponde 200, l'endpoint è vulnerabile. Burp ha anche Engagement Tools > Generate CSRF PoC che costruisce automaticamente il file HTML di test partendo dalla richiesta intercettata.
Con OWASP ZAP: lo scanner attivo analizza i form dell'applicazione e segnala quelli privi di token. Utile per una verifica sistematica su applicazioni con molti endpoint.
Meccanismi a confronto
| Meccanismo | Efficacia | Quando usarlo |
|---|---|---|
| Token CSRF (Synchronizer) | Alta | Applicazioni con sessioni server-side |
| SameSite=Strict/Lax | Alta | Sempre, come layer aggiuntivo |
| Double Submit Cookie | Media-Alta | API stateless con cookie |
| Controllo Origin/Referer | Media | Come complemento |
| JWT in header Authorization | Alta | API REST senza cookie di sessione |