Cosa è andato storto davvero
Supabase espone le tue tabelle Postgres tramite una REST API generata automaticamente. Il tuo frontend parla con quella API usando la anon key (chiamata anche publishable key). Quella chiave è pensata per essere pubblica: finisce nel tuo bundle JavaScript e chiunque può leggerla in DevTools.
Questo design va bene, a patto che ogni tabella abbia Row Level Security (RLS) attivata con policy sensate. È RLS a decidere quali righe può vedere una determinata richiesta. Come osserva TheCyberSecGuru, citando la documentazione stessa di Supabase: "Senza RLS, chiunque abbia la tua anon key può accedere a tutti i dati."
La catena di errori, quindi, è questa:
- Uno strumento AI (o un essere umano stanco) genera create table profiles (...).
- Nessuno aggiunge enable row level security.
- In fase di test l'app funziona perfettamente, perché il bug sta proprio nel fatto che è troppo permissiva.
- Chiunque copi la anon key dal tuo bundle può eseguire select * su quella tabella.
Se vuoi capire meglio come funziona RLS, la nostra guida su Row Level Security di Supabase spiega le basi. Qui sotto ci concentriamo su test e correzione.
Come controllare la tua app (5 minuti)
1. Guardala dall'esterno, come hanno fatto i ricercatori. Apri il tuo sito in produzione, apri DevTools e cerca supabase.co nel JavaScript caricato. Troverai l'URL del progetto e, accanto, la anon key. Poi, da un terminale, chiedi una riga di una tabella che consideri privata:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*&limit=1" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY"Cosa ti dice la risposta:
- Un array vuoto [] o un errore di permessi: bene, gli utenti anonimi non possono leggere quella tabella.
- Righe reali: hai lo stesso problema di quei 7.403 siti.
Ripeti per ogni tabella che contiene dati degli utenti: profiles, users, messages, orders, bookings, leads, waitlist e così via. Le tabelle di waitlist e dei moduli di contatto sono facili da dimenticare e contengono quasi sempre indirizzi email.
Già che sei nel bundle, verifica anche che la chiave trovata sia la anon key e non la chiave service_role. Il ruolo service bypassa completamente RLS. Se è nel tuo frontend, considerala compromessa e ruotala subito: tutto ciò che sta nel bundle è leggibile da ogni visitatore e da ogni script di terze parti sulla pagina, come hanno scoperto gli utenti di Polymarket, e i bot stanno già dando la caccia alle chiavi esposte. Il nostro articolo su come ruotare i secret della tua app senza downtime spiega la rotazione, e chiavi API esposte nel JavaScript lato client racconta cos'altro tende a finire nei bundle.
2. Controlla dall'interno. Nell'editor SQL di Supabase, elenca tutte le tabelle dello schema public e se hanno RLS attivo:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;Ogni riga con rowsecurity = false è leggibile (e forse scrivibile) con la anon key. Anche il Security Advisor nella dashboard di Supabase le segnala: leggi i suoi avvisi invece di ignorarli.
3. Cerca i casi "RLS attivo, ma comunque aperto". Attivare RLS non è il traguardo. Elenca le tue policy:
select tablename, policyname, roles, cmd, qual
from pg_policies
where schemaname = 'public';Fai attenzione a qual = true su una policy di select che si applica ad anon o public. È una policy che dice "tutti possono leggere tutto", cioè l'equivalente di non avere RLS. A volte gli strumenti AI generano proprio questo per far sparire un errore.
Come correggerlo
Attiva RLS su ogni tabella:
alter table public.profiles enable row level security;
alter table public.messages enable row level security;
-- repeat for every table in publicCon RLS attivo e nessuna policy, la tabella è bloccata: la anon key non riceve nulla. È un default sicuro. Ora aggiungi solo gli accessi di cui hai davvero bisogno.
Scrivi policy ristrette. Una configurazione tipica in cui "gli utenti possono vedere e modificare solo la propria riga":
create policy "Read own profile"
on public.profiles for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Update own profile"
on public.profiles for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);Cose da tenere a mente:
- Limita le policy ad authenticated, non a public, a meno che i dati non siano davvero pubblici (articoli del blog, schede prodotto).
- Separa lettura e scrittura. Una tabella può essere leggibile pubblicamente in sicurezza e avere comunque bisogno di insert bloccati.
- Le tabelle pubbliche espongono comunque le colonne. Se una tabella profiles è pubblica per mostrare i nomi visualizzati, anche una colonna email o telefono nella stessa tabella è pubblica. Sposta i campi privati in una tabella separata o esponi una view con solo le colonne sicure.
- Le view possono bypassare RLS. In Postgres le view vengono eseguite di default con i privilegi del proprietario. Crea le view con security_invoker = true, così si applica l'RLS di chi esegue la query.
- Lo storage è un discorso a parte. I bucket pubblici servono i file a chiunque abbia l'URL. Documenti d'identità caricati, fatture e avatar di utenti privati vanno in bucket privati con storage policy.
Rendilo il comportamento predefinito, non un lavoro di pulizia. Il campione dei ricercatori copre dieci mesi di app nuove, il che significa che il problema si ripresenta a ogni nuova tabella. Rendi difficile dimenticarsene:
- Aggiungi una regola alle istruzioni di progetto del tuo strumento AI: ogni create table in public deve essere seguito da enable row level security e da policy esplicite.
- Prima del merge, controlla nelle migration generate che non ci sia un create table senza il corrispondente enable row level security.
- Ripeti il controllo dall'esterno dopo ogni modifica dello schema, non solo al lancio.
Se usi Firebase invece di Supabase, lo stesso tipo di errore esiste anche lì sotto forma di security rules aperte. Leggi Firebase Security Rules: smetti di lasciare il database aperto.
Se hai trovato dati esposti
Correggere la policy blocca le letture future, ma non annulla quelle passate. Sii onesto con te stesso su cosa era raggiungibile e per quanto tempo. Controlla i log delle API di Supabase per letture massive insolite da IP sconosciuti, ruota tutti i secret salvati in tabelle leggibili e verifica i tuoi obblighi di notifica se sono stati esposti dati personali come email, documenti d'identità o dati di pagamento. Nell'UE, con ogni probabilità si applicano le regole del GDPR sui data breach.
Checklist rapida
Prima di considerarlo chiuso, assicurati che:
- Ogni tabella in public abbia rowsecurity = true.
- Nessuna policy di select usi using (true) su tabelle con dati privati.
- Le policy siano limitate ad authenticated e legate ad auth.uid().
- Le colonne private non stiano in tabelle leggibili pubblicamente.
- Le view usino security_invoker = true.
- Nel frontend ci sia solo la anon key, mai service_role.
- I file privati stiano in bucket di storage privati.
- Tu ripeta il test dall'esterno dopo ogni modifica dello schema.
Controlla la tua app dall'esterno
La nostra checklist di sicurezza per le app create con l'AI copre le altre cose da verificare prima del lancio. La parte scomoda di questa ricerca è quanto fosse banale il metodo: chiave pubblica, richiesta in sola lettura, i dati tornano indietro. È esattamente la prospettiva di un attaccante, ed è quella che dovresti controllare regolarmente.
AppSafe analizza la tua app in produzione dall'esterno. La scansione completa verificata include controlli in sola lettura su database Supabase e Firebase aperti e cerca chiavi API esposte nei tuoi bundle JavaScript, mentre le scansioni pianificate possono avvisarti quando qualcosa cambia dopo un deploy. Vuoi vedere com'è fatto un report? Dai un'occhiata al report di esempio.
Avvia ora una scansione gratuita della tua app e scopri cosa può vedere uno sconosciuto prima che lo faccia qualcun altro.
