ARTICOLO

Un audit gratuito è un server che scarica gli URL degli sconosciuti: come l'ho costruito in Laravel

LaravelsecuritySaaS

Per LocalPro AI, il servizio di marketing locale che seguo per ristoranti e pizzerie, volevo il classico strumento che porta contatti: inserisci l'indirizzo del sito, in pochi secondi hai un punteggio su 100 e l'elenco di cosa sistemare, e se lasci la mail ti arriva il report completo. La parte che si vede l'ho scritta in un pomeriggio. Il resto, cioè tutto quello che serve perché un modulo pubblico che scarica URL scelti da sconosciuti non diventi un problema, ha preso molto di più. Racconto quel resto, perché è la parte che vale per qualunque lead magnet.

Cosa misura, e cosa ho scelto di non misurare

L'audit scarica la home del sito indicato e fa nove controlli, con un peso fisso:

public const WEIGHTS = [
    'reachable'       => 25, // il sito si apre
    'https'           => 15,
    'speed'           => 10, // risposta entro 1,5 s pieno, entro 3 s metà
    'mobile'          => 10, // meta viewport con width=device-width
    'meta'            => 10, // title 10-70 caratteri, description 50-170
    'structured_data' => 10, // JSON-LD o microdata di tipo LocalBusiness
    'nap'             => 10, // nome, indirizzo, telefono leggibili
    'maps'            => 5,  // link o mappa verso la scheda Google
    'crawl'           => 5,  // robots.txt e sitemap
];

Il punteggio è la somma, con un tetto a 100. Niente media pesata misteriosa: chi legge il report deve poter capire da solo perché ha preso 62.

La cosa che non fa è più interessante di quelle che fa. Non interroga Google: niente Places API, niente scraping dei risultati di ricerca, che i termini di Google vietano. Della scheda Google controllo solo se il sito la collega. È un limite consapevole: un audit che parte violando i termini del fornitore più importante del tuo cliente non è un buon biglietto da visita.

C'è anche un pezzo di AI, ed è spento di default. Quando è acceso riscrive soltanto i consigli della mail in un italiano meno da modulo, e nel prompt vanno il settore, il punteggio e i consigli: mai il nome dell'attività, la mail o l'URL. Ha un tetto di 100 chiamate al giorno oltre al budget AI generale, e se fallisce o finisce il tetto si usano i testi fissi. Il report funziona identico senza.

Il problema vero: un server che scarica quello che gli dici

Un modulo che accetta un URL e lo va a scaricare è, per definizione, una richiesta HTTP fatta dal mio server per conto di chiunque. Se non stai attento, quel chiunque ti chiede http://169.254.169.254/ (il servizio metadata del cloud), http://localhost:6379 o l'IP del database nella rete interna. Si chiama SSRF, e un lead magnet è il posto perfetto per introdurla senza accorgersene.

Il fetcher che ho scritto, SafeHttpFetcher, fa queste cose, nell'ordine:

Il pezzo centrale sono le opzioni passate a curl tramite il client HTTP di Laravel:

Http::withHeaders([
    'Accept-Encoding' => 'identity',
])->withOptions([
    'allow_redirects' => false,
    'decode_content'  => false,
    'timeout'         => $timeout,
    'curl' => [
        CURLOPT_RESOLVE   => ["{$host}:{$port}:{$pinned}"],
        CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
        CURLOPT_PROXY     => '',
        CURLOPT_MAXFILESIZE => $maxBytes,
        CURLOPT_NOPROGRESS  => false,
        CURLOPT_PROGRESSFUNCTION =>
            fn ($ch, $dlTotal, $dlNow) => $dlNow > $maxBytes ? 1 : 0,
    ],
])->get($url);

Tre righe meritano una parola.

CURLOPT_RESOLVE è la difesa contro il DNS rebinding: il dominio dell'attaccante risponde con un IP pubblico quando lo controllo e con 127.0.0.1 mezzo secondo dopo, quando curl si connette. Se l'IP lo decido io, il secondo lookup non c'è.

CURLOPT_PROXY => '' sembra inutile finché non scopri che curl rispetta le variabili d'ambiente HTTP_PROXY e HTTPS_PROXY. Con un proxy di mezzo è il proxy a risolvere l'host, e il pin salta. La stringa vuota lo spegne anche se qualcuno un giorno imposta quelle variabili nel container.

decode_content => false insieme a Accept-Encoding: identity: altrimenti Guzzle chiede a curl di decomprimere senza limiti, e una risposta gzip da pochi KB può diventare qualche GB in memoria. La decompressione la faccio io, con lo stesso tetto di dimensione. La funzione di progresso interrompe il download appena lo supera, anche quando il server non dichiara il Content-Length.

Ultimo dettaglio, scoperto scrivendo i test: il timeout vale per tutta la catena di redirect, non per il singolo salto. Altrimenti un sito lento con tre redirect tiene occupato un worker per quattro volte il tempo previsto. Sopra c'è un budget di tempo per l'audit intero, home più robots.txt e sitemap.

Rate limit: per IP, e per indirizzo

Ogni analisi scarica un sito di terzi, e ogni report manda una mail. Sono due risorse diverse, e i limiti sono separati: le analisi sono limitate per IP, al minuto e al giorno; i report per IP e per indirizzo email, e l'indirizzo entra nella chiave del limiter solo come hash SHA-256.

Il limite per indirizzo è quello che conta: chi cambia IP non può usare il modulo per riempire di mail la casella di qualcun altro. L'indirizzo finisce nella chiave del rate limiter solo come hash, quindi Redis non tiene una lista di mail in chiaro. Captcha non ce ne sono: per un modulo rivolto a un pizzaiolo che lo apre dal telefono fra un servizio e l'altro, preferisco un honeypot (un campo nascosto che si chiama fax_aziendale, che nessun umano compila) e limiti stretti.

Una pagina in cache non può avere un token CSRF

Questo è il compromesso su cui ho pensato più a lungo. La pagina del modulo è servita dalla cache pubblica, come il resto del sito marketing, perché deve essere veloce. Ma una pagina in cache è uguale per tutti, e quindi non può contenere un token CSRF di sessione. Le due POST dell'audit sono quindi esenti dal controllo CSRF in bootstrap/app.php.

Ho accettato l'esenzione perché ho guardato cosa può fare un sito terzo che costringe il tuo browser a inviare quel modulo: avviare un'analisi (limitata per IP) o far mandare un report a un indirizzo (limitato per indirizzo). Nessuna delle due agisce su un account, perché account non ce ne sono. I limiti fanno il lavoro che di solito fa il token. Se un giorno l'audit dovesse toccare dati di un utente loggato, quella scelta andrebbe rifatta da zero.

Le pagine con il risultato, invece, escono con Cache-Control: no-store, private e X-Robots-Tag: noindex, nofollow: il punteggio della pizzeria di qualcuno non deve finire né in una cache condivisa né su Google.

Il lead nasce solo quando c'è il consenso

L'analisi non crea niente nel database. Il risultato resta in cache per 60 minuti sotto un token casuale di 40 caratteri, e il lead viene scritto solo quando la persona chiede il report, dentro una transazione insieme alle prove di consenso. Dopo, il token viene cancellato: un secondo invio dello stesso modulo non crea doppioni e non manda una seconda mail.

Sui consensi:

L'audit sta dietro un interruttore, spento di default: da spento modulo e analisi rispondono 404 e la pagina sparisce da sitemap e informativa. Le route di disiscrizione invece restano fuori dall'interruttore, apposta. Chi ha ricevuto una mail deve potersi disiscrivere anche il giorno in cui decido di spegnere tutto.

I test

Sono 73 test Pest in sette file, e più della metà non riguarda il punteggio. Coprono redirect verso IP privati, host che risolvono su più IP di cui uno interno, risposte compresse oltre il limite, catene di redirect lente, il token che scade, il secondo invio dello stesso modulo, il consenso dato con la GET che non deve valere. Il calcolo dei punti è la parte facile da testare e quella che si rompe meno.

Il punto, per chi ne costruisce uno

Un lead magnet sembra marketing, ma dal lato del server è un endpoint pubblico, anonimo, che fa richieste di rete e manda mail a indirizzi forniti da chiunque. Trattarlo come una funzione secondaria è il modo più rapido per avere il primo incidente di sicurezza proprio sulla pagina che dovrebbe portare clienti. La regola che mi sono dato: tutto quello che il modulo fa per conto del visitatore deve avere un limite, e ogni limite deve avere un test.

Se hai una pizzeria o un ristorante e vuoi capire come si presenta online, trovi tutto su localproai.it. Se invece ti serve uno strumento così per il tuo prodotto, o vuoi che qualcuno guardi quello che hai già, qui trovi come lavoro. Di sicurezza in Laravel ho scritto anche a proposito del path traversal di Storage::path().

← Tutti gli articoli