PostgreSQL
 sql >> Database >  >> RDS >> PostgreSQL

Autoprovisioning degli account utente in PostgreSQL tramite accesso anonimo non privilegiato

Nota di Diversinines:questo blog è stato pubblicato postumo alla morte di Berend Tober il 16 luglio 2018. Onoriamo i suoi contributi alla comunità di PostgreSQL e auguriamo pace al nostro amico e scrittore ospite.

Nell'articolo precedente abbiamo introdotto le basi dei trigger PostgreSQL e delle funzioni memorizzate e fornito sei casi d'uso di esempio tra cui la convalida dei dati, la registrazione delle modifiche, la derivazione di valori dai dati inseriti, l'occultamento dei dati con semplici viste aggiornabili, il mantenimento dei dati di riepilogo in tabelle separate e chiamata sicura del codice con privilegi elevati. Questo articolo si basa ulteriormente su tale base e presenta una tecnica che utilizza un trigger e una funzione archiviata per facilitare la delega del provisioning delle credenziali di accesso a ruoli con privilegi limitati (ovvero non superutente). Questa funzione può essere utilizzata per ridurre il carico di lavoro amministrativo per il personale di amministrazione di sistema di alto valore. Portato all'estremo, dimostriamo l'autoprovisioning anonimo delle credenziali di accesso da parte dell'utente finale, ovvero consentendo ai potenziali utenti del database di fornire le credenziali di accesso autonomamente implementando "SQL dinamico" all'interno di una funzione archiviata eseguita a livello di privilegio con ambito appropriato.Introduzione

Utile lettura in background

Il recente articolo di Sebastian Insausti su Come proteggere il database PostgreSQL include alcuni suggerimenti molto importanti con cui dovresti avere familiarità, vale a dire i suggerimenti n. 1 - n. 5 sul controllo dell'autenticazione del client, sulla configurazione del server, sulla gestione di utenti e ruoli, sulla gestione dei super utenti e Crittografia dei dati. Utilizzeremo parti di ogni suggerimento in questo articolo.

Un altro articolo recente di Joshua Otwell sui privilegi di PostgreSQL e sulla gestione degli utenti ha anche un buon trattamento della configurazione dell'host e dei privilegi dell'utente che entra un po' più in dettaglio su questi due argomenti.

Protezione del traffico di rete

La funzionalità proposta prevede che gli utenti forniscano le credenziali di accesso al database e, mentre lo fanno, specificheranno il loro nuovo nome di accesso e password sulla rete. La protezione di questa comunicazione di rete è essenziale e può essere ottenuta configurando il server PostgreSQL in modo che supporti e richieda connessioni crittografate. La sicurezza del livello di trasporto è abilitata nel file postgresql.conf dall'impostazione "ssl":

ssl = on

Controllo dell'accesso basato su host

Per il caso in esame, aggiungeremo una riga di configurazione dell'accesso basata su host nel file pg_hba.conf che consente l'accesso anonimo, ovvero attendibile, al database da una sottorete appropriata per la popolazione di potenziali utenti del database utilizzando letteralmente il nome utente "anonimo" e una seconda riga di configurazione che richiede l'accesso con password per qualsiasi altro nome di accesso. Ricorda che le configurazioni dell'host richiamano la prima corrispondenza, quindi la prima riga verrà applicata ogni volta che viene specificato il nome utente "anonimo", consentendo una connessione attendibile (ovvero, non è richiesta alcuna password), e successivamente ogni volta che viene specificato un altro nome utente verrà richiesta una password. Ad esempio, se il database di esempio "sampledb" deve essere utilizzato, ad esempio, solo dai dipendenti e internamente alle strutture aziendali, è possibile configurare l'accesso attendibile per alcune sottoreti interne non instradabili con:

# TYPE  DATABASE USER      ADDRESS        METHOD
hostssl sampledb anonymous 192.168.1.0/24 trust
hostssl sampledb all       192.168.1.0/24 md5

Se il database deve essere reso generalmente disponibile al pubblico, è possibile configurare l'accesso a "qualsiasi indirizzo":

# TYPE  DATABASE USER       ADDRESS  METHOD
hostssl sampledb anonymous  all      trust
hostssl sampledb all        all      md5

Nota quanto sopra è potenzialmente pericoloso senza ulteriori precauzioni, possibilmente nella progettazione dell'applicazione o su un dispositivo firewall, per limitare l'uso di questa funzione, perché sai che alcuni script kiddie automatizzeranno la creazione infinita di account solo per lulz.

Si noti inoltre che abbiamo specificato il tipo di connessione come "hostssl", il che significa che le connessioni effettuate tramite TCP/IP hanno esito positivo solo quando la connessione viene effettuata con crittografia SSL in modo da proteggere il traffico di rete dalle intercettazioni.

Blocco dello schema pubblico

Dal momento che stiamo consentendo a persone possibilmente sconosciute (cioè non attendibili) di accedere al database, vorremo essere sicuri che gli accessi predefiniti siano limitati. Una misura importante consiste nel revocare il privilegio di creazione di oggetti dello schema pubblico predefinito in modo da mitigare una vulnerabilità PostgreSQL pubblicata di recente relativa ai privilegi dello schema predefinito (cfr. Bloccare lo schema pubblico in modo sincero).

Un database di esempio

Inizieremo con un database di esempio vuoto a scopo illustrativo:

create database sampledb;
\connect sampledb

revoke create on schema public from public;
alter default privileges revoke all privileges on tables from public;

Creiamo anche il ruolo di accesso anonimo corrispondente alla precedente impostazione pg_hba.conf.

create role anonymous login
    nosuperuser 
    noinherit 
    nocreatedb 
    nocreaterole 
    Noreplication;

E poi facciamo qualcosa di nuovo definendo una visione non convenzionale:

create or replace view person as 
 select 
    null::name as login_name,
    null::name as login_pass;

Questa vista non fa riferimento a nessuna tabella e quindi una query select restituisce sempre una riga vuota:

select * from person;
 login_name | login_pass 
------------+-------------
            | 
(1 row)

Una cosa che questo fa per noi è in un certo senso fornire documentazione o un suggerimento agli utenti finali su quali dati sono necessari per creare un account. Cioè, interrogando la tabella, anche se il risultato è una riga vuota, il risultato rivela i nomi dei due elementi di dati.

Ma ancora meglio, l'esistenza di questa vista consente la determinazione dei tipi di dati richiesti:

\d person
      View "public.person"
    Column    | Type | Modifiers 
--------------+------+-----------
 login_name   | name | 
 login_pass   | name | 

Implementeremo la funzionalità di provisioning delle credenziali con una funzione e un trigger archiviati, quindi dichiariamo un modello di funzione vuoto e il trigger associato:

create or replace function person_iit()
  returns trigger
  set schema 'public'
  language plpgsql
  security definer
  as '
  begin
  end;
  ';

create trigger person_iit
  instead of insert
  on person
  for each row execute procedure person_iit();

Si noti che stiamo seguendo la convenzione di denominazione proposta dall'articolo precedente, utilizzando il nome della tabella associato suffisso con un'abbreviazione abbreviata che denota gli attributi della relazione di trigger tra la tabella e la funzione memorizzata per un trigger INSTEAD OF INSERT (cioè, suffisso " io esso"). Abbiamo anche aggiunto alla funzione memorizzata gli attributi SCHEMA e SECURITY DEFINER:il primo perché è buona norma impostare il percorso di ricerca che si applica alla durata dell'esecuzione della funzione, il secondo per facilitare la creazione del ruolo, che normalmente è un'autorità di superutente del database solo ma in questo caso sarà delegato agli utenti anonimi.

E infine aggiungiamo autorizzazioni minimamente sufficienti sulla vista per interrogare e inserire:

grant select, insert on table person to anonymous;
Scarica il whitepaper oggi Gestione e automazione di PostgreSQL con ClusterControlScopri cosa devi sapere per distribuire, monitorare, gestire e ridimensionare PostgreSQLScarica il whitepaper

Rivediamo

Prima di implementare il codice della funzione memorizzato, esaminiamo ciò che abbiamo. Innanzitutto c'è il database di esempio di proprietà dell'utente postgres:

\l
                                  List of databases
   Name    |  Owner   | Encoding |   Collate   |    Ctype    |   Access privileges   
-----------+----------+----------+-------------+-------------+-----------------------
 sampledb  | postgres | UTF8     | en_US.UTF-8 | en_US.UTF-8 | 
And there’s the user roles, including the database superuser and the newly-created anonymous login roles:
\du
                                   List of roles
 Role name |                         Attributes                         | Member of 
-----------+------------------------------------------------------------+-----------
 anonymous | No inheritance                                             | {}
 postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS | {}

E c'è la vista che abbiamo creato e un elenco dei privilegi di accesso in creazione e lettura concessi all'utente anonimo dall'utente postgres:

\d
         List of relations
 Schema |  Name  | Type |  Owner   
--------+--------+------+----------
 public | person | view | postgres
(1 row)


\dp
                                Access privileges
 Schema |  Name  | Type |     Access privileges     | Column privileges | Policies 
--------+--------+------+---------------------------+-------------------+----------
 public | person | view | postgres=arwdDxt/postgres+|                   | 
        |        |      | anonymous=ar/postgres     |                   | 
(1 row)

Infine, il dettaglio della tabella mostra i nomi delle colonne e i tipi di dati, nonché il trigger associato:

\d person
      View "public.person"
    Column    | Type | Modifiers 
--------------+------+-----------
 login_name   | name | 
 login_pass   | name | 
Triggers:
    person_iit INSTEAD OF INSERT ON person FOR EACH ROW EXECUTE PROCEDURE person_iit()

SQL dinamico

Utilizzeremo SQL dinamico, ovvero costruendo la forma finale di un'istruzione DDL in fase di esecuzione in parte dai dati inseriti dall'utente, per compilare il corpo della funzione trigger. In particolare, codificamo in modo rigido lo schema dell'istruzione per creare un nuovo ruolo di accesso e compilare i parametri specifici come variabili.

La forma generale di questo comando è

create role name [ [ with ] option [ ... ] ]

dove opzione può essere una qualsiasi delle sedici proprietà specifiche. In genere le impostazioni predefinite sono appropriate, ma saremo espliciti su diverse opzioni di limitazione e utilizzeremo il modulo

create role name 
  with 
    login 
    inherit 
    nosuperuser 
    nocreatedb 
    nocreaterole 
    password ‘password’;

dove inseriremo il nome del ruolo e la password specificati dall'utente in fase di esecuzione.

Le istruzioni costruite dinamicamente vengono invocate con il comando di esecuzione:

execute command-string [ INTO [STRICT] target ] [ USING expression [, ... ] ];

che per le nostre esigenze specifiche assomiglierebbe a

  execute 'create role '
    || new.login_name
    || ' with login inherit nosuperuser nocreatedb nocreaterole password '
    || quote_literal(new.login_pass);

dove la funzione quote_literal restituisce l'argomento stringa opportunamente citato per essere utilizzato come letterale stringa in modo da rispettare il requisito sintattico che la password sia effettivamente citata.

Una volta creata la stringa di comando, la forniamo come argomento al comando pl/pgsql execute all'interno della funzione trigger.

Mettere insieme tutto questo sembra:

create or replace function person_iit()
  returns trigger
  set schema 'public'
  language plpgsql
  security definer
  as $$
  begin

  -- note this is for demonstration only. it is vulnerable to sql injection.

  execute 'create role '
    || new.login_name
    || ' with login inherit nosuperuser nocreatedb nocreaterole password '
    || quote_literal(new.login_pass);

  return new;
  end;
  $$;

Proviamo!

Tutto è a posto, quindi diamoci una mossa! Per prima cosa cambiamo l'autorizzazione di sessione per l'utente anonimo e quindi eseguiamo un inserimento contro la vista persona:

set session authorization anonymous;
insert into person values ('alice', '1234');

Il risultato è che il nuovo utente alice è stato aggiunto alla tabella di sistema:

\du
                                   List of roles
 Role name |                         Attributes                         | Member of 
-----------+------------------------------------------------------------+-----------
 alice     |                                                            | {}
 anonymous | No inheritance                                             | {}
 postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS | {}

Funziona anche direttamente dalla riga di comando del sistema operativo collegando una stringa di comando SQL all'utilità client psql per aggiungere l'utente bob:

$ psql sampledb anonymous <<< "insert into person values ('bob', '4321');"
INSERT 0 1

$ psql sampledb anonymous <<< "\du"
                                   List of roles
 Role name |                         Attributes                         | Member of 
-----------+------------------------------------------------------------+-----------
 alice     |                                                            | {}
 anonymous | No inheritance                                             | {}
 bob       |                                                            | {}
 postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS | {}

Applica un po' di armatura

L'esempio iniziale della funzione trigger è vulnerabile all'attacco SQL injection, ovvero un attore di minacce dannose potrebbe creare input che si traducono in un accesso non autorizzato. Ad esempio, mentre si è connessi come ruolo utente anonimo, un tentativo di eseguire qualcosa fuori dall'ambito non riesce correttamente:

set session authorization anonymous;
drop user alice;
ERROR:  permission denied to drop role

Ma il seguente input dannoso crea un ruolo di superutente chiamato "eve" (oltre a un account esca chiamato "cathy"):

insert into person 
  values ('eve with superuser login password ''666''; create role cathy', '777');
\du
                                   List of roles
 Role name |                         Attributes                         | Member of 
-----------+------------------------------------------------------------+-----------
 alice     |                                                            | {}
 anonymous | No inheritance                                             | {}
 cathy     |                                                            | {}
 eve       | Superuser                                                  | {}
 postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS | {}

Quindi il ruolo di superutente surrettizio può essere utilizzato per devastare il database, ad esempio eliminando gli account utente (o peggio!):

\c - eve
drop user alice;
\du
                                   List of roles
 Role name |                         Attributes                         | Member of 
-----------+------------------------------------------------------------+-----------
 anonymous | No inheritance                                             | {}
 cathy     |                                                            | {}
 eve       | Superuser                                                  | {}
 postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS | {}

Per mitigare questa vulnerabilità, dobbiamo adottare misure per disinfettare l'input. Ad esempio, applicando la funzione quote_ident, che restituisce una stringa opportunamente citata per essere utilizzata come identificatore in un'istruzione SQL con virgolette aggiunte quando necessario, ad esempio se la stringa contiene caratteri non identificativi o se fosse piegata tra maiuscole e minuscole, e raddoppiando correttamente incorporata virgolette:

create or replace function person_iit()
  returns trigger
  set schema 'public'
  language plpgsql
  security definer
  as $$
  begin

  execute 'create role '
    || quote_ident(new.login_name)
    || ' with login inherit nosuperuser nocreatedb nocreaterole password '
    || quote_literal(new.login_pass);

  return new;
  end;
  $$;

Ora, se lo stesso exploit SQL injection viene tentato di creare un altro superutente chiamato "frank", fallisce e il risultato è un nome utente molto poco ortodosso:

set session authorization anonymous;
insert into person 
  values ('frank with superuser login password ''666''; create role dave', '777');
\du
                                 List of roles
    Role name          |                         Attributes                         | Member of 
-----------------------+------------------------------------------------------------+----------
 anonymous             | No inheritance                                             | {}
 eve                   | Superuser                                                  | {}
 frank with superuser  |                                                            |
  login password '666';|                                                            |
  create role dave     |                                                            |
 postgres              | Superuser, Create role, Create DB, Replication, Bypass RLS | {}

Possiamo applicare un'ulteriore convalida dei dati sensibili all'interno della funzione di attivazione, ad esempio richiedere solo nomi utente alfanumerici e rifiutare spazi bianchi e altri caratteri:

create or replace function person_iit()
  returns trigger
  set schema 'public'
  language plpgsql
  security definer
  as $$
  begin

  -- Basic input sanitization

  if new.login_name is null then
    raise exception 'null login_name disallowed';
  elsif position(' ' in new.login_name) > 0 then
    raise exception 'login_name whitespace disallowed';
  elsif length(new.login_name) = 0 then
    raise exception 'login_name must be non-empty';
  elsif not (select new.login_name similar to '[A-Za-z]%') then
    raise exception 'login_name must begin with a letter.';
  end if;

  if new.login_pass is null then
    raise exception 'null login_pass disallowed';
  elsif position(' ' in new.login_pass) > 0 then
    raise exception 'login_pass whitespace disallowed';
  elsif length(new.login_pass) = 0 then
    raise exception 'login_pass must be non-empty';
  end if;

  -- Provision login credentials

  execute 'create role '
    || quote_ident(new.login_name)
    || ' with login inherit nosuperuser nocreatedb nocreaterole password '
    || quote_literal(new.login_pass);

  return new;
  end;
  $$;

e poi conferma che i vari controlli di sanificazione funzionano:

set session authorization anonymous;
insert into person values (NULL, NULL);
ERROR:  null login_name disallowed
insert into person values ('gina', NULL);
ERROR:  null login_pass disallowed
insert into person values ('gina', '');
ERROR:  login_pass must be non-empty
insert into person values ('', '1234');
ERROR:  login_name must be non-empty
insert into person values ('gi na', '1234');
ERROR:  login_name whitespace disallowed
insert into person values ('1gina', '1234');
ERROR:  login_name must begin with a letter.

Facciamo un salto di qualità

Si supponga di voler archiviare ulteriori metadati o dati dell'applicazione relativi al ruolo utente creato, ad esempio un timestamp e un indirizzo IP di origine associati alla creazione del ruolo. La vista ovviamente non può soddisfare questo nuovo requisito poiché non c'è spazio di archiviazione sottostante, quindi è necessaria una tabella effettiva. Inoltre, supponiamo inoltre di voler limitare la visibilità di quella tabella agli utenti che accedono con il ruolo di accesso anonimo. Possiamo nascondere la tabella in uno spazio dei nomi separato (cioè uno schema PostgreSQL) che rimane inaccessibile agli utenti anonimi. Chiamiamo questo spazio dei nomi lo spazio dei nomi "privato" e creiamo la tabella nello spazio dei nomi:

create schema private;

create table private.person (
  login_name   name not null primary key,
  inet_client_addr inet default inet_client_addr(),
  create_time timestamptz default now()  
);

Un semplice comando di inserimento aggiuntivo all'interno della funzione trigger registra questi metadati associati:

create or replace function person_iit()
  returns trigger
  set schema 'public'
  language plpgsql
  security definer
  as $$
  begin

  -- Basic input sanitization
  if new.login_name is null then
    raise exception 'null login_name disallowed';
  elsif position(' ' in new.login_name) > 0 then
    raise exception 'login_name whitespace disallowed';
  elsif length(new.login_name) = 0 then
    raise exception 'login_name must be non-empty';
  elsif not (select new.login_name similar to '[A-Za-z]%') then
    raise exception 'login_name must begin with a letter.';
  end if;

  if new.login_pass is null then
    raise exception 'null login_pass disallowed';
  elsif length(new.login_pass) = 0 then
    raise exception 'login_pass must be non-empty';
  end if;

  -- Record associated metadata
  insert into private.person values (new.login_name);

  -- Provision login credentials

  execute 'create role '
    || quote_ident(new.login_name)
    || ' with login inherit nosuperuser nocreatedb nocreaterole password '
    || quote_literal(new.login_pass);

  return new;
  end;
  $$;

E possiamo fare un test facile. Per prima cosa confermiamo che mentre connesso come ruolo anonimo è visibile solo la vista public.person e non la tabella private.person:

set session authorization anonymous;

\d
         List of relations
 Schema |  Name  | Type |  Owner   
--------+--------+------+----------
 public | person | view | postgres
(1 row)
                   
select * from private.person;
ERROR:  permission denied for schema private

E poi dopo un nuovo ruolo inserisci:

insert into person values ('gina', '1234');

reset session authorization;

select * from private.person;
 login_name | inet_client_addr |          create_time          
------------+------------------+-------------------------------
 gina       | 192.168.2.106    | 2018-06-24 07:56:13.838679-07
(1 row)

la tabella private.person mostra l'acquisizione dei metadati per l'indirizzo IP e il tempo di inserimento della riga.

Conclusione

In questo articolo, abbiamo dimostrato una tecnica per delegare il provisioning delle credenziali del ruolo PostgreSQL a ruoli non superutente. Sebbene l'esempio deleghi completamente la funzionalità di credenziali a utenti anonimi, un approccio simile potrebbe essere utilizzato per delegare parzialmente la funzionalità solo a personale fidato, pur mantenendo il vantaggio di scaricare questo lavoro dal database di alto valore o dal personale dell'amministratore di sistema. Abbiamo anche dimostrato una tecnica di accesso ai dati a più livelli utilizzando schemi PostgreSQL, esponendo o nascondendo in modo selettivo gli oggetti del database. Nel prossimo articolo di questa serie espanderemo la tecnica di accesso ai dati a più livelli per proporre un nuovo progetto di architettura di database per implementazioni di applicazioni.