Come prevenire attacchi di tipo CSRF in un sito già esistente, in PHP: parte 1
In questo articolo vediamo come proteggere un sito dagli attacchi di tipo Cross-Site Request Forgery o semplicemente CSRF.
Cosa Sono gli attacchi CSRF
Il Cross-site Request Forgery è una vulnerabilità a cui sono esposti i siti web che non implementano meccanismi di controllo sulle richieste di tipo GET o POST: in poche parole questi siti non verificano se le richieste siano stata inviate intenzionalmente dall'utente che sta navigando il sito oppure no.
Sono quindi richieste HTTP malevoli, volte a manipolare o accedere a dati sensibili.
Facciamo un esempio: un utente è loggato su un sito web, ad esempio, di una banca, ed quindi è legittimato ad operare su questo sito.
Un hacker "attaccante" crea un sito web malevolo con un modulo (FORM) nascosto che invia una richiesta POST al sito bancario per trasferire denaro dal conto dell’utente al conto dell’attaccante. L' attaccante invia un link tramite una email all’utente, una email che, ad un occhio non attento, sembra provenire da una mittente reale, e che induce l'utente a cliccare sul link per visitare il sito malevolo. L'utente clicca, apre la pagina dell'attaccante, parte al chiamata POST al sito della banca... e il danno è fatto perchè l'utente è loggato sul sito della banca, e il sito della banca non verifica se quella chiamata è stata effettuata in modo consapevole dall'utente.
E' chiaro che i siti delle banche implementino meccanismi di controllo per cui questo non sarebbe possibile, ma questo esempio serve a farti capire come agiscono questi attacchi, in siti in cui non sono presenti sistemi di controllo delle richieste.
Differenza tra richieste GET e POST: le REQUEST METHODS
Il client (normalmente un browser) effettua una richiesta HTTP al server (ad una pagina/script php), inviando dei parametri.
Nelle richieste di tipo GET i parametri sono visibili all'utente perchè vengono appesi alla url chiamata.
In questo esempio passiamo i parametri "pippo" e "pluto" alla pagina "lamiapagina.php"
https://www.miosito.it/lamiapagina.php?pippo=3&pluto=5
La stringa "pippo=3&pluto=5" si chiama query string.
Nelle richieste di tipo POST i parametri vengono inviati senza appenderli alla url, tipicamente tramite i classici "form" inseriti nella pagina, dove si specifica come metodo di invio il POST.
Ecco un esempio di POST con l'invio degli stessi parametri dell'esempio precedente
<!DOCTYPE html>
<html>
<head>
<title></title>
</head>
<body>
<form action="lamiapagina.php" method="POST">
<input type="hidden" name="pippo" value="3" />
<input type="hidden" name="pluto" value="5" />
<input type="submit" value="Invia" />
</form>
</body>
</html>
Ovviamente, ispezionando l'html, questi campi di tipo "hidden" e i loro valori sono comunque visibili.
Nel codice precedente, il click sul pulsante INVIA, porta l'utente sulla pagina "lamiapagina.php", che elabora i dati inviati in post, e poi l'utente verrà nuovamente redirezionato sulla pagina precedente, o una pagina deffierente, dove verrà presentata una risposta.
Questa metodologia... si usava nel metodo scorso.
Oggi infatti per le chiamate POST si fa uso di javascript e nello specifico di chiamate di tipo AJAX, che consentono di effettuare queste post senza cambiare pagina, intercettare la risposta della pagina chiamata, e rispondere all'utente, senza cambiare mai pagina!
In questo articolo vedremo proprio un esempio di chiamata Ajax.
NOTA: non esistono richieste ibride POST e GET, le chiamate o sono GET o sono POST.
Se fai una chiamata POST ad una url come ad esempio questa "lamiapagina.php.php?a=3&b=5", anche se riceverai i parametri "a" e "b" in GET, questa sarà considerata sempre una richiesta POST.
Per sapere che tipo di richiesta HTTP stiamo ricevendo è sufficiente fare un print della variabile superglobale di php $_SERVER['REQUEST_METHOD']
<?php
echo $_SERVER['REQUEST_METHOD'];
Tuttavia non esistono solo richieste GET e POST.
Le tipologie di richieste verso uno script php, sono le seguenti, e sono legate alle classiche operazioni CRUD sui dati (create, read, update, delete):
- GET: per leggere i dati di un record e visualizzarli nella pagina, non modificando quindi i record
- POST: per creare un nuovo record
- PUT: per aggiornare tutti i campi di un record
- PATCH: per aggiornare solo alcuni campi di un record
- DELETE: per cancellare un record
Esiste anche il metodo HEAD, simile a GET, ma serve a richiedere solo gli header di risposta senza il corpo, e il metodo OPTIONS che descrive le opzioni di comunicazione per la risorsa di destinazione. Questi due metodi, tuttavia, qui non ci interessano.
Ho fatto questa premessa perchè su un sito custom difficilmente differenziamo le POST / PUT / PATCH / DELETE: usiamo solo le POST. Se invece avete dimestichezza con i framework, come Laravel, ne vedrete il loro utilizzo.
In questo articolo generalizziamo e ipotizziamo un mondo con chiamate solo di tipo GET e POST.
Come proteggersi dagli attacchi CSRF: il token CSRF
Per progettere il nostro sito da questa tipologia di offensiva, occorre implementare un token, cioè un valore generato ad ogni apertura delle nostre pagina web: quando dalla nostra pagina, che identifichiamo con la lettera A, faremo una chiamata di tipo POST o GET ad un'altra nostra pagina/script, che identifichiamo con la lettera B, dobbiamo portarci dietro questo token e verificarlo nella pagina B. Se il token è verificato nella pagina B, la chiamata/navigazione procede, altrimenti viene bloccata.
I framework normalmente già implementano questo sistema di protezione, vedi ad esempio Laravel, per cui su questi il problema non sussiste.
Mentre su un siti/applicativi custom potrebbe esserci l'esigenza di implementarlo, e di dover metter mano a decine o centinaia pagine/script già presenti.
Il token CSRF può essere implementato sia nelle chiamate di tipo GET che POST.
L'obiettivo di questo articolo è implementare il Token CSRF nel modo più agevole possibile, limitando l'intervento nel nostro codice, con una soluzione quasi indolore che ci consenta di agire solo su due file normalmente già presenti e che sono inclusi in tutte le pagine/script del nostro sito: il file config.php (dove inseriamo le configurazioni, costanti, variabili usate da tutto il sito) e un javascript usato da tutto il sito. In questo modo, agendo solo su due file, andiamo ad implementare un sistema che consente di prevenire questa tipologia di attacchi.
Inoltre: il token CSRF serve a proteggere dati sensibili e le operazioni di tipo CRUD sui database (create, read, update, delete), e normalmente operiamo su dati sensibili quando facciamo chiamate di tipo POST. Le GET infatti si usano sopratutto, ad esempio, in una pagina con un modulo di ricerca ed un paginatore, quando cambiamo pagina e ci portiamo dietro il numero di pagina, i criteri di ricerca,.... (come fa Google). Oppure quando apriamo una pagina con un parametro appeso, ad esempio un ID di un record, per aprire in quella pagina quel record. Ma, in questi casi, nella pagina abbiamo certamente già implementato un controllo sui parametri passati (ad esempio se l'id ricevuto è un intero, se il parametri di tipo stringa non contenga caratteri "strani"...) e certamente preveniamo le injection nelle interrogazioni al database usando PDO o comunque filtrando i parametri ricevuti .... mi soffermo sul certamente, mi raccomando!
Tutto questo per dire che con le GET le possibilità per un attaccante di far danni sono decisamente minori, per cui andremo ad introdurre il CSRF solo nelle chiamate POST (come fa il già menzionato Laravel).
Bene, mettiamoci al lavoro. Vediamo step by step come implementare il token CRSF .... e, non preoccuparti, a fine articolo troverai il link per scaricare l'esempio.
Come implementare il token CRSF
Abbiamo un sito già pronto, con varie pagine, e varie chiamate di tipo POST. Andiamo a proteggere tutte le chiamate POST!
Sicuramente nel tuo sito esiste un file di configurazione, chiamato ad esempio config.php, che è incluso all'inizio di tutte le pagine / script del nostro sito.
Inoltre è molto probabile che nelle pagine venga incluso un javascript comune a tutte le pagine, chiamato ad esempio global.js: se non c'è crealo ed inseriscilo preferibilmente nel footer del sito. E' preferibile che i javascript stiano nel footer così che vengano letti solo alla fine del rendering di tutto l'html della pagina. Se hai un file footer.php incluso in tutte le pagine, mettilo li.
Quindi agiamo solo su due file, il config.php e il global.js... o come si chiamano nel vostro sito. E creeremo un file con una classe per gestire il token, che includeremo nella config.php.
Ti dico in sintesi cosa faremo:
- All'apertura della pagina, nella config.php inclusa, viene generato il token, ed inserito in un tag nella header di tutte le pagine
- Nella pagina ci sarà il form che farà una chiamata post, che verrà intercettata da un javascript, e verrà automaticamente aggiunto il token il CSRF agli altri parametri postati.
- Nella pagina / script chiamata dalla post, nella config.php inclusa, verifico se il token ricevuto è uguale a quello generato prima. Se la verifica è superata, lo script prosegue, altrimenti blocchiamo la chiamata
Andiamo nel dettaglio. La nostra pagina web, esempio index.php, al momento è strutturata senza implementazione del token, come nell'esempio sottostante.
<?php
include "config.php";
?>
<!DOCTYPE html>
<html lang='it'>
<head>
<meta charset='utf-8' />
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'>
</head>
<body>
<h2 style='text-align:center'>LA MIA PAGINA</p>
<br>
<form action="action1.php" method="POST">
<input type="hidden" id="param1" name="param1" value="pippo">
<input type="hidden" id="param2" name="param2" value="pluto">
<input type="submit" value="invia primo form">
</form>
<br><br>
<form action="action2.php" method="POST">
<input type="hidden" id="param1" name="param1" value="goldrake">
<input type="hidden" id="param2" name="param2" value="mazinga">
<input type="submit" value="invia secondo form">
</form>
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script type='text/javascript' src='global.js'></script>
</body>
</html>
Il file include la config.php e contiene due form, il primo che punterà la POST ad una url chiamata "action1.php" e l'altro ad una url differente "action2.php": questo perchè voglio dimostrarti come il nostro metodo si applicherà a qualunque form automaticamente (ed in qualunque pagina sia).
Nel footer è presente la libreria jquery, che ci semplifica la vita, ed un file global.js, entrambi presenti in tutte le pagine del sito.
Generalizziamo e diciamo che tutto il codice prima del tag "<body>", e quello dall'inclusione del jquery fino al fondo, è presente in tutte le pagine (potresti ad esempio inserire quel codice nei file header.php e footer.php, se il tuo sito, come immagino, avrà).
Vediamo cosa c'è nella config.php: l'apertura di una nuova sessione, molto utile ai nostri fini, e costanti come quelle per la connessione al database... che al momento non ci interessano (ognuno avrà le sue).
<?php
session_start();
// costanti del sito
define('DB_NAME', 'database_name_here');
define('DB_USER', 'username_here');
define('DB_PASSWORD', 'password_here');
define('DB_HOST', 'localhost');
define('DB_CHARSET', 'utf8');
............
Da questo momento inizia il nostro lavoro di implementazione del token CSRF.
Creiamo una classe Crsf, con la quale generiamo e verifichiamo il token, ed inseriamola nel file omonimo Crsf.php
Al suo interno abbiamo due metodi pubblici
- generateCSRF: genera il token e lo salva in una variabile di sessione chiamata $_SESSION['tokenCSRF']
- checkCSRF: verifica se il token ricevuto in POST è uguale a quello salvato in sessione. Se la verifica ha successo risponde con true, altrimenti false.
<?php
class Csrf
{
public function generateCSRF():string
{
$token = bin2hex(random_bytes(16));
// Save the token in the user Session.
$_SESSION['tokenCSRF'] = $token;
return $token;
}
public function checkCSRF():bool
{
if(empty($_SESSION['tokenCSRF']) || empty($_POST['csrf'])){
return false;
}
if($_POST['csrf']!=$_SESSION['tokenCSRF']){
return false;
}
return true;
}
}
Includiamo questo file nel config.php e quindi in tutte le pagine / script del nostro sito web.
Dopo averlo incluso, e istanziata la classe Csrf, procediamo così
- Se riceviamo una chiamata POST, chiamiamo il metodo checkCSRF: se risponde true, lo script procede, altrimenti inseriamo un bell' exit!
- In tutti gli altri casi, generiamo il token chiamando il metodo generateCSRF
<?php
session_start();
// instanzio la classe Csrf (che genera e verifica il token Csrf)
include_once 'Csrf.php';
$csrf=new Csrf();
// se è una post: verifico sempre il token ...
if($_SERVER['REQUEST_METHOD']=='POST'){
// ... se non verificato blocco tutto
if($csrf->checkCSRF()!==true){
exit;
}
// ... se ok procedo e sarà il file chiamato dalla post a rispondere come previsto
}
// altrimenti genera sempre il token
else {
$token=$csrf->generateCSRF();
}
Quando apro la pagina, e quindi non sto facendo una POST, verrà generato il token, che abbiamo nella variabile $token. Inseriamo questo token nella/e pagine web, in una parte comune a tutte le pagine: la header. Inseriamolo in un meta tag con il nome "_csrf". In questo modo tutte le pagine che l'utente vedrà avrà questo token, ogni volta diverso perchè ad ogni apertura verrà rigerenerato.
<?php
include "config.php";
?>
<!DOCTYPE html>
<html lang='it'>
<head>
<meta charset='utf-8' />
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'>
<meta name="_csrf" content="<?=$token?>"/>
</head>
<body>
............
Cosa ci manca? Aggiungere questo token ai parametri quando faccio la POST, cioè voglio che al click sul bottone del form, di qualunque form, di qualunque pagina, quel token venga aggiunto automaticamente agli altri parametri che già vengono inviati con la POST.
E per farlo usiamo javascript. Nel file global.js aggiungiamo del codice: con un Listener, recupero tutti i form prensenti nella pagina, e chiamo la funzione "handleforms" che resta in ascolto ed intercetta evantuali chiamate POSTO. Opera così:
- preleva il csrf dal meta tag
- intercetta le chiamate post
- le "blocca" cioè non le esegue ancora, usando event.preventDefault()
- aggiunge il parametro csrf agli altri parametri della post
- e solo a quel punto effettua la post con un chiamata ajax
document.addEventListener('DOMContentLoaded', () => {
// cerco tutti i form della pagina
var forms = document.querySelectorAll('form');
handleforms(forms);
})
function handleforms(forms) {
// prelevo il csrf
const csrf=document.querySelector('meta[name="_csrf"]').getAttribute('content');
// Loop through each form
forms.forEach(function(form) {
form.addEventListener("submit", function(event) {
// Prevent the default form submission
event.preventDefault();
// Get the action URL of the form
var urlToCall = form.action;
// params to post (Create FormData object)
var formData = new FormData(this);
// append csrf
formData.append("csrf", csrf);
// execute ajax call
$.ajax({
url: urlToCall,
type: "POST",
data: formData,
processData: false,
contentType: false,
async: true,
cache: false,
success: function(data)
{
alert(data)
return false;
},
error: function(xhr, ajaxOptions, thrownError){
alert("si è verificato un problema tecnico")
return false;
}
});
});
});
}
In questo modo il click sul pulsante non fa cambiare url al browser ed aprire la "action1.php", come dovrebbe avvenire con un FORM classico, ma viene fatta semplicemente una POST a quella url, senza cambiare pagina. Direi che oggi, un sito ben costruito, gestisce così tutte le chiamate POST: cambiare url, eleborare i dati e poi redirigere nuovamente sulla pagina precedente con appesi in get i parametri di risposta, o rispondere nella stessa pagina di atterraggio che riceve la POST .... si usava nel secolo scorso.
Dunque, abbiamo fatto la chiamata POST alla "action1.php" (o allo script previsto dal form che ho usato).
Nella action1.php c'è incluso il file config.php, e ricevendo una chiamata POST, viene eseguito il codice che abbiamo previsto e cioè
// se è una post: verifico sempre il token ...
if($_SERVER['REQUEST_METHOD']=='POST'){
// ... se non verificato blocco tutto
if($csrf->checkCSRF()!==true){
exit;
}
// ... se ok procedo e sarà il file chiamato dalla post a rispondere come previsto
}
Se il controllo non è superato la chiamata si blocca e non succede nulla. Tentativo di hacking bloccato!
Se invece il controllo è superato, la chiamata procede normalmente.
Come vedi, abbiamo implementato un sistema di prevenzione di attacchi di tipo Cross-Site Request Forgery, mettendo mano solo a file comuni, e già presenti, nel sito: la config.php, la header del sito, e un global.js.
Ed il tuo sito, che abbia una, cento o mille pagine, con uno, cento o mille form, avrà così implementato il token CSRF.
In aggiunta a questo token, potresti aggiungere anche un controllo sulla url chiamante, e cioè verificare che la chiamata avvenga dallo stesso dominio, ma questo lo vedremo in un altro altricolo.
Puoi scaricare il sorgente da GitHub. Il link subito sotto.
Bene, spero di esserti stato utile, molto più di ChatGPT o Copilot :-)
STAY TUNED!