sql >> Base de Datos >  >> RDS >> PostgreSQL

Autoaprovisionamiento de cuentas de usuario en PostgreSQL mediante acceso anónimo sin privilegios

Nota de Variousnines:este blog se publica póstumamente debido a la muerte de Berend Tober el 16 de julio de 2018. Honramos sus contribuciones a la comunidad de PostgreSQL y deseamos paz para nuestro amigo y escritor invitado.

En el artículo anterior, presentamos los conceptos básicos de los activadores y las funciones almacenadas de PostgreSQL y brindamos seis casos de uso de ejemplo, que incluyen la validación de datos, el registro de cambios, la obtención de valores a partir de datos insertados, la ocultación de datos con vistas actualizables simples y el mantenimiento de datos resumidos. en tablas separadas e invocación segura de código con privilegios elevados. Este artículo se basa más en esa base y presenta una técnica que utiliza un activador y una función almacenada para facilitar la delegación del aprovisionamiento de credenciales de inicio de sesión a roles con privilegios limitados (es decir, no superusuario). Esta característica podría usarse para reducir la carga de trabajo administrativo para el personal de administración de sistemas de alto valor. Llevado al extremo, demostramos el autoaprovisionamiento anónimo de las credenciales de inicio de sesión por parte del usuario final, es decir, permitir que los posibles usuarios de la base de datos proporcionen las credenciales de inicio de sesión por su cuenta mediante la implementación de "SQL dinámico" dentro de una función almacenada ejecutada en el nivel de privilegio de alcance adecuado.Introducción

Lectura de fondo útil

El artículo reciente de Sebastian Insausti sobre Cómo proteger su base de datos PostgreSQL incluye algunos consejos muy relevantes con los que debe estar familiarizado, a saber, Consejos n.° 1 a n.° 5 sobre control de autenticación de clientes, configuración del servidor, administración de roles y usuarios, administración de superusuarios y Cifrado de datos. Usaremos partes de cada consejo en este artículo.

Otro artículo reciente de Joshua Otwell sobre Privilegios y administración de usuarios de PostgreSQL también tiene un buen tratamiento de la configuración del host y los privilegios de usuario que profundiza un poco más en esos dos temas.

Protección del tráfico de red

La función propuesta implica permitir a los usuarios proporcionar credenciales de inicio de sesión en la base de datos y, al hacerlo, especificarán su nuevo nombre de inicio de sesión y contraseña en la red. La protección de esta comunicación de red es esencial y se puede lograr configurando el servidor PostgreSQL para admitir y requerir conexiones cifradas. La seguridad de la capa de transporte está habilitada en el archivo postgresql.conf mediante la configuración "ssl":

ssl = on

Control de acceso basado en host

Para el presente caso, agregaremos una línea de configuración de acceso basada en host en el archivo pg_hba.conf que permite el inicio de sesión anónimo, es decir, confiable, en la base de datos desde alguna subred apropiada para la población de posibles usuarios de la base de datos que literalmente usan el nombre de usuario. "anónimo" y una segunda línea de configuración que requiere un inicio de sesión con contraseña para cualquier otro nombre de inicio de sesión. Recuerde que las configuraciones de host invocan la primera coincidencia, por lo que la primera línea se aplicará cada vez que se especifique el nombre de usuario "anónimo", lo que permite una conexión confiable (es decir, no se requiere contraseña), y luego, cada vez que se especifique cualquier otro nombre de usuario, se requerirá una contraseña. Por ejemplo, si la base de datos de muestra "sampledb" se va a utilizar, digamos, solo por empleados e internamente en las instalaciones corporativas, entonces podemos configurar el acceso de confianza para alguna subred interna no enrutable con:

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

Si la base de datos se va a poner a disposición del público en general, podemos configurar el acceso a "cualquier dirección":

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

Tenga en cuenta que lo anterior es potencialmente peligroso sin precauciones adicionales, posiblemente en el diseño de la aplicación o en un dispositivo de firewall, para limitar el uso de esta función, porque sabe que algunos script kiddie automatizarán la creación interminable de cuentas solo para lulz.

Tenga en cuenta que también hemos especificado el tipo de conexión como "hostssl", lo que significa que las conexiones realizadas mediante TCP/IP solo tienen éxito cuando la conexión se realiza con encriptación SSL para proteger el tráfico de la red de escuchas ilegales.

Bloqueo del esquema público

Dado que estamos permitiendo que personas posiblemente desconocidas (es decir, que no son de confianza) accedan a la base de datos, queremos asegurarnos de que los accesos predeterminados tengan una capacidad limitada. Una medida importante es revocar el privilegio de creación de objeto de esquema público predeterminado para mitigar una vulnerabilidad de PostgreSQL publicada recientemente relacionada con los privilegios de esquema predeterminado (cf. Bloqueo del esquema público por su servidor).

Una base de datos de muestra

Comenzaremos con una base de datos de muestra vacía con fines ilustrativos:

create database sampledb;
\connect sampledb

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

También creamos el rol de inicio de sesión anónimo correspondiente a la configuración anterior de pg_hba.conf.

create role anonymous login
    nosuperuser 
    noinherit 
    nocreatedb 
    nocreaterole 
    Noreplication;

Y luego hacemos algo novedoso al definir una vista poco convencional:

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

Esta vista no hace referencia a ninguna tabla, por lo que una consulta de selección siempre devuelve una fila vacía:

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

Una cosa que esto hace por nosotros es, en cierto sentido, proporcionar documentación o una pista a los usuarios finales sobre qué datos se requieren para establecer una cuenta. Es decir, al consultar la tabla, aunque el resultado sea una fila vacía, el resultado revela los nombres de los dos elementos de datos.

Pero aún mejor, la existencia de esta vista permite determinar los tipos de datos requeridos:

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

Implementaremos la funcionalidad de aprovisionamiento de credenciales con una función almacenada y un disparador, así que declaremos una plantilla de función vacía y el disparador asociado:

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();

Tenga en cuenta que estamos siguiendo la convención de nomenclatura propuesta del artículo anterior, utilizando el nombre de la tabla asociada con el sufijo de una abreviatura abreviada que denota los atributos de la relación del activador entre la tabla y la función almacenada para un activador INSTEAD OF INSERT (es decir, el sufijo " iit”). También hemos agregado a la función almacenada los atributos SCHEMA y SECURITY DEFINER:el primero porque es una buena práctica establecer la ruta de búsqueda que se aplica durante la ejecución de la función, y el segundo para facilitar la creación de roles, que normalmente es una autoridad de superusuario de la base de datos. solo pero en este caso se delegará a usuarios anónimos.

Y, por último, agregamos permisos mínimamente suficientes en la vista para consultar e insertar:

grant select, insert on table person to anonymous;
Descargue el documento técnico hoy Administración y automatización de PostgreSQL con ClusterControlObtenga información sobre lo que necesita saber para implementar, monitorear, administrar y escalar PostgreSQLDescargar el documento técnico

Revisemos

Antes de implementar el código de función almacenado, revisemos lo que tenemos. Primero está la base de datos de muestra propiedad del usuario de 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 | {}

Y está la vista que creamos y una lista de privilegios de acceso de creación y lectura otorgados al usuario anónimo por el usuario de 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)

Por último, el detalle de la tabla muestra los nombres de las columnas y los tipos de datos, así como el disparador asociado:

\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 dinámico

Vamos a emplear SQL dinámico, es decir, construir la forma final de una instrucción DDL en tiempo de ejecución parcialmente a partir de datos ingresados ​​por el usuario, para completar el cuerpo de la función de activación. Específicamente codificamos el esquema de la declaración para crear un nuevo rol de inicio de sesión y completar los parámetros específicos como variables.

La forma general de este comando es

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

donde opción puede ser cualquiera de dieciséis propiedades específicas. En general, los valores predeterminados son apropiados, pero vamos a ser explícitos acerca de varias opciones de limitación y utilizaremos el formulario

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

donde insertaremos el nombre de rol y la contraseña especificados por el usuario en tiempo de ejecución.

Las declaraciones construidas dinámicamente se invocan con el comando de ejecución:

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

que para nuestras necesidades específicas sería

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

donde la función quote_literal devuelve el argumento de cadena entrecomillado adecuadamente para su uso como literal de cadena para cumplir con el requisito sintáctico de que la contraseña de hecho debe estar entrecomillada.

Una vez que hemos creado la cadena de comando, la proporcionamos como argumento para el comando de ejecución pl/pgsql dentro de la función de activación.

Juntando todo esto se ve así:

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;
  $$;

¡Vamos a intentarlo!

Todo está en su lugar, ¡así que démosle un giro! Primero cambiamos la autorización de sesión al usuario anónimo y luego hacemos una inserción en la vista de persona:

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

El resultado es que se ha agregado el nuevo usuario alice a la tabla del sistema:

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

Incluso funciona directamente desde la línea de comandos del sistema operativo mediante la canalización de una cadena de comandos SQL a la utilidad del cliente psql para agregar el usuario 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 | {}

Aplica algo de armadura

El ejemplo inicial de la función de activación es vulnerable a un ataque de inyección SQL, es decir, un actor de amenazas malicioso podría crear una entrada que resulte en un acceso no autorizado. Por ejemplo, mientras está conectado como el rol de usuario anónimo, un intento de hacer algo fuera del alcance falla apropiadamente:

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

Pero la siguiente entrada maliciosa crea un rol de superusuario llamado 'eva' (así como una cuenta señuelo llamada '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 | {}

Luego, el rol de superusuario subrepticio se puede usar para causar estragos en la base de datos, por ejemplo, eliminando cuentas de usuario (¡o algo peor!):

\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 | {}

Para mitigar esta vulnerabilidad, debemos tomar medidas para desinfectar la entrada. Por ejemplo, aplicar la función quote_ident, que devuelve una cadena entrecomillada adecuadamente para su uso como identificador en una declaración SQL con comillas agregadas cuando sea necesario, como si la cadena contiene caracteres que no son de identificación o si se doblarían entre mayúsculas y minúsculas, y duplicando correctamente incrustados comillas:

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;
  $$;

Ahora bien, si se intenta el mismo exploit de inyección SQL para crear otro superusuario llamado 'frank', falla y el resultado es un nombre de usuario muy poco ortodoxo:

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 | {}

Podemos aplicar una validación de datos sensible adicional dentro de la función de activación, como requerir solo nombres de usuario alfanuméricos y rechazar espacios en blanco y otros caracteres:

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;
  $$;

y luego confirme que los diversos controles de desinfección funcionan:

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.

Vamos a dar un paso más

Supongamos que queremos almacenar metadatos adicionales o datos de la aplicación relacionados con la función de usuario creada, por ejemplo, tal vez una marca de tiempo y una dirección IP de origen asociada con la creación de la función. La vista, por supuesto, no puede satisfacer este nuevo requisito ya que no hay almacenamiento subyacente, por lo que se requiere una tabla real. Además, supongamos que queremos restringir la visibilidad de esa tabla de los usuarios que inician sesión con el rol de inicio de sesión anónimo. Podemos ocultar la tabla en un espacio de nombres separado (es decir, un esquema de PostgreSQL) que permanece inaccesible para los usuarios anónimos. Llamemos a este espacio de nombres el espacio de nombres "privado" y creemos la tabla en el espacio de nombres:

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 simple comando de inserción adicional dentro de la función de activación registra estos metadatos asociados:

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;
  $$;

Y podemos darle una prueba fácil. Primero, confirmamos que mientras está conectado como el rol anónimo, solo la vista public.person es visible y no la tabla 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

Y luego, después de un nuevo rol, inserte:

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 tabla private.person muestra la captura de metadatos para la dirección IP y el tiempo de inserción de la fila.

Conclusión

En este artículo, hemos demostrado una técnica para delegar el aprovisionamiento de credenciales de roles de PostgreSQL a roles que no son de superusuario. Si bien el ejemplo delegó por completo la funcionalidad de acreditación a usuarios anónimos, se podría usar un enfoque similar para delegar parcialmente la funcionalidad solo a personal de confianza y, al mismo tiempo, conservar el beneficio de descargar este trabajo de la base de datos de alto valor o del personal administrador de sistemas. También demostramos una técnica de acceso a datos en capas utilizando esquemas de PostgreSQL, exponiendo u ocultando selectivamente objetos de la base de datos. En el próximo artículo de esta serie, ampliaremos la técnica de acceso a datos en capas para proponer un diseño de arquitectura de base de datos novedoso para implementaciones de aplicaciones.