Vai al contenuto

I form: GET, POST e validazione

In questa lezione impari a

  • inviare dati a PHP con form GET e POST e scegliere il metodo giusto
  • leggere e pulire i dati con $_GET, $_POST e trim
  • validare i dati lato server e mostrare gli errori accanto ai campi
  • proteggere le pagine dal Cross-Site Scripting (XSS) con htmlspecialchars
  • evitare il doppio invio con lo schema Post/Redirect/Get

Finora le pagine mostravano dati scritti da noi. Con i form è l'utente a mandare dati al server: una ricerca, un nuovo libro, un login. È il momento in cui una pagina diventa davvero un'applicazione, e anche il momento in cui bisogna iniziare a pensare alla sicurezza.

GET e POST

<form action="cerca.php" method="get">
  <label>Titolo <input type="text" name="q"></label>
  <button>Cerca</button>
</form>

Quando si preme il pulsante, il browser chiama cerca.php e gli manda i campi. Ogni campo è identificato dal suo name: in PHP lo ritroviamo con quel nome.

method="get" method="post"
Dove viaggiano i dati nell'indirizzo: cerca.php?q=robot nel corpo della richiesta, non si vedono nell'indirizzo
In PHP $_GET['q'] $_POST['q']
Si possono salvare nei preferiti o condividere sì no
Se si ricarica la pagina si ripete la richiesta, senza problemi il browser chiede se re-inviare i dati
Quando si usa leggere: ricerche, filtri, pagine numerate modificare: inserire, modificare, cancellare, login

La regola: GET per chiedere, POST per cambiare. Un link che cancella un libro (elimina.php?id=5) è un errore grave: basta che un motore di ricerca, o un'anteprima del link in una chat, lo "visiti" e il libro sparisce.

Anche i link possono passare parametri GET: <a href="libro.php?id=7">1984</a>. È così che, dalla lezione 12, la pagina di dettaglio saprà quale libro mostrare.

I parametri nell'indirizzo, scritti a mano

Una richiesta GET non ha bisogno di un form: basta scrivere l'indirizzo nella barra del browser. È il modo più veloce per provare una pagina mentre la si scrive. Crea prova.php, che mostra tutto quello che arriva in $_GET:

<?php
echo '<pre>';
print_r($_GET);
echo '</pre>';

e prova questi indirizzi:

Scrivi nella barra degli indirizzi $_GET contiene
localhost/prova.php niente: Array ( )
localhost/prova.php?q=robot [q] => robot
localhost/prova.php?q=1984&genere=Fantascienza [q] => 1984, [genere] => Fantascienza
localhost/prova.php?q= [q] => (la chiave c'è, ma il valore è vuoto)
localhost/prova.php?q=io robot [q] => io robot (il browser trasforma lo spazio in %20)
localhost/prova.php?q=A%26B [q] => A&B

Le regole sono poche:

  • ? separa il nome della pagina dai parametri; c'è una sola volta;
  • ogni parametro è nome=valore: il nome è quello che poi si legge con $_GET['nome'];
  • & separa un parametro dall'altro (l'ordine non conta);
  • gli spazi e i caratteri speciali nel valore vengono codificati: lo spazio diventa %20 (o + quando l'indirizzo lo costruisce un form), la & diventa %26, l'apice ' diventa %27. Il browser lo fa da solo e PHP li decodifica: in $_GET arrivano già come testo normale.

Prova anche a inviare il form di ricerca e a guardare la barra degli indirizzi: è esattamente un indirizzo come questi. Il form non fa altro che costruirlo al posto tuo.

Due conseguenze importanti. Primo: chiunque può scrivere nell'indirizzo qualsiasi cosa, anche valori che il form non permetterebbe mai (?id=-1, ?id=abc, ?pagina=99): la pagina deve controllarli sempre. Secondo: con POST non si può fare lo stesso dalla barra degli indirizzi, perché i dati viaggiano nel corpo della richiesta. Per questo le azioni che cambiano i dati vanno in POST: un indirizzo si può aprire per sbaglio, condividere o far aprire a qualcun altro.

Leggere i dati

Arriviamo alla forma giusta un passo alla volta, partendo da quella più semplice.

1. La forma diretta

Il valore del campo q sta in $_GET['q']. Lo si copia in una variabile normale:

<?php
$q = $_GET['q'];
echo "Hai cercato: $q";

Perché copiarlo in $q? Si scrive più in fretta di $_GET['q'], e soprattutto il valore si controlla e si "sistema" una volta sola, all'inizio: da lì in poi tutto lo script usa la variabile già pronta. Il nome è libero; di solito è lo stesso del campo, così si capisce subito da dove arriva.

Se si arriva dal form, funziona. Ma prova ad aprire cerca.php direttamente, senza passare dal form: nell'indirizzo non c'è ?q=…, quindi in $_GET la chiave q non esiste, e PHP avvisa:

Warning: Undefined array key "q" in C:\xampp\htdocs\cerca.php on line 2

Succede anche quando un campo non viene inviato (per esempio una casella non spuntata) o quando qualcuno modifica l'indirizzo a mano. Prima di leggere bisogna quindi controllare che il dato ci sia.

2. Controllare con isset

isset($variabile) restituisce true se la variabile (o la chiave dell'array) esiste e non è null:

<?php
if (isset($_GET['q'])) {
    $q = $_GET['q'];
} else {
    $q = '';            // nessuna ricerca: stringa vuota
}

È la forma che trovi su quasi tutti i libri. Funziona sempre, ma sono cinque righe per leggere un solo campo.

3. In una riga: l'operatore ternario

Lo stesso if … else si può scrivere in una riga con l'operatore ternario della lezione 7, condizione ? valore_se_vero : valore_se_falso:

<?php
$q = isset($_GET['q']) ? $_GET['q'] : '';

Si legge: "se $_GET['q'] esiste, prendi $_GET['q'], altrimenti prendi ''".

4. La scorciatoia: l'operatore ??

Il caso "se esiste prendi lui, altrimenti un valore predefinito" è così frequente che da PHP 7 ha un operatore tutto suo, ?? (null coalescing, "coalescenza nulla"):

<?php
$q = $_GET['q'] ?? '';

Fa esattamente la stessa cosa del punto 3, e si legge "oppure, se manca": il valore inviato, oppure stringa vuota se non c'è. Da qui in avanti nel corso useremo sempre questa forma.

5. Ripulire il valore

Gli utenti inseriscono spesso degli spazi per sbaglio, prima o dopo il testo. trim li toglie:

<?php
$q = trim($_GET['q'] ?? '');

È la riga che troverai all'inizio di ogni pagina con un form: leggo il campo, se manca uso un valore predefinito, tolgo gli spazi.

Da non confondere. ?? non è un "oppure" logico come || o or, che restituiscono true o false: restituisce uno dei due valori. E non è nemmeno ?:, che sostituisce anche i valori che contano come falsi ('', 0, '0'): '0' ?? 'x' vale '0', mentre '0' ?: 'x' vale 'x'. Per i dati dei form si usa ??: un campo inviato vuoto esiste, e va controllato dopo, non sostituito.

Per sapere se il form è stato inviato con POST:

<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // il form è stato inviato: leggo e controllo i dati
}

Tutto ciò che arriva dal browser in $_GET e $_POST è una stringa (o manca del tutto), anche se il campo era type="number". E soprattutto: può contenere qualsiasi cosa. Il browser lo controlla l'utente: i controlli HTML (required, maxlength, type="email") si aggirano in dieci secondi con gli strumenti per sviluppatori. Servono per la comodità dell'utente; la sicurezza si fa sul server.

Il pericolo: Cross-Site Scripting (XSS)

Prova questa pagina di ricerca:

<?php
$q = trim($_GET['q'] ?? '');
?>
<form method="get">
  <input type="text" name="q" value="<?= $q ?>">
  <button>Cerca</button>
</form>
<p>Hai cercato: <?= $q ?></p>

Cerca robot: funziona. Ora cerca questo:

<script>alert('Ti ho preso!')</script>

Compare il messaggio: il testo inserito è diventato codice eseguito dal browser. Questo è il Cross-Site Scripting. Un attaccante può mandare a una vittima un link con dentro uno script che, invece di un innocuo alert, legge i cookie di sessione e li invia a lui: così entra nel sito al posto della vittima. Se il testo viene salvato nel database (il titolo di un libro, un commento), lo script parte per ogni visitatore della pagina.

La difesa: htmlspecialchars

La funzione htmlspecialchars trasforma i caratteri speciali dell'HTML nelle loro "entità", così il browser li mostra come testo invece di interpretarli:

Carattere Diventa
< &lt;
> &gt;
& &amp;
" &quot;
' &#039;

Siccome si usa in continuazione, conviene una funzione dal nome corto. Mettila nel tuo funzioni.php:

<?php
function h(?string $testo): string
{
    return htmlspecialchars($testo ?? '', ENT_QUOTES, 'UTF-8');
}
<input type="text" name="q" value="<?= h($q) ?>">
<p>Hai cercato: <?= h($q) ?></p>

Ora lo script compare come testo innocuo. La regola: ogni dato che non hai scritto tu (form, indirizzo, database) passa da h() prima di finire nell'HTML. Anche i dati del database: se qualcuno è riuscito a salvarci uno script, h() lo neutralizza al momento della stampa.

Validare: un form completo

Prima di arrivare al form completo, costruiamolo per gradi con un solo campo, il titolo di un libro.

1. Il form manda i dati a se stesso

Un <form method="post"> senza action invia i dati alla stessa pagina. In cima allo script PHP controlla se il form è stato inviato e, se sì, legge il campo:

<?php
require_once 'funzioni.php';     // contiene h()

$titolo = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $titolo = trim($_POST['titolo'] ?? '');
    echo '<p>Hai scritto: ' . h($titolo) . '</p>';
}
?>
<form method="post">
  <input name="titolo">
  <button>Salva</button>
</form>

La prima volta (pagina aperta con GET) non succede nulla; dopo l'invio compare il testo scritto. Ma si accetta qualunque cosa, anche un titolo vuoto.

2. Un controllo e un messaggio

<?php
require_once 'funzioni.php';

$titolo = '';
$errore = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $titolo = trim($_POST['titolo'] ?? '');
    if ($titolo === '') {
        $errore = 'Il titolo è obbligatorio.';
    } else {
        echo '<p>Libro salvato: ' . h($titolo) . '</p>';
    }
}
?>
<form method="post">
  <input name="titolo">
  <?php if ($errore): ?><span class="errore"><?= $errore ?></span><?php endif; ?>
  <button>Salva</button>
</form>

Ora un titolo vuoto viene rifiutato con un messaggio. Prova però a scrivere un titolo lunghissimo, aggiungere un controllo sulla lunghezza e sbagliare apposta: dopo l'errore il campo è di nuovo vuoto, e l'utente deve riscrivere tutto.

3. Il form "ricorda" quello che è stato scritto

Basta rimettere il valore nel campo, con h() perché è un dato dell'utente:

<input name="titolo" value="<?= h($titolo) ?>">

La prima volta $titolo è vuoto e il campo pure; dopo un errore il campo contiene quello che l'utente aveva scritto.

4. Tanti campi, tanti errori

Con più campi, invece di una variabile per ogni valore e per ogni errore si usano due array: $dati con i valori (chiave = nome del campo) ed $errori con i messaggi (chiave = campo sbagliato). Il form è valido se $errori è vuoto. È esattamente la struttura del form completo, qui sotto.

Il form completo

Ecco il form per proporre un nuovo libro alla biblioteca. Non salva ancora nulla (lo faremo con il database nella lezione 12), ma ha tutto il resto: legge i dati, li controlla, mostra gli errori accanto ai campi e ricorda i valori già scritti.

<?php
require_once 'funzioni.php';     // contiene h()

$generi = ['Romanzo', 'Racconti', 'Fantascienza', 'Avventura', 'Memorialistica'];
$dati = ['titolo' => '', 'anno' => '', 'genere' => '', 'copie' => '1'];
$errori = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // 1. leggo e pulisco
    foreach ($dati as $campo => $valore) {
        $dati[$campo] = trim($_POST[$campo] ?? '');
    }

    // 2. controllo
    if ($dati['titolo'] === '') {
        $errori['titolo'] = 'Il titolo è obbligatorio.';
    } elseif (mb_strlen($dati['titolo']) > 150) {
        $errori['titolo'] = 'Il titolo può avere al massimo 150 caratteri.';
    }

    $anno = filter_var($dati['anno'], FILTER_VALIDATE_INT);
    if ($dati['anno'] !== '' && ($anno === false || $anno < 1000 || $anno > (int) date('Y'))) {
        $errori['anno'] = 'Scrivi un anno tra 1000 e ' . date('Y') . '.';
    }

    if (!in_array($dati['genere'], $generi, true)) {
        $errori['genere'] = 'Scegli un genere dall\'elenco.';
    }

    $copie = filter_var($dati['copie'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 50]]);
    if ($copie === false) {
        $errori['copie'] = 'Le copie devono essere un numero da 1 a 50.';
    }

    // 3. se è tutto a posto, si salva (lezione 12) e si va via
    if (!$errori) {
        header('Location: libro_nuovo.php?ok=1');
        exit;
    }
}
?>
<!DOCTYPE html>
<html lang="it">
<head><meta charset="UTF-8"><title>Nuovo libro</title></head>
<body>
<h1>Nuovo libro</h1>

<?php if (isset($_GET['ok'])): ?>
  <p class="successo">Libro salvato!</p>
<?php endif; ?>

<form method="post" novalidate>
  <p>
    <label>Titolo<br><input name="titolo" value="<?= h($dati['titolo']) ?>" maxlength="150" required></label>
    <?php if (isset($errori['titolo'])): ?><br><span class="errore"><?= $errori['titolo'] ?></span><?php endif; ?>
  </p>
  <p>
    <label>Anno di pubblicazione<br><input name="anno" type="number" value="<?= h($dati['anno']) ?>"></label>
    <?php if (isset($errori['anno'])): ?><br><span class="errore"><?= $errori['anno'] ?></span><?php endif; ?>
  </p>
  <p>
    <label>Genere<br>
      <select name="genere">
        <option value="">— scegli —</option>
        <?php foreach ($generi as $g): ?>
          <option <?= $g === $dati['genere'] ? 'selected' : '' ?>><?= h($g) ?></option>
        <?php endforeach; ?>
      </select>
    </label>
    <?php if (isset($errori['genere'])): ?><br><span class="errore"><?= $errori['genere'] ?></span><?php endif; ?>
  </p>
  <p>
    <label>Copie<br><input name="copie" type="number" min="1" max="50" value="<?= h($dati['copie']) ?>"></label>
    <?php if (isset($errori['copie'])): ?><br><span class="errore"><?= $errori['copie'] ?></span><?php endif; ?>
  </p>
  <button>Salva</button>
</form>
</body>
</html>

Guardiamo i punti chiave:

  • I dati partono vuoti e, dopo l'invio, contengono quello che l'utente ha scritto: se c'è un errore il form si ripresenta già compilato e l'utente corregge solo il campo sbagliato. Nessuno ama riscrivere tutto.
  • filter_var(..., FILTER_VALIDATE_INT) restituisce il numero intero se la stringa lo è davvero, false altrimenti ('12abc' → false). Con min_range e max_range controlla anche i limiti.
  • Il genere è controllato con in_array su un elenco: anche se qualcuno modifica il <select> con gli strumenti per sviluppatori, un genere inventato viene rifiutato. È la lista bianca (whitelist): si accetta solo ciò che è previsto.
  • I messaggi d'errore li scriviamo noi, quindi non serve h(); i valori dell'utente invece passano tutti da h().
  • L'attributo novalidate disattiva i controlli del browser: lo mettiamo solo per provare quelli del server. Nel sito vero si toglie, così l'utente ha entrambi.

Post/Redirect/Get

Dopo un POST andato a buon fine, la pagina non mostra direttamente il risultato: manda il browser a un'altra pagina (o alla stessa) con header('Location: ...'), seguito sempre da exit.

  1. Post: il browser invia il form;
  2. Redirect: PHP salva i dati e risponde "vai a libro_nuovo.php?ok=1";
  3. Get: il browser carica quella pagina con una normale richiesta GET.

Senza questo schema, se l'utente preme F5 dopo aver salvato, il browser re-invia il POST e il libro viene inserito due volte. Con il redirect, F5 ricarica solo la pagina di conferma.

header() funziona solo se la pagina non ha ancora mandato nulla al browser: nemmeno uno spazio prima di <?php. Per questo tutta la logica sta in cima al file, prima dell'HTML. Se vedi l'errore Cannot modify header information - headers already sent, cerca l'output (spesso un echo di prova o una riga vuota) che viene prima.

Esercizi

Esercizio 1 – Prova l'attacco. Crea la pagina di ricerca senza h() e prova questi testi. Poi aggiungi h() e riprova.

  1. <b>grassetto</b>
  2. <script>alert(1)</script>
  3. " autofocus onfocus="alert(2) (attenzione agli apici)

Perché il terzo funziona anche se non contiene <script>?

Mostra la soluzione

Senza h():

  1. il testo compare in grassetto: il browser ha interpretato il tag;
  2. compare l'alert;
  3. compare l'alert appena la pagina si carica. Il primo " chiude l'attributo value dell'input, e il resto aggiunge due attributi nuovi: autofocus mette il cursore nel campo e onfocus esegue codice quando il campo riceve il cursore. L'HTML diventa <input value="" autofocus onfocus="alert(2)">.

Con h() tutti e tre vengono mostrati come testo. Il terzo caso spiega perché in h() c'è ENT_QUOTES: trasforma anche gli apici, che sono pericolosi dentro gli attributi. Bloccare la parola "script" non basterebbe: i modi di eseguire codice sono tantissimi. Per questo la difesa giusta è trasformare tutto al momento della stampa.

Esercizio 2 – Iscrizione alla biblioteca. Crea iscrizione.php con un form POST per iscrivere uno studente: nome, cognome, classe, email. Regole:

  • nome e cognome obbligatori, al massimo 50 caratteri;
  • la classe è un numero da 1 a 5 seguito da una lettera maiuscola (es. 4A);
  • l'email deve essere valida (usa filter_var con FILTER_VALIDATE_EMAIL) e finire con @scuola.it.

In caso di successo, reindirizza alla stessa pagina con ?ok=1 e mostra un messaggio.

Mostra la soluzione

La parte di controllo (l'HTML è come quello del libro, con un campo per ogni dato):

<?php
require_once 'funzioni.php';

$dati = ['nome' => '', 'cognome' => '', 'classe' => '', 'email' => ''];
$errori = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    foreach ($dati as $campo => $valore) {
        $dati[$campo] = trim($_POST[$campo] ?? '');
    }
    $dati['classe'] = strtoupper($dati['classe']);

    foreach (['nome', 'cognome'] as $campo) {
        if ($dati[$campo] === '' || mb_strlen($dati[$campo]) > 50) {
            $errori[$campo] = 'Obbligatorio, al massimo 50 caratteri.';
        }
    }

    if (!preg_match('/^[1-5][A-Z]$/', $dati['classe'])) {
        $errori['classe'] = 'Scrivi la classe così: 4A.';
    }

    if (!filter_var($dati['email'], FILTER_VALIDATE_EMAIL)
        || !str_ends_with($dati['email'], '@scuola.it')) {
        $errori['email'] = 'Serve un indirizzo valido @scuola.it.';
    }

    if (!$errori) {
        header('Location: iscrizione.php?ok=1');
        exit;
    }
}
  • strtoupper sulla classe accetta anche 4a, che viene corretto in 4A: è gentile con l'utente.
  • preg_match controlla che la stringa segua uno schema (una espressione regolare): ^ inizio, [1-5] una cifra da 1 a 5, [A-Z] una lettera maiuscola, $ fine.
  • str_ends_with è di PHP 8.

Esercizio 3 – GET o POST? Per ciascuna azione indica il metodo giusto e spiega perché.

  1. Filtrare il catalogo per genere.
  2. Registrare la restituzione di un libro.
  3. Passare alla pagina 3 dei risultati.
  4. Entrare con username e password.
  5. Mostrare la scheda del libro con id 7.
Mostra la soluzione
  1. GET: è una lettura; l'indirizzo index.php?genere=Romanzo si può salvare e condividere.
  2. POST: modifica il database. Con un link GET basterebbe un clic involontario (o un'anteprima automatica) per registrare restituzioni mai avvenute.
  3. GET: ?pagina=3, è una lettura.
  4. POST: la password non deve finire nell'indirizzo, che resta nella cronologia del browser e nei registri del server.
  5. GET: libro.php?id=7, è una lettura.