Vai al contenuto
New

DeepSeek V4.1 Flash free for 7 days: 10M tokens a day after a top-up of $10 or more. Until Nov 30.

How it works

Che cos'è il CSRF e come aiutano i cookie SameSite

Il cross-site request forgery usa contro di te i cookie del tuo browser. Le difese in ordine di efficacia e un laboratorio Flask per provare SameSite.

Intermedio12 min di letturaDi NotHoly

Il cross-site request forgery (CSRF) permette a una pagina di un sito di far inviare al tuo browser una richiesta che cambia lo stato su un altro sito dove hai fatto l'accesso. Il browser allega da solo il cookie di sessione, quindi l'altro sito considera la richiesta come tua. Le difese sono un segreto per sessione che l'attaccante non può leggere, l'attributo SameSite dei cookie, un controllo su Origin e la regola che le richieste GET non cambiano mai lo stato. Questo articolo le spiega una per una, poi passa a un piccolo laboratorio Flask per farti vedere come si muove il cookie.

Che cos'è il CSRF

Una sessione web vive di solito in un cookie. Dopo che accedi a bank.example, il browser salva un cookie per quell'host e lo invia con ogni richiesta verso quell'host. Il server legge il cookie, trova la tua sessione ed esegue l'azione.

Il browser non controlla da dove arriva una richiesta. Se una pagina su evil.example contiene un form che fa POST verso https://bank.example/transfer, il browser invia quel POST con il cookie di bank.example allegato. La banca vede una sessione valida e sposta il denaro. L'attaccante non vede mai il tuo cookie e non legge mai la risposta. All'attacco basta che il server esegua l'azione.

CWE-352 descrive così la debolezza: l'applicazione esegue un'azione che cambia lo stato per conto di un utente senza controllare che l'utente abbia scelto di fare quella richiesta. La correzione consiste nel permettere al server di distinguere una richiesta fatta dall'utente da una forgiata da una pagina di terzi.

Perché è importante

Qualsiasi app che tiene le sessioni nei cookie e modifica dati quando riceve una richiesta è esposta. Le azioni più delicate sono quelle che cambiano credenziali o impostazioni: un cambio di password, un cambio di indirizzo email, una nuova chiave API, un nuovo metodo di pagamento, una regola di inoltro. Un cambio di email forgiato è spesso il primo passo per impossessarsi di un account, perché a quel punto l'attaccante può reimpostare la password tramite il nuovo indirizzo.

Le sessioni con cookie sono il comportamento predefinito in molti framework, e il browser invia il cookie che lo sviluppatore ci abbia pensato o no. Per questo il CSRF compare ancora negli elenchi di OWASP e CWE.

Come funziona l'attacco, passo per passo

  1. La vittima accede a bank.example. Il browser salva il cookie di sessione per quell'host.
  2. La vittima apre un'altra pagina nello stesso browser, senza uscire dall'account. La pagina appartiene all'attaccante.
  3. La pagina contiene un form che fa POST verso https://bank.example/transfer con valori scelti dall'attaccante. Può partire da solo al caricamento della pagina, quindi la vittima non deve cliccare nulla.
  4. Il browser invia il POST e include il cookie di bank.example.
  5. Il server convalida il cookie, vede un utente autenticato ed esegue il trasferimento.

Nota cosa non è servito all'attaccante. Non ha rubato il cookie, non ha indovinato una password e non ha letto un solo byte della risposta della banca. Il lavoro l'ha fatto il browser.

Perché GET non deve cambiare lo stato

Una richiesta GET si può innescare senza alcun form. Un tag <img src="https://bank.example/delete?id=7"> fa inviare al browser una richiesta GET appena la pagina viene renderizzata. Anche il prefetch, le anteprime dei link e i crawler possono inviare richieste GET. Se una route GET modifica dati, al CSRF non serve nemmeno un form.

La regola è semplice: GET e HEAD leggono i dati e non li modificano mai. Le modifiche passano da POST, PUT, PATCH o DELETE. Da sola la regola non è una difesa, perché un POST si può comunque forgiare, ma ogni altra difesa funziona meglio quando la regola è rispettata.

Le difese, in ordine di efficacia

1. Synchronizer token

Il server crea un token casuale per ogni sessione, lo mette in ogni form come campo nascosto e lo controlla su ogni richiesta che cambia lo stato. Una richiesta senza il token giusto viene rifiutata.

Il token funziona perché la pagina dell'attaccante non può leggerlo. La same-origin policy impedisce a evil.example di leggere l'HTML restituito da bank.example, quindi l'attaccante non può copiare il campo nascosto in un form forgiato. Una richiesta forgiata arriva con il cookie e senza il token.

Il token deve essere imprevedibile (usa un generatore casuale crittografico), legato alla sessione dell'utente e controllato su ogni metodo non sicuro, compresi gli endpoint chiamati dal tuo JavaScript. La maggior parte dei framework include questa protezione. Per questo il cheat sheet di OWASP mette per prime le difese basate su token.

I double-submit cookie sono una variante più debole. Il token va sia in un cookie sia in un campo della richiesta, e il server li confronta. Un attaccante che può impostare cookie sul tuo dominio, per esempio da un sottodominio fratello, può aggirarli. Preferisci il synchronizer token quando puoi tenere lo stato sul server.

SameSite dice al browser quando allegare un cookie a una richiesta che parte da un altro sito. L'attributo ha tre valori.

  • Strict invia il cookie solo per le richieste che partono dallo stesso sito. Un link da un'email verso la tua app arriva senza cookie, quindi alla prima visita l'utente risulta non autenticato.
  • Lax invia il cookie per le richieste dallo stesso sito e per le navigazioni GET di primo livello da un altro sito, come quando si segue un link. Non invia il cookie nelle richieste POST cross-site, né nelle richieste che caricano immagini, frame e script da un altro sito. Lax blocca l'attacco classico basato su form.
  • None invia il cookie anche nelle richieste cross-site. Richiede l'attributo Secure, quindi il cookie viaggia solo su HTTPS. Usa None solo quando un flusso cross-site reale ha bisogno del cookie, come un widget di terze parti incorporato.

MDN documenta l'attributo e i suoi valori nella pagina di riferimento di Set-Cookie. La bozza RFC 6265bis definisce "sito" a partire dal dominio registrabile (il suffisso pubblico più l'etichetta alla sua sinistra, quindi site.example per www.site.example). I numeri di porta non contano. I sottodomini contano come stesso sito. Questo torna utile più avanti nel laboratorio, perché localhost e 127.0.0.1 sono host diversi e quindi siti diversi.

SameSite è una difesa forte, ma incompleta. Un sottodominio fratello come forum.bank.example è same-site rispetto a bank.example, quindi Lax non ferma un attacco lanciato da lì. I browser più vecchi che ignorano l'attributo inviano il cookie ovunque. E Lax invia ancora il cookie nelle navigazioni GET di primo livello, ed è per questo che la regola su GET resta valida. Considera SameSite uno degli strati.

3. Controlli su Origin e Referer

I browser inviano un header Origin nelle richieste POST e nella maggior parte delle richieste cross-site. L'header contiene schema, host e porta della pagina che ha avviato la richiesta, così il server può confrontarlo con la propria origine. Sec-Fetch-Site dà un segnale simile, con valori come same-origin, same-site e cross-site.

Il controllo è semplice. Rifiuta le richieste che cambiano lo stato quando Origin è presente e non corrisponde alla tua origine. Se Origin manca, ricorri a Referer. Se in una richiesta che cambia lo stato mancano entrambi, rifiutala. Interpreta l'header come URL e confronta l'origine completa. Un controllo per sottostringa fallisce: Origin: https://bank.example.evil.test contiene il tuo dominio ma appartiene a qualcun altro.

I controlli su Origin intercettano le richieste chiaramente cross-site, ma dipendono da header che alcuni client omettono e che le impostazioni di privacy possono togliere. Usali come filtro e tieni un token per i casi che gli header non coprono.

4. Mai cambiare lo stato con GET

Questa è una regola, e non un controllo che puoi verificare con una singola richiesta. Rispettala perché estende la copertura di ogni altra difesa. Con una route che cambia lo stato solo con POST, l'attaccante deve forgiare un form: non gli basta piazzare un tag immagine.

Ti servono Python 3.12 e tre finestre di terminale. Per questo articolo ho usato Python 3.12.10 su Windows 11 con Git Bash. Il laboratorio usa un'app Flask su 127.0.0.1:5000 e una semplice pagina statica su localhost:8001. È la seconda origine a rendere la richiesta cross-site.

Prepara l'ambiente

Crea un ambiente virtuale e installa Flask. Su Linux e macOS lo attivi con . .venv/bin/activate. Su Windows con Git Bash, lo script di attivazione si trova in .venv/Scripts/activate.

bash
python3 -m venv .venv && . .venv/bin/activate && pip install flask

In questa esecuzione le versioni installate erano Flask 3.1.3 e Werkzeug 3.1.9.

Scrivi l'app

Salva questo file come app.py. La route /login imposta un cookie di sessione con SameSite=Lax e HttpOnly. La route /transfer non modifica nulla. Stampa solo se il cookie è arrivato, insieme a due header della richiesta che aiutano a leggere il risultato.

python
import os

from flask import Flask, make_response, request

app = Flask(__name__)

# Change this to "None" to compare. Browsers reject SameSite=None without Secure.
SAMESITE = os.environ.get("LAB_SAMESITE", "Lax")
SECURE = SAMESITE == "None"


@app.get("/login")
def login():
    resp = make_response("logged in\n")
    resp.set_cookie("session", "lab", samesite=SAMESITE, secure=SECURE, httponly=True)
    return resp


@app.post("/transfer")
def transfer():
    arrived = request.cookies.get("session") == "lab"
    print(
        f"POST /transfer cookie_arrived={arrived} "
        f"origin={request.headers.get('Origin')} "
        f"sec_fetch_site={request.headers.get('Sec-Fetch-Site')}",
        flush=True,
    )
    return f"cookie_arrived={arrived}\n"

L'app per ora non convalida nulla, ed è voluto. Vuoi vedere cosa invia il browser prima di aggiungere una difesa.

Nel primo terminale:

bash
flask --app app run --host 127.0.0.1 --port 5000

In un secondo terminale, richiedi la route di login e leggi l'header:

bash
curl -i http://127.0.0.1:5000/login

Output di questa esecuzione, senza la riga Date:

text
HTTP/1.1 200 OK
Server: Werkzeug/3.1.9 Python/3.12.10
Content-Type: text/html; charset=utf-8
Content-Length: 10
Set-Cookie: session=lab; HttpOnly; Path=/; SameSite=Lax
Connection: close

logged in

La parte che conta è la riga Set-Cookie. HttpOnly tiene il cookie lontano dal JavaScript della pagina. Non impedisce al browser di inviarlo, quindi HttpOnly non è una difesa contro il CSRF. L'attributo che cambia ciò che il browser invia tra siti diversi è SameSite=Lax.

Simula quello che vede il server

Prima di coinvolgere un browser, invia tu le richieste, così sai cosa registra il server:

bash
curl -s -X POST -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://localhost:8001" -H "Sec-Fetch-Site: cross-site" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://127.0.0.1:5000" -H "Sec-Fetch-Site: same-origin" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transfer

Risposte di questa esecuzione, una riga per comando:

text
cookie_arrived=False
cookie_arrived=True
cookie_arrived=True

Le righe stampate dal terminale di Flask per quelle tre richieste:

text
POST /transfer cookie_arrived=False origin=None sec_fetch_site=None
POST /transfer cookie_arrived=True origin=http://localhost:8001 sec_fetch_site=cross-site
POST /transfer cookie_arrived=True origin=http://127.0.0.1:5000 sec_fetch_site=same-origin

curl non applica SameSite. Invia quello che gli passi con -b. Quindi queste righe dimostrano che la route legge il cookie e gli header, ma non dicono cosa invia un browser. Quello è il passo successivo.

Servi un form da una seconda origine

Crea una cartella chiamata attacker e salva questo file come attacker/index.html:

html
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Second origin (lab)</title></head>
<body>
<h1>Form on a second origin</h1>
<form method="POST" action="http://127.0.0.1:5000/transfer">
  <input type="hidden" name="amount" value="100">
  <button type="submit">Send transfer</button>
</form>
</body>
</html>

In un terzo terminale, avvia un server statico dalla cartella che contiene attacker/:

bash
python3 -m http.server 8001 --bind 127.0.0.1

In questa esecuzione, una richiesta al server statico ha restituito HTTP 200. La pagina poi si carica su http://localhost:8001/attacker/. Fai attenzione al nome host. localhost e 127.0.0.1 sono host diversi, quindi il form servito da localhost:8001 fa POST verso un sito diverso dall'host del cookie. È la configurazione cross-site che ti serve.

Confronta SameSite=Lax con SameSite=None

Apri prima http://127.0.0.1:5000/login nel browser, così il cookie viene salvato per 127.0.0.1 in quel profilo. Poi apri http://localhost:8001/attacker/ nello stesso browser e clicca Send transfer. Leggi il terminale di Flask.

Con SameSite=Lax, un POST da un form cross-site non dovrebbe portare il cookie. La riga di log attesa è cookie_arrived=False. Il browser omette il cookie perché Lax non lo invia nelle richieste POST cross-site. Il passo nel browser non è stato eseguito per questo articolo, quindi confronta tu la riga di log con questa aspettativa.

Ora riavvia l'app con None:

bash
LAB_SAMESITE=None flask --app app run --host 127.0.0.1 --port 5000

Leggi l'header sul nuovo server:

bash
curl -i http://127.0.0.1:5000/login

Output di questa esecuzione:

text
Set-Cookie: session=lab; Secure; HttpOnly; Path=/; SameSite=None

SameSite=None da solo non basta. I browser rifiutano un cookie None senza Secure, e Secure richiede HTTPS. Il laboratorio serve HTTP semplice su 127.0.0.1, e il fatto che un browser salvi o no un cookie Secure da quell'origine dipende dal browser. Apri il cookie negli strumenti per sviluppatori del browser e guarda se esiste. Se non è stato salvato, il log del server mostra cookie_arrived=False, ma il motivo è la regola di Secure e non SameSite. Se è stato salvato, invia di nuovo il form. Una riga cookie_arrived=True con None significa che il browser ha inviato un cookie in un POST cross-site. Questo comportamento rende None rischioso per i cookie di sessione, ed è il motivo per cui conviene usare Lax o Strict, a meno che un flusso cross-site reale non richieda None.

Ferma ogni server con Ctrl+C quando hai finito.

Cosa non dimostra il laboratorio

Il laboratorio mostra come i cookie e SameSite cambiano ciò che riceve il server. Non aggiunge una difesa. La route /transfer accetta ancora qualsiasi POST che porti il cookie, quindi un cookie SameSite=None lascerebbe passare un form forgiato. Per chiudere il buco, aggiungi un synchronizer token al form e controllalo nella route. Il laboratorio si ferma qui per farti vedere prima l'attributo da solo.

Errori comuni

  • Contare su CORS per fermare il CSRF. CORS controlla se una pagina può leggere una risposta. Non impedisce al browser di inviare un semplice POST cross-site. La richiesta arriva comunque al server.
  • Credere che HttpOnly fermi il CSRF. HttpOnly nasconde il cookie al JavaScript. Il browser continua ad allegarlo alle richieste forgiate.
  • Controllare il token solo su alcune route. Gli endpoint usati dalle chiamate AJAX spesso saltano il controllo. Gli attaccanti cercano proprio la route che lo salta.
  • Mettere il token nell'URL. Gli URL finiscono nei log del server, negli header Referer e nella cronologia del browser.
  • Far modificare dati alle route GET. A quel punto per attaccare basta un semplice tag immagine, e SameSite=Lax non protegge più la route.
  • Impostare None senza Secure. I browser lo rifiutano. Se cambi l'attributo per sistemare un flusso che non funziona, rischi di aprire un percorso cross-site che non volevi aprire.
  • Confrontare Origin per sottostringa. evil-bank.example e bank.example.evil.test contengono entrambi bank.example. Confronta le origini complete.
  • Affidarsi solo a SameSite. Protegge bene dai siti non correlati, ma i sottodomini fratelli sono same-site e i browser più vecchi possono ignorare l'attributo.

Come andare oltre

Leggi il cheat sheet di OWASP dall'inizio alla fine. Copre i pattern dei token, i controlli sugli header e i casi che ognuno lascia scoperti. Leggi la bozza RFC 6265bis per le regole esatte di SameSite, compreso il valore predefinito quando l'attributo manca e il trattamento delle navigazioni di primo livello. La pagina MDN su Set-Cookie è un riferimento più rapido per i valori dell'attributo.

Nella tua app usa la protezione CSRF del framework, e testala. Scrivi un test che, senza token, fa POST su una route che cambia lo stato e si aspetta un rifiuto. Scrivine un secondo che fa POST con un token di un'altra sessione e si aspetta di nuovo un rifiuto. Se usi Flask, guarda l'estensione CSRFProtect di Flask-WTF. Controlla gli avvisi sui cookie negli strumenti per sviluppatori del browser, perché Chrome segnala i problemi di SameSite nel pannello Issues.

Per il livello di trasporto sotto questi cookie, leggi cosa fanno HTTPS e TLS. L'attributo Secure ha senso solo quando la connessione è cifrata. Il prossimo articolo di questa serie, come scrivere il tuo primo report di sicurezza, mostra come documentare un finding come questo per il team responsabile del codice.

Fonti

Fonti

  1. OWASP Cross-Site Request Forgery Prevention Cheat Sheet
  2. MDN, Set-Cookie header
  3. CWE-352, Cross-Site Request Forgery (CSRF)
  4. IETF draft-ietf-httpbis-rfc6265bis, HTTP State Management Mechanism

Altro su Sicurezza web e API

Sicurezza web e API

Testare i bug web comuni con l'aiuto dell'IA

Metodo per IDOR, XSS, SSRF e auth rotta con un modello di IA come lettore e secondo parere, più come verificare un finding ed evitare i falsi positivi.

Base3 min di lettura

Sicurezza web e API

Test delle API con l'IA: OpenAPI e GraphQL

Le API si descrivono tramite le spec OpenAPI e l'introspection GraphQL. Usa l'IA per leggere lo schema e pianificare i test di autorizzazione.

Intermedio3 min di lettura