api-sicura
Skill4 starsChanged 10 days agoMITView on GitHub
Sicurezza delle API secondo la OWASP API Security Top 10 (2023), con REST, RPC e API generate come PostgREST/Supabase - accesso a oggetti altrui, campi privilegiati, limiti di consumo, funzioni riservate, flussi abusabili, inventario degli endpoint. Trigger - "API", "endpoint", "IDOR", "BOLA", "RPC", "rate limit", "OWASP API". EN - API security following the OWASP API Security Top 10 (2023) for REST, RPC and generated APIs like PostgREST/Supabase - object and property level authorization, resource limits, function level authorization, abusable business flows, endpoint inventory. Triggers - "API", "endpoint", "IDOR", "BOLA", "RPC", "rate limit", "OWASP API".
- Sends data out
We split this skill into the rules it gives an agent
9 rules. About 0 are common, 1 is distinctive, and 8 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
8
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 178
Test: an expired token, one with a changed signature character or one with "alg":"none" gets 401 from REST, RPC and Edge Functions.
- Very specific: line 189
Test: with your own token, PATCH /rest/v1/profiles?id=eq.<my id> with {"verified":true} fails with permission denied (42501).
- Very specific: line 194
Test: as a regular user POST /rest/v1/rpc/ban_user fails.
- Very specific: line 200
Test: GET /rest/v1/meetups?select=id&limit=100000 returns at most "Max rows".
- Very specific: line 206
Test: a script calling the RPC 50 times in a row gets rate_limited past the threshold.
- Distinctive: line 216
Test: the same insert sent twice creates a single row.
- Very specific: line 239
Test: a deliberate error (duplicate, wrong type) shows no table names, constraints or values.
- Very specific: line 244
Test: http://169.254.169.254/latest/meta-data/, http://localhost:54321/, http://[::1]/, a domain resolving to 127.0.0.1 and a URL that …
- Very specific: line 250
Test: a fake provider response with <img src=x onerror=alert(1)> in a place name shows up as text and a javascript: URL does not become a …
≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
API sicura · Secure APIs
🇮🇹 Versione italiana qui sotto · 🇬🇧 English version below
Un'API è tutto ciò che si chiama senza passare dall'interfaccia: con un token e curl, un controllo fatto solo nel componente React non esiste. Con Supabase ogni tabella, vista e funzione degli schemi esposti è un endpoint (/rest/v1/<tabella>, /rest/v1/rpc/<funzione>), e nomi e chiave pubblica sono nel bundle JavaScript. Le regole seguono l'OWASP API Security Top 10 2023 (codice API1-API10 nei titoli) e valgono anche per REST, GraphQL, Edge Functions e server propri. Basi di Supabase: skill supabase-sicuro; limiti anti-molestie e scraping: sicurezza-social; posizioni: privacy-posizione; login: autenticazione-sicura; client: frontend-spa; WAF su server propri: difesa-attacchi; dati già usciti: risposta-incidenti.
Regole obbligatorie
1. Inventario degli endpoint (API9 Improper Inventory Management)
- Tenere nella repo l'elenco di ciò che è raggiungibile: tabelle e viste esposte, RPC, Edge Functions, bucket, tabelle in Realtime, schemi esposti (impostazioni della Data API: solo
public), API di terzi chiamate, webhook ricevuti. Per ognuno: chi lo chiama (anon, authenticated, ruolo), cosa restituisce, quali limiti. La parte del database si genera così:
select c.relname, c.relkind, c.relrowsecurity as rls, -- tabelle e viste, permessi per colonna compresi
has_any_column_privilege('anon', c.oid, 'select,insert,update') or has_table_privilege('anon', c.oid, 'delete') as anon,
has_any_column_privilege('authenticated', c.oid, 'select,insert,update') or has_table_privilege('authenticated', c.oid, 'delete') as auth
from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind in ('r','p','v','m','f') order by 1;
select p.proname, pg_get_function_identity_arguments(p.oid) as args, p.prosecdef as definer, has_function_privilege('anon', p.oid, 'execute') as anon,
has_function_privilege('authenticated', p.oid, 'execute') as auth from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' order by 1; -- RPC- La CI rigenera l'elenco sul database locale (
supabase startcon le migrazioni) e fallisce se cambia senza che il file sia aggiornato; ogni riga conanonodefineratrueha un motivo scritto. Versioni vecchie (get_profile_v1), funzioni di debug, tabelle di prova ed Edge Functions inutilizzate si cancellano con una migrazione: i nomi non sono un segreto. Anteprime e ambienti di prova usano un progetto separato con dati finti, mai la produzione con regole più larghe.
2. Negare per default
- Tabella nuova: RLS attiva, nessuna policy finché non serve,
grantsolo delle azioni necessarie (supabase-sicuro, punto 2). Una policy per azione (for select,for insert…): unafor allvale anche perdelete. Edge Functions e server propri: accesso richiesto su ogni rotta; le rotte pubbliche sono un elenco esplicito, ognuna con i suoi limiti. - Funzioni: Postgres concede
executeapublicsu ogni funzione nuova e Supabase anche adanoneauthenticated, quindi ogni funzione inpublicsi chiama come RPC. Togliere il permesso per default e darlo una funzione alla volta congrant execute on function … to authenticated, anche alle funzioni usate nelle policy, che girano come chi chiama. Quelle esistenti si correggono conrevoke execute on function … from public, anon, authenticated; l'inventario del punto 1 conferma il risultato. Helper delle policy, funzioni dei trigger e funzioni interne vanno nello schemaprivate, non esposto.
alter default privileges for role postgres revoke execute on functions from public;
alter default privileges for role postgres in schema public revoke execute on functions from anon, authenticated;3. Autorizzazione per oggetto (API1 Broken Object Level Authorization)
- Cosa è: l'API riceve un id e legge o modifica l'oggetto senza chiedersi se chi chiama ne ha diritto (
/messages?chat_id=eq.…con la chat di un altro). È la falla più comune; gli uuid casuali non la chiudono, perché finiscono in link, URL e risposte. - In PostgREST la difesa è la RLS: ogni policy lega la riga a
auth.uid()(proprietario, partecipante, amico), mai a un id mandato dal client. Le viste saltano la RLS se non sono createwith (security_invoker = true); le viste materializzate non hanno RLS ma PostgREST le serve se stanno in uno schema esposto: metterle inprivate(o togliereselectaanoneauthenticated) e leggerle da una RPC che filtra perauth.uid(). Le RPCsecurity definere le Edge Functions con la chiave secret ricontrollano l'appartenenza nel codice; le Edge Functions che agiscono per l'utente creano il client con il suo token, così la RLS resta valida. Prova con il token di un utente di prova B sugli oggetti di A (password da variabile d'ambiente, mai nel codice):
J=(-H "apikey: $SB_KEY" -H "Content-Type: application/json")
TOKEN_B=$(curl -s "$SB_URL/auth/v1/token?grant_type=password" "${J[@]}" -d "{\"email\":\"$EMAIL_B\",\"password\":\"$PW_B\"}" | jq -r .access_token)
H=("${J[@]}" -H "Authorization: Bearer $TOKEN_B")
curl -s "$SB_URL/rest/v1/messages?chat_id=eq.$CHAT_A&select=*" "${H[@]}" # atteso: []
curl -s -X PATCH "$SB_URL/rest/v1/meetups?id=eq.$MEETUP_A" "${H[@]}" -H "Prefer: return=representation" -d '{"title":"x"}' # atteso: []
curl -s -X POST "$SB_URL/rest/v1/rpc/get_chat" "${H[@]}" -d "{\"chat_id\":\"$CHAT_A\"}" # atteso: errore- Una modifica fermata dalla RLS non dà 403 ma zero righe: controllare il contenuto, non solo lo stato HTTP. Stesse prove su Storage (
/storage/v1/object/<bucket>/<percorso di A>) e sui canali Realtime; la variante in SQL, con transazione annullata, è in supabase-sicuro, punto 7.
4. Autenticazione (API2 Broken Authentication)
- Cosa è: token accettati senza verifica, scadenze lunghe, login senza limiti, identità presa dal corpo della richiesta.
- L'identità viene solo dal token verificato:
auth.uid()nel database; nelle Edge Functionssupabase.auth.getUser(token)o la firma controllata con le chiavi pubbliche (JWKS) del progetto. Mai un JWT solo decodificato, mai unuser_idletto dal body. Server propri: algoritmo fissato (mainone),exp,isseaudcontrollati. - Access token brevi: in Supabase la scadenza di default è un'ora, non alzarla. Gli accessi anonimi di Supabase hanno il ruolo
authenticated: le policy riservate agli account veri aggiungono(select (auth.jwt()->>'is_anonymous')::boolean) is false. Login, iscrizione, recupero e OTP con limiti e captcha (autenticazione-sicura; supabase-sicuro, punto 6); mai token in URL, log o analytics. - Prova: un token scaduto, con un carattere della firma cambiato o con
"alg":"none"riceve 401 da REST, RPC ed Edge Functions; iluser_iddi un altro nel body viene ignorato o rifiutato.
5. Autorizzazione per proprietà (API3 Broken Object Property Level Authorization)
- Cosa è: la riga è giusta, le colonne no. In lettura escono campi riservati (email, data di nascita, posizione precisa, orientamento,
is_admin); in scrittura il client imposta campi che non dovrebbe (verified,role,plan,owner_id, contatori: mass assignment). - La RLS filtra le righe, non le colonne: con
select=*PostgREST restituisce tutto ciò che il ruolo può leggere. Rimedio: permessi per colonna, o i dati privati in una tabella a parte leggibile solo dal proprietario.
revoke select, insert, update, delete on public.profiles from anon, authenticated; -- il profilo lo crea un trigger all'iscrizione
grant select (id, display_name, bio, avatar_path, city) on public.profiles to authenticated;
grant update (display_name, bio, avatar_path, city) on public.profiles to authenticated;- Con i permessi per colonna
select=*dà errore: il client elenca le colonne. Le RPC dichiaranoreturns table (…)con i soli campi utili, maisetof profiles; i campi con visibilità scelta dall'utente passano da una funzione che applica la scelta (privacy-posizione). In Realtime pubblicare solo tabelle senza colonne riservate. - Prova: con il proprio token,
PATCH /rest/v1/profiles?id=eq.<mio id>con{"verified":true}fallisce conpermission denied(42501);select=birthdatesu un altro profilo fallisce; un abbonato Realtime di prova non riceve colonne riservate.
6. Autorizzazione per funzione (API5 Broken Function Level Authorization)
- Cosa è: azioni da moderatore o amministratore chiamabili da chiunque conosca il nome (
/rpc/ban_user,/admin/export), o un ruolo scelto dal client. - Ogni RPC riservata ha
executesolo per chi serve (punto 2) e controlla il ruolo come prima istruzione:if not exists (select 1 from private.staff where user_id = auth.uid() and role = 'moderator') then raise exception 'forbidden' using errcode = '42501'; end if;. Il ruolo viene da una tabella nello schemaprivateo daapp_metadata, che solo il server scrive; mai dauser_metadata, che l'utente cambia da solo conauth.updateUser(). - Prova: come utente normale
POST /rest/v1/rpc/ban_userfallisce; con la sola chiave pubblica (anon) falliscono tutte le RPC non pubbliche. Ripetere per ogni RPC dell'inventario condefiner = truee per ogni metodo HTTP delle rotte di un server proprio.
7. Consumo di risorse (API4 Unrestricted Resource Consumption)
- Cosa è: richieste che costano troppo: pagine enormi, filtri lenti, file grandi, chiamate che fanno partire SMS, email, geocoding o AI pagati da voi.
- PostgREST: abbassare "Max rows" (impostazioni della Data API, default 1000) al massimo che l'app usa. Nelle RPC limitare sempre (
limit least(p_limit, 50)), paginare a cursore (where created_at < p_before), accettare sulla mappa solo aree di dimensione massima. - Timeout per ruolo (default Supabase: 3 s
anon, 8 sauthenticated), da abbassare se basta:alter role anon set statement_timeout = '2s'; notify pgrst, 'reload config';. Indici sulle colonne usate da policy e filtri: una policy lenta rallenta ogni richiesta. Conteggi esatti (Prefer: count=exact) solo su tabelle piccole. Storage confile_size_limiteallowed_mime_typessu ogni bucket. Le Edge Functions che chiamano servizi a pagamento hanno una quota per utente al giorno in tabella e un tetto di spesa presso il fornitore. - Prova:
GET /rest/v1/meetups?select=id&limit=100000restituisce al massimo "Max rows"; l'RPC conp_limit100000 ne restituisce 50.
8. Flussi sensibili e limiti per utente (API6 Unrestricted Access to Sensitive Business Flows)
- Cosa è: ogni passo è autorizzato, ma ripetuto da uno script diventa abuso: entrare in tutti i tavoli per vedere luoghi e partecipanti, occupare i posti di un evento, invitare migliaia di persone, recensioni false o a raffica, iscrizioni in massa, codici invito provati a forza.
- Elencare i flussi di valore e scrivere nel database le regole del flusso, non solo del record. Entrare in un tavolo: niente insert diretto su
meetup_members, solo un'RPC che controlla capienza, invito o approvazione, blocchi ed età, conunique (meetup_id, user_id). Recensioni: solo da chi ha partecipato, dopo la fine, una per coppia (unique (meetup_id, reviewer_id, reviewed_id)). - Limiti per utente e per azione con
private.rate_limit(sicurezza-social, punto 4), chiamato da trigger e RPC, più stretti per account nuovi; captcha davanti ai flussi più appetibili. Le RPC di lettura che registrano le chiamate (ricerca, mappa) devono esserevolatile: nellestablele scritture falliscono. - Prova: uno script che chiama l'RPC 50 volte di fila riceve
rate_limitedoltre la soglia; un insert diretto inmeetup_membersè rifiutato.
9. Idempotenza e replay
- Doppi tocchi e reti instabili ripetono le richieste: due iscrizioni, due messaggi, due pagamenti. Ogni azione che crea qualcosa porta un id generato dal client (
crypto.randomUUID()) con un vincolo unico; la ripetizione riceve 409 (violazione di unicità) e il client la tratta come riuscita. - Link e codici monouso (inviti, "condividi il piano", recupero): nel database solo l'hash, con scadenza, consumati in modo atomico dentro l'RPC che li accetta. Webhook in arrivo: firma, finestra di tempo e id degli eventi già elaborati (punto 13). Un access token rubato resta valido fino alla scadenza anche dopo il logout: per questo è breve.
alter table public.messages add column client_id uuid, add constraint messages_once unique (sender_id, client_id);
update public.invites set used_at = now() where token_hash = encode(sha256(convert_to(p_token, 'UTF8')), 'hex')
and used_at is null and expires_at > now() returning meetup_id; -- nessuna riga: link non valido, scaduto o già usato- Prova: lo stesso insert mandato due volte crea una sola riga; lo stesso link d'invito funziona una volta sola.
10. Validazione dell'input lato server
- Un client modificato manda qualsiasi cosa. Tipi, lunghezze, intervalli e valori ammessi si impongono nel database con vincoli e domini; nelle Edge Functions con uno schema (zod, valibot) che rifiuta i campi sconosciuti.
create domain public.lat as double precision check (value between -90 and 90);
alter table public.messages add constraint body_len check (char_length(body) between 1 and 2000);
alter table public.meetups add constraint seats_ok check (seats between 2 and 50), add constraint time_ok check (ends_at > starts_at),
add constraint visibility_ok check (visibility in ('friends', 'public', 'invite')), add constraint meta_size check (pg_column_size(meta) < 4096);- Le regole che dipendono dall'ora o da altre righe (evento nel futuro, al massimo un anno avanti, capienza) vanno in un trigger: un
checkdeve dare sempre lo stesso risultato. Anche gli array hanno un massimo (cardinality(tags) <= 10). Testo libero salvato così com'è e reso sicuro all'output (React lo fa; nientedangerouslySetInnerHTML); URL accettati solo conhttps:. Mai SQL composto con testo del client (supabase-sicuro, punto 3).
11. Configurazione, errori e CORS (API8 Security Misconfiguration)
- Supabase: schemi esposti solo
public;pg_graphqldisattivata se GraphQL non si usa; nessuna chiave secret nel frontend; bucket privati; Security Advisor senza avvisi (supabase-sicuro). Risposte con dati personali:Content-Type: application/json,X-Content-Type-Options: nosniff,Cache-Control: no-store; solo i metodi HTTP usati; nessun endpoint di debug o di stato che riveli versioni. - Errori: PostgREST inoltra messaggio, dettaglio e suggerimento di Postgres, cioè nomi di tabelle e vincoli e a volte valori (
Key (email)=(…) already exists). Nelle RPC e nei trigger rilanciare gli errori attesi con un codice stabile, senza dati e con lo stato HTTP giusto; l'interfaccia traduce i codici nella lingua dell'utente e non mostra maierror.messagegrezzo. Server propri: risposta generica con un id dell'errore, dettagli solo nei log (hardening-siti, punto 6).
raise sqlstate 'PGRST' using message = '{"code":"rate_limited","message":"Troppe richieste"}', detail = '{"status":429,"headers":{"Retry-After":"60"}}';- CORS non è un controllo di accesso: decide solo quali siti leggono le risposte nel browser, non ferma curl né gli script. Le API che si autenticano con un token nell'header, come quelle di Supabase, possono accettare ogni origine; con cookie di sessione servono un elenco esplicito di origini,
Access-Control-Allow-Credentials: truesolo con un'origine esatta, mai l'Originricopiato senza controllo, mainull, sempreVary: Origin. Nelle Edge Functions, su ogni risposta (OPTIONScompreso):
const ALLOWED = ['https://example.app'], origin = req.headers.get('Origin') ?? ''; // produzione e anteprime note
const cors = { Vary: 'Origin', 'Access-Control-Allow-Headers': 'authorization, apikey, content-type', 'Access-Control-Allow-Methods': 'POST, OPTIONS', ...(ALLOWED.includes(origin) ? { 'Access-Control-Allow-Origin': origin } : {}) };- Prova: un errore voluto (duplicato, tipo sbagliato) non mostra nomi di tabelle, vincoli o valori;
curl -i -H 'Origin: https://evil.example'verso la Edge Function non riceveAccess-Control-Allow-Origin.
12. SSRF (API7 Server Side Request Forgery)
- Cosa è: il server scarica un URL scelto dall'utente (anteprima di un link, avatar da URL, import di un calendario, webhook configurabili) e così raggiunge reti interne o i metadati del cloud (
169.254.169.254). Con Supabase succede nelle Edge Functions e conpg_net/httpnel database: mai passare a queste un URL dell'utente senza controlli. - Rimedio: meglio un elenco di host ammessi; altrimenti solo
https:sulla porta 443, nome risolto e rifiutato se punta a indirizzi privati, di loopback, link-local o riservati (127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,100.64.0.0/10,0.0.0.0/8,::1,::ffff:0:0/96,fc00::/7,fe80::/10), redirect manuali (redirect: 'manual') ricontrollati uno per uno, timeout breve, dimensione massima, tipo di contenuto atteso, mai la risposta grezza restituita all'utente. Un nome può cambiare indirizzo tra controllo e richiesta (DNS rebinding): nei casi delicati, un proxy in uscita con le stesse regole. Anteprime dei link in chat: generarle sul server con questi controlli, mai nel browser di chi riceve, che rivelerebbe il suo IP al sito del link. - Prova:
http://169.254.169.254/latest/meta-data/,http://localhost:54321/,http://[::1]/, un dominio che risolve in127.0.0.1e un URL che fa redirect verso uno di questi: tutti rifiutati.
13. API di terzi e webhook (API10 Unsafe Consumption of APIs)
- Cosa è: fidarsi di ciò che arriva da un'API esterna (mappe, geocoding, luoghi, login social, pagamenti, AI, metadati delle anteprime) come se fosse proprio.
- Validare le risposte con uno schema, limitarne tempo e dimensione, solo HTTPS, in caso di errore negare (fail closed). Nomi di luoghi, titoli e descrizioni sono testo non fidato: React li rende sicuri,
innerHTMLe i popup HTML delle mappe no (MapLibre:setText, nonsetHTML). URL ricevuti usati inhrefosrcsolo sehttps:. Chiavi segrete sul server o nelle Edge Functions; le chiavi che devono stare nel browser (mappe) limitate per dominio e quota presso il fornitore. Ai terzi il minimo: posizioni arrotondate, niente id interni (privacy-posizione). - Webhook in arrivo (pagamenti, email, Database Webhooks di Supabase): firma HMAC verificata sul corpo grezzo con confronto a tempo costante, finestra di tempo di pochi minuti, id dell'evento salvato per scartare i doppioni, risposta 2xx rapida ed elaborazione idempotente; per i Database Webhooks un header segreto controllato dalla funzione. Codice: privacy-pagamenti.
- Prova: una risposta finta del fornitore con
<img src=x onerror=alert(1)>nel nome di un luogo appare come testo e un URLjavascript:non diventa un link; un webhook con firma sbagliata o vecchio di un'ora riceve 400 e non cambia nulla.
Checklist
- Inventario generato e controllato in CI;
executerevocato per default; nessuna tabella, vista o funzione esposta senza motivo; versioni vecchie cancellate. - Prove curl con il token di un altro utente su tabelle, RPC, bucket e canali; viste
security_invoker; permessi per colonna; RPC riservate con controllo del ruolo daprivateoapp_metadata. - Max rows, limiti nelle RPC, timeout, quote; limiti per utente sui flussi sensibili; id idempotenti; link monouso con hash; vincoli su ogni input; errori con codici stabili; CORS giusto; SSRF bloccato; risposte di terzi validate; webhook firmati.
Limiti da comunicare
Le prove trovano i buchi che si cercano: vanno ripetute a ogni migrazione e prima di ogni rilascio. I limiti rallentano gli script, non li fermano: chi usa molti account resta possibile e va riconosciuto nei log e nelle segnalazioni. La RLS protegge i dati, non la logica dei flussi: le regole di business vanno scritte una per una. Una verifica esterna (penetration test) resta utile prima del lancio e dopo cambi grandi.
Secure APIs (English)
An API is anything that can be called without going through the UI: with a token and curl, a check done only in the React component does not exist. With Supabase every table, view and function in the exposed schemas is an endpoint (/rest/v1/<table>, /rest/v1/rpc/<function>), and the names and the public key are in the JavaScript bundle. The rules follow the OWASP API Security Top 10 2023 (API1-API10 code in the headings) and also apply to REST, GraphQL, Edge Functions and your own servers. Supabase basics: supabase-sicuro skill; anti-harassment limits and scraping: sicurezza-social; locations: privacy-posizione; login: autenticazione-sicura; client: frontend-spa; WAF on your own servers: difesa-attacchi; data already leaked: risposta-incidenti.
Mandatory rules
1. Endpoint inventory (API9 Improper Inventory Management)
- Keep in the repo the list of what is reachable: exposed tables and views, RPCs, Edge Functions, buckets, tables in Realtime, exposed schemas (Data API settings:
publiconly), third-party APIs called, webhooks received. For each: who calls it (anon, authenticated, role), what it returns, which limits apply. The database part is generated like this:
select c.relname, c.relkind, c.relrowsecurity as rls, -- tables and views, column grants included
has_any_column_privilege('anon', c.oid, 'select,insert,update') or has_table_privilege('anon', c.oid, 'delete') as anon,
has_any_column_privilege('authenticated', c.oid, 'select,insert,update') or has_table_privilege('authenticated', c.oid, 'delete') as auth
from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind in ('r','p','v','m','f') order by 1;
select p.proname, pg_get_function_identity_arguments(p.oid) as args, p.prosecdef as definer, has_function_privilege('anon', p.oid, 'execute') as anon,
has_function_privilege('authenticated', p.oid, 'execute') as auth from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' order by 1; -- RPCs- CI regenerates the list on the local database (
supabase startwith the migrations) and fails if it changes without the file being updated; every row withanonordefinerset totruehas a written reason. Old versions (get_profile_v1), debug functions, test tables and unused Edge Functions are dropped with a migration: names are not a secret. Previews and test environments use a separate project with fake data, never production with looser rules.
2. Deny by default
- New table: RLS on, no policy until one is needed,
grantonly the actions required (supabase-sicuro, rule 2). One policy per action (for select,for insert…): afor allpolicy also coversdelete. Edge Functions and your own servers: sign-in required on every route; public routes are an explicit list, each with its own limits. - Functions: Postgres grants
executetopublicon every new function and Supabase also toanonandauthenticated, so every function inpubliccan be called as an RPC. Remove the privilege by default and grant it one function at a time withgrant execute on function … to authenticated, including functions used in policies, which run as the caller. Fix existing ones withrevoke execute on function … from public, anon, authenticated; the inventory from rule 1 confirms the result. Policy helpers, trigger functions and internal functions go in theprivateschema, which is not exposed.
alter default privileges for role postgres revoke execute on functions from public;
alter default privileges for role postgres in schema public revoke execute on functions from anon, authenticated;3. Object-level authorization (API1 Broken Object Level Authorization)
- What it is: the API receives an id and reads or changes the object without asking whether the caller is entitled to it (
/messages?chat_id=eq.…with someone else's chat). It is the most common flaw; random uuids do not close it, because they end up in links, URLs and responses. - In PostgREST the defense is RLS: every policy ties the row to
auth.uid()(owner, participant, friend), never to an id sent by the client. Views bypass RLS unless createdwith (security_invoker = true); materialized views have no RLS but PostgREST serves them when they live in an exposed schema: keep them inprivate(or revokeselectfromanonandauthenticated) and read them through an RPC that filters byauth.uid().security definerRPCs and Edge Functions using the secret key re-check ownership in code; Edge Functions acting for the user create the client with the user's token, so RLS still applies. Test with test user B's token against A's objects (password from an environment variable, never in code):
J=(-H "apikey: $SB_KEY" -H "Content-Type: application/json")
TOKEN_B=$(curl -s "$SB_URL/auth/v1/token?grant_type=password" "${J[@]}" -d "{\"email\":\"$EMAIL_B\",\"password\":\"$PW_B\"}" | jq -r .access_token)
H=("${J[@]}" -H "Authorization: Bearer $TOKEN_B")
curl -s "$SB_URL/rest/v1/messages?chat_id=eq.$CHAT_A&select=*" "${H[@]}" # expected: []
curl -s -X PATCH "$SB_URL/rest/v1/meetups?id=eq.$MEETUP_A" "${H[@]}" -H "Prefer: return=representation" -d '{"title":"x"}' # expected: []
curl -s -X POST "$SB_URL/rest/v1/rpc/get_chat" "${H[@]}" -d "{\"chat_id\":\"$CHAT_A\"}" # expected: error- An update stopped by RLS does not return 403 but zero rows: check the content, not just the HTTP status. Run the same tests on Storage (
/storage/v1/object/<bucket>/<A's path>) and on Realtime channels; the SQL variant, in a rolled-back transaction, is in supabase-sicuro, rule 7.
4. Authentication (API2 Broken Authentication)
- What it is: tokens accepted without verification, long expiries, logins without limits, identity taken from the request body.
- Identity comes only from the verified token:
auth.uid()in the database; in Edge Functionssupabase.auth.getUser(token)or the signature checked against the project's public keys (JWKS). Never a merely decoded JWT, never auser_idread from the body. Your own servers: a fixed algorithm (nevernone),exp,issandaudchecked. - Short access tokens: in Supabase the default expiry is one hour, do not raise it. Supabase anonymous sign-ins have the
authenticatedrole: policies reserved for real accounts add(select (auth.jwt()->>'is_anonymous')::boolean) is false. Sign-in, sign-up, recovery and OTP with limits and a captcha (autenticazione-sicura; supabase-sicuro, rule 6); never tokens in URLs, logs or analytics. - Test: an expired token, one with a changed signature character or one with
"alg":"none"gets 401 from REST, RPC and Edge Functions; someone else'suser_idin the body is ignored or rejected.
5. Property-level authorization (API3 Broken Object Property Level Authorization)
- What it is: the row is right, the columns are not. On read, restricted fields leak (email, birthdate, precise location, orientation,
is_admin); on write, the client sets fields it should not (verified,role,plan,owner_id, counters: mass assignment). - RLS filters rows, not columns: with
select=*PostgREST returns everything the role can read. Fix: column grants, or private data in a separate table readable only by its owner.
revoke select, insert, update, delete on public.profiles from anon, authenticated; -- a trigger creates the profile at sign-up
grant select (id, display_name, bio, avatar_path, city) on public.profiles to authenticated;
grant update (display_name, bio, avatar_path, city) on public.profiles to authenticated;- With column grants
select=*errors out: the client lists the columns. RPCs declarereturns table (…)with only the useful fields, neversetof profiles; fields whose visibility the user chooses go through a function that applies that choice (privacy-posizione). In Realtime publish only tables without restricted columns. - Test: with your own token,
PATCH /rest/v1/profiles?id=eq.<my id>with{"verified":true}fails withpermission denied(42501);select=birthdateon another profile fails; a test Realtime subscriber receives no restricted columns.
6. Function-level authorization (API5 Broken Function Level Authorization)
- What it is: moderator or admin actions callable by anyone who knows the name (
/rpc/ban_user,/admin/export), or a role chosen by the client. - Every restricted RPC has
executeonly for those who need it (rule 2) and checks the role as its first statement:if not exists (select 1 from private.staff where user_id = auth.uid() and role = 'moderator') then raise exception 'forbidden' using errcode = '42501'; end if;. The role comes from a table in theprivateschema or fromapp_metadata, which only the server writes; never fromuser_metadata, which users change themselves withauth.updateUser(). - Test: as a regular user
POST /rest/v1/rpc/ban_userfails; with the public key alone (anon) every non-public RPC fails. Repeat for every RPC in the inventory withdefiner = trueand for every HTTP method of your own server's routes.
7. Resource consumption (API4 Unrestricted Resource Consumption)
- What it is: requests that cost too much: huge pages, slow filters, large files, calls that trigger SMS, email, geocoding or AI you pay for.
- PostgREST: lower "Max rows" (Data API settings, default 1000) to the most the app uses. In RPCs always cap (
limit least(p_limit, 50)), paginate with a cursor (where created_at < p_before), and accept only map areas of bounded size. - Per-role timeouts (Supabase default: 3 s
anon, 8 sauthenticated), lowered if that is enough:alter role anon set statement_timeout = '2s'; notify pgrst, 'reload config';. Index the columns used by policies and filters: a slow policy slows every request. Exact counts (Prefer: count=exact) only on small tables. Storage withfile_size_limitandallowed_mime_typeson every bucket. Edge Functions that call paid services keep a per-user daily quota in a table and a spending cap with the provider. - Test:
GET /rest/v1/meetups?select=id&limit=100000returns at most "Max rows"; the RPC withp_limit100000 returns 50.
8. Sensitive flows and per-user limits (API6 Unrestricted Access to Sensitive Business Flows)
- What it is: each step is authorized, but repeated by a script it becomes abuse: joining every table to see venues and attendees, filling all seats of an event, inviting thousands of people, fake or bulk reviews, mass sign-ups, brute-forcing invite codes.
- List the valuable flows and write the flow's rules in the database, not just the record's. Joining a table: no direct insert on
meetup_members, only an RPC that checks capacity, invite or approval, blocks and age, withunique (meetup_id, user_id). Reviews: only from attendees, after the end, one per pair (unique (meetup_id, reviewer_id, reviewed_id)). - Per-user, per-action limits with
private.rate_limit(sicurezza-social, rule 4), called from triggers and RPCs, stricter for new accounts; a captcha in front of the most attractive flows. Read RPCs that log calls (search, map) must bevolatile: instablefunctions writes fail. - Test: a script calling the RPC 50 times in a row gets
rate_limitedpast the threshold; a direct insert intomeetup_membersis rejected.
9. Idempotency and replay
- Double taps and flaky networks repeat requests: two sign-ups, two messages, two payments. Every action that creates something carries a client-generated id (
crypto.randomUUID()) with a unique constraint; the repeat gets 409 (unique violation) and the client treats it as success. - Single-use links and codes (invites, "share the plan", recovery): only the hash in the database, with an expiry, consumed atomically inside the RPC that accepts them. Incoming webhooks: signature, time window and ids of already processed events (rule 13). A stolen access token stays valid until it expires, even after logout: that is why it is short.
alter table public.messages add column client_id uuid, add constraint messages_once unique (sender_id, client_id);
update public.invites set used_at = now() where token_hash = encode(sha256(convert_to(p_token, 'UTF8')), 'hex')
and used_at is null and expires_at > now() returning meetup_id; -- no row: invalid, expired or already used link- Test: the same insert sent twice creates a single row; the same invite link works only once.
10. Server-side input validation
- A modified client sends anything. Types, lengths, ranges and allowed values are enforced in the database with constraints and domains; in Edge Functions with a schema (zod, valibot) that rejects unknown fields.
create domain public.lat as double precision check (value between -90 and 90);
alter table public.messages add constraint body_len check (char_length(body) between 1 and 2000);
alter table public.meetups add constraint seats_ok check (seats between 2 and 50), add constraint time_ok check (ends_at > starts_at),
add constraint visibility_ok check (visibility in ('friends', 'public', 'invite')), add constraint meta_size check (pg_column_size(meta) < 4096);- Rules that depend on the clock or on other rows (event in the future, at most one year ahead, capacity) go in a trigger: a
checkmust always give the same result. Arrays get a maximum too (cardinality(tags) <= 10). Free text stored as is and made safe on output (React does it; nodangerouslySetInnerHTML); URLs accepted only withhttps:. Never SQL built from client text (supabase-sicuro, rule 3).
11. Configuration, errors and CORS (API8 Security Misconfiguration)
- Supabase: exposed schemas
publiconly;pg_graphqldisabled if GraphQL is not used; no secret key in the frontend; private buckets; Security Advisor with no warnings (supabase-sicuro). Responses with personal data:Content-Type: application/json,X-Content-Type-Options: nosniff,Cache-Control: no-store; only the HTTP methods in use; no debug or status endpoint that reveals versions. - Errors: PostgREST passes on Postgres's message, detail and hint, that is table and constraint names and sometimes values (
Key (email)=(…) already exists). In RPCs and triggers, re-raise expected errors with a stable code, no data and the right HTTP status; the UI translates codes into the user's language and never shows the rawerror.message. Your own servers: a generic response with an error id, details only in the logs (hardening-siti, rule 6).
raise sqlstate 'PGRST' using message = '{"code":"rate_limited","message":"Too many requests"}', detail = '{"status":429,"headers":{"Retry-After":"60"}}';- CORS is not access control: it only decides which sites can read responses in the browser, and does not stop curl or scripts. APIs that authenticate with a token in a header, like Supabase's, can accept any origin; with session cookies you need an explicit origin list,
Access-Control-Allow-Credentials: trueonly with an exact origin, never theOriginechoed back unchecked, nevernull, alwaysVary: Origin. In Edge Functions, on every response (OPTIONSincluded):
const ALLOWED = ['https://example.app'], origin = req.headers.get('Origin') ?? ''; // production and known previews
const cors = { Vary: 'Origin', 'Access-Control-Allow-Headers': 'authorization, apikey, content-type', 'Access-Control-Allow-Methods': 'POST, OPTIONS', ...(ALLOWED.includes(origin) ? { 'Access-Control-Allow-Origin': origin } : {}) };- Test: a deliberate error (duplicate, wrong type) shows no table names, constraints or values;
curl -i -H 'Origin: https://evil.example'against the Edge Function gets noAccess-Control-Allow-Origin.
12. SSRF (API7 Server Side Request Forgery)
- What it is: the server fetches a URL chosen by the user (link preview, avatar from URL, calendar import, configurable webhooks) and thereby reaches internal networks or cloud metadata (
169.254.169.254). With Supabase this happens in Edge Functions and withpg_net/httpin the database: never pass them a user URL without checks. - Fix: preferably an allowlist of hosts; otherwise only
https:on port 443, the name resolved and rejected if it points to private, loopback, link-local or reserved addresses (127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,100.64.0.0/10,0.0.0.0/8,::1,::ffff:0:0/96,fc00::/7,fe80::/10), manual redirects (redirect: 'manual') re-checked one by one, a short timeout, a maximum size, the expected content type, and never the raw response returned to the user. A name can change address between check and request (DNS rebinding): for sensitive cases, an egress proxy with the same rules. Link previews in chat: generate them on the server with these checks, never in the recipient's browser, which would reveal the recipient's IP to the linked site. - Test:
http://169.254.169.254/latest/meta-data/,http://localhost:54321/,http://[::1]/, a domain resolving to127.0.0.1and a URL that redirects to one of these: all rejected.
13. Third-party APIs and webhooks (API10 Unsafe Consumption of APIs)
- What it is: trusting what comes from an external API (maps, geocoding, places, social login, payments, AI, preview metadata) as if it were your own.
- Validate responses with a schema, cap their time and size, HTTPS only, deny on error (fail closed). Place names, titles and descriptions are untrusted text: React makes them safe,
innerHTMLand HTML map popups do not (MapLibre:setText, notsetHTML). URLs received are used inhreforsrconly ifhttps:. Secret keys on the server or in Edge Functions; keys that must live in the browser (maps) restricted by domain and quota with the provider. Send third parties the minimum: rounded locations, no internal ids (privacy-posizione). - Incoming webhooks (payments, email, Supabase Database Webhooks): HMAC signature verified on the raw body with a constant-time comparison, a time window of a few minutes, the event id stored to drop duplicates, a quick 2xx response and idempotent processing; for Database Webhooks a secret header checked by the function. Code: privacy-pagamenti.
- Test: a fake provider response with
<img src=x onerror=alert(1)>in a place name shows up as text and ajavascript:URL does not become a link; a webhook with a wrong signature or an hour old gets 400 and changes nothing.
Checklist
- Inventory generated and checked in CI;
executerevoked by default; no table, view or function exposed without a reason; old versions dropped. - curl tests with another user's token on tables, RPCs, buckets and channels;
security_invokerviews; column grants; restricted RPCs check the role fromprivateorapp_metadata. - Max rows, caps in RPCs, timeouts, quotas; per-user limits on sensitive flows; idempotent ids; hashed single-use links; constraints on every input; errors with stable codes; correct CORS; SSRF blocked; third-party responses validated; signed webhooks.
Limitations to communicate
Tests find the holes you look for: repeat them at every migration and before every release. Limits slow scripts down, they do not stop them: an attacker with many accounts is still possible and must be spotted in logs and reports. RLS protects data, not the logic of flows: business rules must be written one by one. An external assessment (penetration test) is still worthwhile before launch and after big changes.
In this repository14 other files it gives its agents.
- account-fornitoriSkill
- aggiornamenti-runtimeSkill
- architettura-fortezzaSkill
- autenticazione-sicuraSkill
- difesa-attacchiSkill
- email-dominioSkill
- frontend-spaSkill
- hardening-sitiSkill
- privacy-pagamentiSkill
- privacy-posizioneSkill
- risposta-incidentiSkill
- sicurezza-githubSkill
- sicurezza-socialSkill
- supabase-sicuroSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

