Alcuni giorni fa ho scritto sul blog i problemi comuni con ruoli e privilegi che scopriamo durante le revisioni della sicurezza.
Naturalmente, PostgreSQL offre molte funzionalità avanzate relative alla sicurezza, una delle quali è la sicurezza a livello di riga (RLS), disponibile da PostgreSQL 9.5.
Poiché la 9.5 è stata rilasciata a gennaio 2016 (quindi solo pochi mesi fa), RLS è una funzionalità abbastanza nuova e non abbiamo ancora a che fare con molte distribuzioni di produzione. Invece RLS è un argomento comune delle discussioni su "come implementare" e una delle domande più comuni è come farlo funzionare con gli utenti a livello di applicazione. Vediamo quindi quali sono le possibili soluzioni.
Introduzione a RLS
Vediamo prima un esempio molto semplice, che spiega di cosa tratta RLS. Diciamo che abbiamo una chat
tabella che memorizza i messaggi inviati tra utenti:gli utenti possono inserire righe al suo interno per inviare messaggi ad altri utenti e interrogarlo per vedere i messaggi inviati loro da altri utenti. Quindi la tabella potrebbe assomigliare a questa:
CREATE TABLE chat ( message_uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(), message_time TIMESTAMP NOT NULL DEFAULT now(), message_from NAME NOT NULL DEFAULT current_user, message_to NAME NOT NULL, message_subject VARCHAR(64) NOT NULL, message_body TEXT );
La classica sicurezza basata sui ruoli ci consente solo di limitare l'accesso all'intera tabella o a porzioni verticali di essa (colonne). Quindi non possiamo usarlo per impedire agli utenti di leggere messaggi destinati ad altri utenti o di inviare messaggi con un falso message_from
campo.
Ed è esattamente a questo che serve RLS:ti consente di creare regole (politiche) che limitano l'accesso a sottoinsiemi di righe. Quindi, ad esempio, puoi farlo:
CREATE POLICY chat_policy ON chat USING ((message_to = current_user) OR (message_from = current_user)) WITH CHECK (message_from = current_user)
Questa politica garantisce che un utente possa vedere solo i messaggi inviati da lui o destinati a lui:questa è la condizione in USING
clausola lo fa. La seconda parte della politica (WITH CHECK
) assicura che un utente può inserire solo messaggi con il suo nome utente in message_from
colonna, impedendo messaggi con mittente contraffatto.
Puoi anche immaginare RLS come un modo automatico per aggiungere ulteriori condizioni WHERE. Potresti farlo manualmente a livello di applicazione (e prima che le persone RLS lo facessero spesso), ma RLS lo fa in modo affidabile e sicuro (ad esempio, sono stati compiuti molti sforzi per prevenire varie fughe di informazioni).
Nota :prima di RLS, un modo popolare per ottenere qualcosa di simile era rendere la tabella inaccessibile direttamente (revocare tutti i privilegi) e fornire una serie di funzioni di definizione della sicurezza per accedervi. Ciò ha raggiunto per lo più lo stesso obiettivo, ma le funzioni hanno vari svantaggi:tendono a confondere l'ottimizzatore e limitano seriamente la flessibilità (se l'utente ha bisogno di fare qualcosa e non esiste una funzione adatta, è sfortunato). E, naturalmente, devi scrivere quelle funzioni.
Utenti dell'applicazione
Se leggi la documentazione ufficiale su RLS, potresti notare un dettaglio:tutti gli esempi utilizzano current_user
, ovvero l'utente del database corrente. Ma non è così che funzionano la maggior parte delle applicazioni di database al giorno d'oggi. Le applicazioni Web con molti utenti registrati non mantengono la mappatura 1:1 per gli utenti del database, ma utilizzano invece un singolo utente del database per eseguire query e gestire gli utenti dell'applicazione da soli, magari in un users
tabella.
Tecnicamente non è un problema creare molti utenti di database in PostgreSQL. Il database dovrebbe gestirlo senza problemi, ma le applicazioni non lo fanno per una serie di motivi pratici. Ad esempio, devono tenere traccia di informazioni aggiuntive per ciascun utente (ad es. dipartimento, posizione all'interno dell'organizzazione, dettagli di contatto, ...), quindi l'applicazione richiede gli users
tavolo comunque.
Un altro motivo potrebbe essere il pool di connessioni:utilizzando un singolo account utente condiviso, anche se sappiamo che è risolvibile utilizzando l'ereditarietà e SET ROLE
(vedi post precedente).
Ma supponiamo che tu non voglia creare utenti di database separati:desideri continuare a utilizzare un unico account di database condiviso e utilizzare RLS con gli utenti dell'applicazione. Come farlo?
Variabili di sessione
In sostanza, ciò di cui abbiamo bisogno è passare un contesto aggiuntivo alla sessione del database, in modo da poterlo utilizzare in seguito dalla politica di sicurezza (invece di current_user
variabile). E il modo più semplice per farlo in PostgreSQL sono le variabili di sessione:
SET my.username = 'tomas'
Se questo è simile ai normali parametri di configurazione (ad es. SET work_mem = '...'
), hai assolutamente ragione:è più o meno la stessa cosa. Il comando definisce un nuovo spazio dei nomi (my
), e aggiunge un username
variabile in esso. Il nuovo spazio dei nomi è obbligatorio, in quanto quello globale è riservato alla configurazione del server e non possiamo aggiungervi nuove variabili. Questo ci consente di modificare la politica di sicurezza in questo modo:
CREATE POLICY chat_policy ON chat USING (current_setting('my.username') IN (message_from, message_to)) WITH CHECK (message_from = current_setting('my.username'))
Tutto quello che dobbiamo fare è assicurarci che il pool di connessioni/l'applicazione imposti il nome utente ogni volta che ottiene una nuova connessione e lo assegni all'attività dell'utente.
Vorrei sottolineare che questo approccio fallisce una volta che si consente agli utenti di eseguire SQL arbitrario sulla connessione o se l'utente riesce a scoprire una vulnerabilità di SQL injection adeguata. In tal caso non c'è nulla che possa impedire loro di impostare un nome utente arbitrario. Ma non disperare, ci sono un sacco di soluzioni a questo problema e le esamineremo rapidamente.
Variabili di sessione firmate
La prima soluzione è un semplice miglioramento delle variabili di sessione:non possiamo davvero impedire agli utenti di impostare un valore arbitrario, ma se potessimo verificare che il valore non sia stato sovvertito? È abbastanza facile da fare usando una semplice firma digitale. Invece di memorizzare solo il nome utente, la parte attendibile (pool di connessione, applicazione) può fare qualcosa del genere:
signature = sha256(username + timestamp + SECRET)
e quindi archiviare sia il valore che la firma nella variabile di sessione:
SET my.username = 'username:timestamp:signature'
Supponendo che l'utente non conosca la stringa SECRET (es. 128B di dati casuali), non dovrebbe essere possibile modificare il valore senza invalidare la firma.
Nota :Questa non è un'idea nuova:è essenzialmente la stessa cosa dei cookie HTTP firmati. Django ha una bella documentazione a riguardo.
Il modo più semplice per proteggere il valore SECRET è archiviarlo in una tabella inaccessibile all'utente e fornire un security definer
funzione, che richiede una password (in modo che l'utente non possa semplicemente firmare valori arbitrari).
CREATE FUNCTION set_username(uname TEXT, pwd TEXT) RETURNS text AS $ DECLARE v_key TEXT; v_value TEXT; BEGIN SELECT sign_key INTO v_key FROM secrets; v_value := uname || ':' || extract(epoch from now())::int; v_value := v_value || ':' || crypt(v_value || ':' || v_key, gen_salt('bf')); PERFORM set_config('my.username', v_value, false); RETURN v_value; END; $ LANGUAGE plpgsql SECURITY DEFINER STABLE;
La funzione cerca semplicemente la chiave di firma (segreta) in una tabella, calcola la firma e quindi imposta il valore nella variabile di sessione. Restituisce anche il valore, principalmente per comodità.
Quindi la parte fidata può farlo subito prima di consegnare la connessione all'utente (ovviamente "passphrase" non è una password molto buona per la produzione):
SELECT set_username('tomas', 'passphrase')
E poi, ovviamente, abbiamo bisogno di un'altra funzione che verifichi semplicemente la firma e ometta errori o restituisca il nome utente se la firma corrisponde.
CREATE FUNCTION get_username() RETURNS text AS $ DECLARE v_key TEXT; v_parts TEXT[]; v_uname TEXT; v_value TEXT; v_timestamp INT; v_signature TEXT; BEGIN -- no password verification this time SELECT sign_key INTO v_key FROM secrets; v_parts := regexp_split_to_array(current_setting('my.username', true), ':'); v_uname := v_parts[1]; v_timestamp := v_parts[2]; v_signature := v_parts[3]; v_value := v_uname || ':' || v_timestamp || ':' || v_key; IF v_signature = crypt(v_value, v_signature) THEN RETURN v_uname; END IF; RAISE EXCEPTION 'invalid username / timestamp'; END; $ LANGUAGE plpgsql SECURITY DEFINER STABLE;
E poiché questa funzione non ha bisogno della passphrase, l'utente può semplicemente fare questo:
SELECT get_username()
Ma il get_username()
la funzione è pensata per le politiche di sicurezza, ad es. così:
CREATE POLICY chat_policy ON chat USING (get_username() IN (message_from, message_to)) WITH CHECK (message_from = get_username())
Un esempio più completo, racchiuso come una semplice estensione, può essere trovato qui.
Si noti che tutti gli oggetti (tabella e funzioni) sono di proprietà di un utente privilegiato, non dell'utente che accede al database. L'utente ha solo EXECUTE
privilegio sulle funzioni, che sono comunque definite come SECURITY DEFINER
. Questo è ciò che fa funzionare questo schema proteggendo il segreto dall'utente. Le funzioni sono definite come STABLE
, per limitare il numero di chiamate a crypt()
funzione (che è intenzionalmente costosa per prevenire il bruteforcing).
Le funzioni di esempio richiedono sicuramente più lavoro. Ma si spera che sia abbastanza buono per un proof of concept che dimostri come archiviare contesto aggiuntivo in una variabile di sessione protetta.
Cosa deve essere risolto chiedi? In primo luogo, le funzioni non gestiscono molto bene varie condizioni di errore. In secondo luogo, mentre il valore con segno include un timestamp, in realtà non stiamo facendo nulla con esso:potrebbe essere utilizzato per far scadere il valore, ad esempio. È possibile aggiungere ulteriori bit al valore, ad es. un reparto dell'utente, o anche informazioni sulla sessione (es. PID del processo di back-end per impedire il riutilizzo dello stesso valore su altre connessioni).
Criptografia
Le due funzioni si basano sulla crittografia:non stiamo usando molto tranne alcune semplici funzioni di hashing, ma è comunque un semplice schema crittografico. E tutti sanno che non dovresti fare le tue criptovalute. Ecco perché ho usato l'estensione pgcrypto, in particolare crypt()
funzione, per aggirare questo problema. Ma non sono un crittografo, quindi anche se credo che l'intero schema vada bene, forse mi sfugge qualcosa:fammi sapere se trovi qualcosa.
Inoltre, la firma sarebbe un'ottima corrispondenza per la crittografia a chiave pubblica:potremmo usare una normale chiave PGP con una passphrase per la firma e la parte pubblica per la verifica della firma. Purtroppo, sebbene pgcrypto supporti PGP per la crittografia, non supporta la firma.
Approcci alternativi
Ovviamente esistono diverse soluzioni alternative. Ad esempio, invece di archiviare il segreto di firma in una tabella, è possibile codificarlo nella funzione (ma è necessario assicurarsi che l'utente non possa vedere il codice sorgente). Oppure puoi eseguire la firma in una funzione C, nel qual caso è nascosta a tutti coloro che non hanno accesso alla memoria (nel qual caso hai perso comunque).
Inoltre, se non ti piace affatto l'approccio alla firma, puoi sostituire la variabile con segno con una soluzione più tradizionale "vault". Abbiamo bisogno di un modo per archiviare i dati, ma dobbiamo assicurarci che l'utente non possa vedere o modificare i contenuti in modo arbitrario, se non in un modo definito. Ma ehi, questo è ciò che le normali tabelle con un'API implementate utilizzando security definer
le funzioni possono fare!
Non presenterò qui l'intero esempio rielaborato (controlla questa estensione per un esempio completo), ma ciò di cui abbiamo bisogno è una sessions
tabella che funge da deposito:
CREATE TABLE sessions ( session_id UUID PRIMARY KEY, session_user NAME NOT NULL )
La tabella non deve essere accessibile dai normali utenti del database:un semplice REVOKE ALL FROM ...
dovrebbe occuparsene. E poi un'API composta da due funzioni principali:
set_username(user_name, passphrase)
– genera un UUID casuale, inserisce i dati nel vault e memorizza l'UUID in una variabile di sessioneget_username()
– legge l'UUID da una variabile di sessione e cerca la riga nella tabella (errori se nessuna riga corrispondente)
Questo approccio sostituisce la protezione della firma con la casualità dell'UUID:l'utente può modificare la variabile di sessione, ma la probabilità di ottenere un ID esistente è trascurabile (gli UUID sono valori casuali a 128 bit).
È un approccio un po' più tradizionale, basato sulla tradizionale sicurezza basata sui ruoli, ma presenta anche alcuni svantaggi:ad esempio, esegue effettivamente le scritture di database, il che significa che è intrinsecamente incompatibile con i sistemi hot standby.
Sbarazzarsi della passphrase
È anche possibile progettare il vault in modo che la passphrase non sia necessaria. L'abbiamo introdotto perché presupponevamo set_username
avviene sulla stessa connessione:dobbiamo mantenere la funzione eseguibile (quindi pasticciare con ruoli o privilegi non è una soluzione) e la passphrase garantisce che solo il componente affidabile possa effettivamente utilizzarla.
Ma cosa succede se la firma/creazione della sessione avviene su una connessione separata e solo il risultato (valore firmato o UUID della sessione) viene copiato nella connessione consegnata all'utente? Bene, allora non abbiamo più bisogno della passphrase. (È un po' simile a quello che fa Kerberos:generare un ticket su una connessione affidabile, quindi utilizzare il ticket per altri servizi.)
Riepilogo
Quindi permettetemi di ricapitolare rapidamente questo post del blog:
- Mentre tutti gli esempi RLS utilizzano utenti di database (tramite
current_user
), non è molto difficile far funzionare RLS con gli utenti dell'applicazione. - Le variabili di sessione sono una soluzione affidabile e abbastanza semplice, presupponendo che il sistema disponga di un componente affidabile in grado di impostare la variabile prima di passare la connessione a un utente.
- Quando l'utente può eseguire SQL arbitrario (in base alla progettazione o grazie a una vulnerabilità), una variabile firmata impedisce all'utente di modificare il valore.
- Sono possibili altre soluzioni, ad es. sostituendo le variabili di sessione con una tabella che memorizza informazioni sulle sessioni identificate da un UUID casuale.
- Una cosa interessante è che le variabili di sessione non eseguono scritture nel database, quindi questo approccio può funzionare su sistemi di sola lettura (es. hot standby).
Nella parte successiva di questa serie di blog esamineremo l'utilizzo degli utenti dell'applicazione quando il sistema non ha un componente affidabile (quindi non può impostare la variabile di sessione o creare una riga nelle sessions
tabella), o quando vogliamo eseguire un'autenticazione personalizzata (aggiuntiva) all'interno del database.