Vai al contenuto

Basi della sicurezza

"Infinite recursion detected in policy" senza disattivare RLS

"Infinite recursion detected in policy for relation" significa che la tua policy ha interrogato la tabella che protegge. Ecco come rompere il cerchio.

Vlad Tkachenko11 min di lettura
Un pannello di tabella chiuso a chiave, con la propria chiave disegnata dentro, dietro il vetro, e una barra di traverso sulla porta.

In breve

  • "Infinite recursion detected in policy for relation" è l'errore 42P17 di Postgres. Una regola Row Level Security su una tabella è andata a fare una domanda a quella stessa tabella, e la domanda non ha fine.
  • Non è il segno che Row Level Security fosse sbagliato per la tua app. È il segno che una regola ha fatto una domanda circolare.
  • Una piccola funzione di appoggio legge la tabella fuori dalle regole e rompe il cerchio. Anche disattivare Row Level Security su quella tabella fa smettere l'errore, consegnando ogni riga che contiene a chiunque abbia la chiave che viaggia dentro la tua app.

Hai attivato Row Level Security, scritto una regola perché gli admin vedano tutti i profili e ciascun altro veda solo il proprio, e adesso non si carica nemmeno una schermata della tua app. Ogni lettura torna con la stessa riga:

infinite recursion detected in policy for relation "profiles"

La maggior parte delle risposte che troverai dice la stessa cosa, ed è quella sbagliata: disattiva Row Level Security su quella tabella finché non ne vieni a capo. L'errore smette davvero. A farlo smettere è che la tabella adesso è leggibile da chiunque visiti il tuo sito.

Che cosa significa "infinite recursion detected in policy for relation"

Una regola su una tabella ha dovuto leggere quella stessa tabella prima di poter rispondere.

Immagina un portiere che controlla ogni visitatore su una lista di invitati, e la lista sta dentro la sala che sta sorvegliando. Per leggere la lista deve entrare. Per entrare deve controllare la lista. Non c'è un primo passo, così resta nel corridoio e nessuno va da nessuna parte.

È quello in cui è finito il tuo database. Gli hanno chiesto alcune righe di profiles, è andato alla regola su profiles per capire quali potevi vedere, e la regola gli ha detto di guardare in profiles. Postgres si accorge che sta girando in tondo, rinuncia e solleva l'errore con attaccato il codice 42P17. Supabase lo passa alla tua app senza toccarlo, ed è per questo che si legge come un pezzo di macchina.

La query fallisce per tutti quelli a cui la regola si applica, quindi di solito arriva come un'app intera che resta vuota in un colpo solo, non come una singola schermata rotta.

Il cerchio, disegnato

La regola che lo provoca è quella che quasi tutti scrivono per prima, perché dice esattamente quello che intendi:

-- Da rifiutare: questo legge profiles per decidere chi può leggere profiles.
create policy "admins read every profile" on profiles for select
to authenticated
using (
  exists (
    select 1 from profiles
    where id = (select auth.uid()) and role = 'admin'
  )
);

L'exists (select 1 from profiles …) è tutto il problema. Leggere profiles vuol dire applicare la regola su profiles, che esegue di nuovo exists (select 1 from profiles …).

La richiesta non ha niente di strano. L'anello sta fra la regola e la tabella che la regola protegge.

La guida a Row Level Security di Supabase nomina la versione a due tabelle: policy su due tabelle che leggono ciascuna l'altra non si risolvono mai, e la query fallisce per ogni ruolo a cui le policy si applicano. Una tabella che legge se stessa è la stessa forma senza la deviazione.

Perché succede sempre sulla tabella profiles?

Perché in profiles sta chi è questa persona.

Qualunque regola che tratti un tipo di persona diversamente da un altro deve scoprire di che tipo è chi sta chiamando, e quel dato vive in una colonna di qualche tabella. Comunque si chiami quella tabella, la regola che la protegge finisce per leggerla. In un'app fatta con Lovable, Bolt, v0 o Replit di solito è profiles, perché è lì che finisce la riga di ogni persona autenticata. Una regola su team, organizzazioni o documenti condivisi produce lo stesso cerchio sulla tabella che tiene l'appartenenza.

Quindi la ricorsione nasce dall'aver scritto la regola ovvia sull'unica tabella che deve rispondere a una domanda su se stessa.

La soluzione: un helper che legge la tabella fuori dalle regole

Sposta la domanda in una piccola funzione che gira come proprietaria del database, così leggere profiles per rispondere non passa dalla regola su profiles.

create schema if not exists private;

create function private.is_admin()
returns boolean
language sql
security definer      -- gira come il ruolo che l'ha creata
set search_path = ''  -- così ogni nome qui dentro va scritto per esteso
stable
as $$
  select exists (
    select 1 from public.profiles
    where id = (select auth.uid()) and role = 'admin'
  );
$$;

revoke execute on function private.is_admin() from public;
grant usage on schema private to authenticated;
grant execute on function private.is_admin() to authenticated;

-- Adesso la regola interroga la funzione invece della tabella.
create policy "admins read every profile" on profiles for select
to authenticated
using ( (select private.is_admin()) );

Adesso il portiere ha la lista di invitati su un tavolo nel corridoio. La legge senza aprire la porta, e la porta resta chiusa per tutti quelli che non ci sono sopra.

Quattro righe lì dentro fanno un lavoro preciso, e tre di esse sono la differenza fra una soluzione e un buco nuovo:

  • security definer è ciò che rompe il cerchio. La guida di Supabase lo definisce come una funzione che gira sotto lo stesso ruolo che ha creato la funzione, e su Supabase quel ruolo è postgres, che può leggere scavalcando le regole.
  • set search_path = '' va messo su ognuna di esse. Supabase consiglia di metterlo su ogni funzione security definer e di scrivere i nomi di tabella per esteso, perché senza un search_path fissato chi chiama può puntare un nome non qualificato a un oggetto suo e farlo girare con i privilegi del proprietario della funzione. È per questo che la funzione scrive public.profiles per esteso.
  • Lo schema private conta per lo stesso motivo. La guida di Supabase avverte che una funzione security definer messa in uno schema esposto si può chiamare dalla Data API con i privilegi di chi l'ha creata, e ti dice di non crearne mai una in uno schema elencato sotto Exposed schemas nelle impostazioni della tua API.
  • (select private.is_admin()), avvolto in un select suo. Così Postgres calcola la risposta una volta per tutta l'istruzione invece di una volta per riga, che è quello che Supabase documenta come motivo per avvolgere auth.uid() allo stesso modo.

Le righe revoke e grant tengono la funzione richiamabile dagli utenti autenticati e da nessun altro.

Quella policy copre la lettura. Se il salvataggio comincia a fallire appena le tue schermate tornano a riempirsi, hai incontrato un altro messaggio con un'altra causa.

La soluzione che non risolve

Anche disattivare Row Level Security su profiles fa smettere l'errore, con un clic, e consegna ogni riga di quella tabella a chiunque abbia la chiave che la tua app manda al browser.

Quella chiave è pensata per essere pubblica. Sta nel codice del tuo sito, e chiunque può estrarla in qualche secondo. Quello che finora le impediva di restituire tutta la tua tabella utenti erano le regole che hai appena disattivato.

Entrambe fanno smettere l'errore. Solo una delle due risponde ancora alla domanda che era stata fatta alla regola.

La tua app dopo ha lo stesso aspetto in entrambi i casi, ed è questa la parte che fa restare la cosa. Niente sulle tue schermate dice quale delle due hai scelto, e l'errore è sparito in tutte e due.

A dirlo è il tuo database, interrogato dall'esterno. Abbiamo scansionato 31.056 app online fatte con Lovable, Bolt, v0, Replit e gli altri, e visto un progetto Supabase dietro 8.435 di esse. Questo è ciò che ha trovato il controllo Row Level Security:

Che cosa abbiamo chiestoApp
Avevano un progetto Supabase visibile8.435
Si è potuto chiederlo3.680
Hanno restituito righe a una richiesta senza login2.096
Di quelle, da una tabella di persone, ordini o messaggi394

La riga di mezzo è quella onesta. Su 4.755 di quelle app il controllo non ha avuto risposta, e lo diciamo invece di darle per pulite. E alcune delle 2.096 devono essere leggibili, perché una tabella pubblica è una cosa che si può volere davvero; dall'esterno, un menu e un elenco di soci si somigliano.

Non possiamo dirti quante di quelle tabelle siano state aperte da qualcuno che si stava togliendo di mezzo esattamente questo errore. Possiamo dirti che una tabella aperta è il normale punto d'arrivo del consiglio che sta in cima ai risultati di ricerca.

Se preferisci non doverci ragionare, la nostra scansione gratuita legge il tuo sito online e ti dice quali delle tue tabelle rispondono a uno sconosciuto. Richiede una ventina di secondi e non chiede un account: scansiona la tua app.

Succede un'altra cosa quando lo disattivi. L'advisor di Supabase comincia a segnalare la tabella, cioè l'avviso RLS disabled in public, e quell'avviso sarà ancora lì fra un mese.

Come capire se il cerchio è davvero sparito

Che l'errore smetta non è la prova, perché a farlo smettere sono due cose diverse.

C'è anche un modo in cui la funzione di appoggio sembra giusta e continua a ricorrere, e Supabase lo documenta: una funzione security definer scavalca le regole solo se il suo proprietario ne ha il diritto. Su Supabase il proprietario è postgres, che quel privilegio ce l'ha, quindi lo schema qui sopra funziona. Una funzione che appartenga a un ruolo senza di esso, o che legga una tabella impostata su force row level security, ripassa dalla regola e resta nel cerchio.

Due controlli, e falli entrambi:

Scrivi i casi ed eseguili. La procedura di Supabase è un file .sql sotto supabase/tests/ che afferma permesso e rifiuto per ogni operazione, per un utente autenticato e per uno anonimo, lanciato con supabase test db. Un admin deve vedere tutti i profili, un membro il suo, uno sconosciuto niente. Finché quello non passa, quello che sai è che l'errore ha smesso.

Poi chiedi dall'esterno, senza login. È la domanda che fa il browser di un visitatore, e l'unica che riflette ciò che la tua app distribuisce davvero. Se uno sconosciuto può leggere il tuo database ci passa attraverso, e Row Level Security può essere attivo e la tabella restare pubblica copre il caso in cui le regole ci sono e lasciano passare tutti lo stesso.

Quando la ricorsione ti sta dicendo che lo schema è sbagliato

A volte non c'è una domanda circolare da togliere, perché le due tabelle dipendono davvero l'una dall'altra.

La guida di Supabase usa la condivisione come esempio: una regola su lists controlla list_members per vedere con chi la lista è stata condivisa, e una regola su list_members controlla lists per vedere di chi è. La regola di ogni tabella legge l'altra, e nessuna può andare per prima. Lo stesso helper scioglie il nodo, con una funzione che restituisce gli id delle liste a cui chi chiama appartiene, e le due regole che interrogano quella funzione invece di interrogarsi a vicenda.

Il segnale su cui vale la pena fermarsi è quando te ne servono tre o quattro perché uno schema stia in piedi. A quel punto l'appartenenza viene ricostruita dentro le regole ogni volta, e una sola tabella che dica chiaramente chi può vedere cosa di solito richiede meno manutenzione delle regole che stai sbrogliando.

Tenere la tabella chiusa dopo averla sistemata

Una regola che hai sistemato oggi descrive il database di oggi. La prossima policy, la prossima tabella e la prossima volta che qualcuno si toglie di mezzo un errore a mezzanotte avvengono tutte dopo l'ultima volta che hai guardato, e nessuna di esse cambia qualcosa che tu possa vedere sulle tue schermate.

Reeve Monitor rifà alla tua app le stesse domande, a calendario:

  • tutti e nove i controlli ogni ora, su un massimo di tre app
  • un messaggio quando un risultato cambia, così una tabella aperta stanotte non aspetta che te ne accorga
  • se l'app risponde, ogni 60 secondi
  • un rapporto mensile di quello che ha visto

Se la tua app tiene i dati in un progetto Supabase tuo, Reeve Care ne conserva anche una copia. Una regola che lascia leggere uno sconosciuto è un problema. Una regola che lo lascia scrivere è l'altro, e nessun controllo di questo articolo riporta indietro righe cancellate.

  • una copia cifrata del tuo database Supabase ogni notte, tenuta dove il tuo progetto non arriva
  • ogni copia verificata prima di contare, contando le righe di ogni tabella
  • un ripristino con un clic quando ti serve
  • anche i tuoi file caricati, appena colleghi una credenziale Storage
  • tutto quello che fa Monitor

Che cosa fare adesso

Cosa fare

  • Lascia attivo Row Level Security. L'errore riguarda una regola, e disattivare le regole è una modifica a tutta la tabella.
  • Sposta il controllo del ruolo in una funzione security definer in uno schema non esposto, con set search_path = '' e ogni nome di tabella scritto per esteso.
  • Punta la policy sulla funzione, avvolta come (select private.is_admin()), così gira una volta per istruzione e non una volta per riga.
  • Scrivi i casi permessi e negati sotto supabase/tests/ e lancia supabase test db: un admin vede tutti i profili, un membro il suo, uno sconosciuto niente.
  • Se hai già disattivato Row Level Security per andare avanti, riattivalo oggi e sistema la regola come si deve. L'advisor di Supabase continuerà a segnalare quella tabella finché non lo fai, e va messo su ogni tabella che hai.

Se vuoi il resto dell'elenco per un'app appena lanciata, la checklist di sicurezza in 10 minuti copre questo e le altre cose che conviene chiudere prima che qualcuno le trovi.

FAQ

Che cosa provoca "infinite recursion detected in policy for relation"?

Una regola su una tabella che deve leggere quella stessa tabella prima di poter rispondere. La tua regola dice più o meno "fai passare questa persona se la sua riga in profiles dice che è admin", quindi il database va a leggere quella riga in profiles, il che impone di controllare la regola su profiles, che lo rimanda a leggere di nuovo la riga. Postgres si accorge che sta girando in tondo, si ferma e solleva l'errore 42P17. La stessa cosa succede fra due tabelle le cui regole leggono ciascuna l'altra.

È sicuro usare una funzione security definer in una policy?

Sì, a due condizioni che Supabase enuncia nella sua guida a Row Level Security. Metti search_path alla stringa vuota e scrivi dentro ogni nome di tabella per esteso, quindi public.profiles e non profiles. Senza, qualcuno può puntare un nome non qualificato a un oggetto suo e farlo girare con i privilegi del proprietario della funzione. E crea la funzione in uno schema non esposto sull'API, perché una funzione security definer in uno schema esposto si può chiamare dall'esterno con i privilegi di chi l'ha creata.

Devo disattivare RLS per risolvere?

Fa smettere l'errore e apre la tabella. Con Row Level Security disattivato, ogni riga di quella tabella è leggibile da chiunque abbia la chiave che la tua app manda al browser, e quella chiave chiunque può estrarla dal tuo sito. La tua app si comporta allo stesso modo in entrambi i casi, quindi dopo niente ti dice quale delle due hai scelto. La funzione di appoggio qui sotto fa smettere l'errore e tiene la tabella chiusa.

Perché succede sempre sulla tabella profiles?

Perché in profiles di solito sta la risposta a "chi è questa persona". Una regola che tratta gli admin in modo diverso, o i membri di un team in modo diverso, deve sapere che cosa è chi sta chiamando, e quel dato è salvato in profiles. Così la regola che protegge profiles finisce per leggere profiles. Qualunque tabella che tenga il ruolo o l'appartenenza di chi chiama può produrlo, e in un'app fatta con Lovable, Bolt o v0 quella tabella di solito è quella che si chiama profiles.

Come controllo che la mia policy funzioni davvero?

Due cose diverse fanno smettere l'errore, quindi l'errore che sparisce da solo dice molto poco. Scrivi i casi permessi e negati in un file .sql sotto supabase/tests/ e lancia supabase test db, che è la procedura che Supabase pubblica insieme alla guida: un proprietario autenticato deve passare e uno sconosciuto deve essere respinto. Poi fai al tuo database la stessa domanda dall'esterno, senza alcun login, come farebbe il browser di un visitatore.

Scritto da

Vlad Tkachenko

Fondatore di Reeve

Passo le giornate a guardare app costruite con Lovable, Bolt, v0, Cursor e Replit, e la breve lista di errori che vi ricompaiono di continuo.

Altro sull'autore

Da leggere dopo

Tutti gli articoli

Non sai come sta messa la tua app?

Esegui una scansione gratuita e ottieni un voto chiaro da A a F in una ventina di secondi. Senza account e senza carta.

Scansiona la tua app gratis

Controllo esterno automatizzato, non un audit completo. L'assenza di risultati non è una garanzia di sicurezza.