· 6 min · backend · PHP · sicurezza
Un modulo di contatto sicuro in PHP: validazione, rate limit e niente doppioni
Come ho costruito il backend del modulo di contatto di questo sito su un hosting condiviso: controlli in ordine di costo, honeypot, rate limit su file senza salvare IP, idempotenza e chiavi fuori dalla webroot.
Il modulo di contatto sembra la parte più noiosa di un sito. Poi ci pensi un attimo e ti accorgi che è l'unico posto in cui chiunque su Internet può mandare dati al tuo server. Arrivano bot che spammano, script che lo bombardano di richieste, e persone normalissime che premono "Invia" tre volte perché la connessione è lenta.
Qui racconto come ho scritto il backend del modulo di questo sito. Il vincolo era chiaro fin dall'inizio: il frontend è React esportato in statico e caricato via FTP su un hosting condiviso Aruba. Quindi niente server Node e niente database, solo PHP e un servizio esterno per spedire le email (Resend).
L'architettura in una riga
Il browser invia un POST in JSON a /api/contact.php. Il PHP controlla tutto, poi chiama l'API di Resend, che recapita l'email. La logica sta in una funzione separata, così è testabile senza inviare email vere.
React (statico) → POST /api/contact.php → validazione + rate limit → API Resend → email
1. Prima i controlli che costano poco
L'ordine dei controlli conta. Quelli economici vanno per primi, così una richiesta malevola viene scartata prima di consumare CPU o, peggio, quota sul servizio email.
if (($server['REQUEST_METHOD'] ?? '') !== 'POST') return contact_result(405, 'method_not_allowed');
if (!in_array($server['HTTP_ORIGIN'] ?? '', $origins, true)) return contact_result(403, 'origin_not_allowed');
if ($contentType !== 'application/json') return contact_result(415, 'invalid_content_type');
if (strlen($raw) > 32768) return contact_result(413, 'payload_too_large');
$data = json_decode($raw, true, 16, JSON_THROW_ON_ERROR);
Alcuni dettagli che fanno la differenza:
- il corpo della richiesta viene letto con un limite:
file_get_contents('php://input', false, null, 0, 32769). Un byte oltre il massimo basta a capire che è troppo grande, senza caricare in memoria megabyte inutili; json_decodeha una profondità massima (16): un JSON annidato all'infinito non manda in crisi il parser;- il controllo su
Originnon è una protezione assoluta (uno script può falsificarlo), ma ferma gli abusi più banali fatti da altri siti nel browser.
2. Validare come se ogni campo fosse ostile
Ogni campo atteso deve esistere ed essere una stringa. Poi si validano contenuto e lunghezza:
$name = trim($data['name']);
if (contact_length($name) < 2 || contact_length($name) > 100
|| preg_match('/[\r\n\x00-\x1f]/', $name)) return contact_result(422, 'invalid_fields');
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || preg_match('/[\r\n]/', $email)) { /* ... */ }
Tre scelte non ovvie:
- lunghezze in caratteri, non in byte.
strlen("città")restituisce 6, non 5. Per contare i caratteri usopreg_match_all('/./us', $value), che rispetta l'UTF-8; - niente a capo nel nome e nell'email. Questi valori finiscono nelle intestazioni dell'email (per esempio
reply_to). Un\r\nnascosto è il classico tentativo di header injection; - valori ammessi in una whitelist. L'argomento della richiesta deve essere una delle chiavi previste, la lingua
itoen. Tutto il resto viene rifiutato con un422.
3. L'honeypot: una trappola invisibile per i bot
Nel modulo c'è un campo website nascosto agli utenti (fuori schermo, tabIndex=-1, aria-hidden). Le persone non lo vedono e lo lasciano vuoto; molti bot compilano tutti i campi che trovano.
if ($data['website'] !== '') return contact_result(422, 'invalid_fields');
Costa zero, non disturba nessuno e non richiede CAPTCHA che rovinano l'esperienza.
4. Rate limit su file, senza salvare gli IP
Senza database, il contatore delle richieste vive in piccoli file JSON in una cartella privata. Il problema delle richieste simultanee si risolve con un lock esclusivo:
$handle = fopen($directory . '/' . $key . '.json', 'c+');
flock($handle, LOCK_EX);
$state = json_decode(stream_get_contents($handle) ?: '{}', true);
if ($state['start'] + $window <= time()) $state = ['start' => time(), 'count' => 0];
if ($state['count'] >= $limit) return false; // troppe richieste
$state['count']++;
// riscrive il file, poi flock($handle, LOCK_UN)
I limiti sono due: uno globale (protegge la quota del servizio email anche da un attacco distribuito) e uno per visitatore, per esempio 5 invii ogni 15 minuti.
Per riconoscere il visitatore serve l'indirizzo IP, ma salvarlo in chiaro su disco significa conservare un dato personale. La soluzione è usare come nome del file un HMAC dell'IP:
$ipKey = hash_hmac('sha256', $server['REMOTE_ADDR'] ?? 'unknown', $secret);
Lo stesso IP produce sempre lo stesso nome, quindi il limite funziona; ma dal file non si risale all'indirizzo. Notare anche che uso solo REMOTE_ADDR: intestazioni come X-Forwarded-For le può inventare chiunque.
Quando il limite scatta, la risposta è un 429 con l'intestazione Retry-After, e il frontend mostra un messaggio chiaro invece di un errore generico.
5. Niente email doppie: l'idempotenza
Scenario reale: l'utente invia, la rete è lenta, la risposta non arriva, l'utente riprova. Il messaggio era già partito? Senza precauzioni, il destinatario riceve due email identiche.
Il frontend genera un identificativo unico (crypto.randomUUID()) per ogni contenuto: se l'utente riprova con lo stesso testo, l'identificativo resta lo stesso; se cambia il messaggio, ne nasce uno nuovo. Il backend lo valida (deve essere un UUID v4) e lo passa al servizio email come chiave di idempotenza:
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Content-Type: application/json',
'Idempotency-Key: contact/' . $requestId,
],
Il servizio riconosce la seconda richiesta con la stessa chiave e non invia un duplicato. Lato client, poi, un semplice flag impedisce invii paralleli mentre la prima richiesta è in corso.
6. Segreti fuori dalla portata del browser
Su un hosting condiviso non sempre si possono usare variabili d'ambiente, quindi il codice prova prima quella (getenv('RESEND_API_KEY')) e solo in alternativa un file di configurazione. Quel file e la libreria stanno in una cartella .private protetta da un .htaccess di una riga:
Require all denied
L'endpoint pubblico si limita a includerli dal filesystem. Anche se qualcuno prova ad aprire /api/.private/contact-config.php dal browser, il server risponde con un rifiuto.
7. Errori chiari per l'utente, muti per l'attaccante
Ogni situazione ha il suo codice HTTP (405, 403, 413, 415, 422, 429, 502, 503) e una risposta JSON minima, del tipo {"ok": false, "code": "rate_limited"}. Il frontend traduce il codice in un messaggio comprensibile, in italiano o in inglese.
Quello che non esce mai: dettagli dell'errore del provider, contenuto del messaggio, chiavi. Nel log finisce solo una riga generica. In più, display_errors è disattivato e ogni eccezione imprevista diventa un 503.
8. Testare senza mandare email
La funzione principale riceve il "trasporto" (la funzione che parla con Resend) come parametro:
function contact_handle(array $server, string $raw, array $config, callable $transport): array
In produzione le passo la funzione che usa cURL; nei test una funzione finta che registra la chiamata e restituisce una risposta simulata. Così posso verificare validazione, limiti e idempotenza senza inviare una sola email reale. Lato frontend, i test end-to-end intercettano la chiamata a /api/contact.php e controllano i casi di successo, errore, rete assente e rate limit.
La checklist
- Controlli dal più economico al più costoso: metodo, origine, tipo, dimensione, JSON.
- Lunghezze in caratteri UTF-8, niente caratteri di controllo nei campi che finiscono nelle intestazioni.
- Whitelist per i valori a scelta.
- Honeypot al posto del CAPTCHA, quando basta.
- Rate limit globale e per visitatore, con lock sul file e IP solo come HMAC.
- Chiave di idempotenza per evitare doppioni.
- Segreti fuori dalla webroot o protetti, mai nel codice del frontend.
- Risposte d'errore minime e logica testabile senza effetti collaterali.
Prese una per una sono tutte cose semplici, nessuna richiede più di qualche riga. La differenza la fanno messe insieme, il giorno in cui qualcuno prova davvero a mandare in crisi il modulo.
Lavoro allo stesso modo sui gestionali e i software aziendali, dove i dati da proteggere sono molti di più. Se hai un progetto del genere, scrivimi.