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

Usuarios de aplicaciones frente a seguridad de nivel de fila

Hace unos días escribí en un blog sobre los problemas comunes con las funciones y los privilegios que descubrimos durante las revisiones de seguridad.

Por supuesto, PostgreSQL ofrece muchas funciones avanzadas relacionadas con la seguridad, una de ellas es la seguridad de nivel de fila (RLS), disponible desde PostgreSQL 9.5.

Como se lanzó 9.5 en enero de 2016 (hace solo unos meses), RLS es una función bastante nueva y todavía no estamos lidiando con muchas implementaciones de producción. En cambio, RLS es un tema común de discusiones sobre "cómo implementar", y una de las preguntas más comunes es cómo hacer que funcione con usuarios de nivel de aplicación. Así que veamos qué posibles soluciones hay.

Introducción al RLS

Veamos primero un ejemplo muy simple, explicando de qué se trata RLS. Digamos que tenemos un chat tabla que almacena mensajes enviados entre usuarios:los usuarios pueden insertar filas en ella para enviar mensajes a otros usuarios y consultarla para ver los mensajes que otros usuarios les envían. Así que la tabla podría verse así:

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 clásica seguridad basada en roles solo nos permite restringir el acceso a toda la tabla o a secciones verticales de la misma (columnas). Por lo tanto, no podemos usarlo para evitar que los usuarios lean mensajes dirigidos a otros usuarios o envíen mensajes con un message_from falso. campo.

Y para eso es exactamente RLS:le permite crear reglas (políticas) que restringen el acceso a subconjuntos de filas. Entonces, por ejemplo, puedes hacer esto:

CREATE POLICY chat_policy ON chat
    USING ((message_to = current_user) OR (message_from = current_user))
    WITH CHECK (message_from = current_user)

Esta política garantiza que un usuario solo pueda ver los mensajes enviados por él o destinados a él; esa es la condición en USING la cláusula lo hace. La segunda parte de la póliza (WITH CHECK ) asegura que un usuario solo puede insertar mensajes con su nombre de usuario en message_from columna, previniendo mensajes con remitente falsificado.

También puede imaginar RLS como una forma automática de agregar condiciones WHERE adicionales. Podría hacerlo manualmente en el nivel de la aplicación (y antes de que la gente de RLS lo hiciera a menudo), pero RLS lo hace de una manera confiable y segura (por ejemplo, se hizo un gran esfuerzo para evitar varias fugas de información).

Nota :antes de RLS, una forma popular de lograr algo similar era hacer que la tabla fuera inaccesible directamente (revocar todos los privilegios) y proporcionar un conjunto de funciones de definición de seguridad para acceder a ella. Eso logró en su mayoría el mismo objetivo, pero las funciones tienen varias desventajas:tienden a confundir al optimizador y limitan seriamente la flexibilidad (si el usuario necesita hacer algo y no hay una función adecuada para ello, no tiene suerte). Y por supuesto, tienes que escribir esas funciones.

Usuarios de la aplicación

Si lee la documentación oficial sobre RLS, puede notar un detalle:todos los ejemplos usan current_user , es decir, el usuario actual de la base de datos. Pero no es así como funcionan la mayoría de las aplicaciones de bases de datos en estos días. Las aplicaciones web con muchos usuarios registrados no mantienen una asignación 1:1 a los usuarios de la base de datos, sino que utilizan un solo usuario de la base de datos para ejecutar consultas y administrar los usuarios de la aplicación por su cuenta, tal vez en un users. mesa.

Técnicamente, no es un problema crear muchos usuarios de bases de datos en PostgreSQL. La base de datos debería manejar eso sin ningún problema, pero las aplicaciones no lo hacen por varias razones prácticas. Por ejemplo, necesitan rastrear información adicional para cada usuario (por ejemplo, departamento, posición dentro de la organización, detalles de contacto, …), por lo que la aplicación necesitaría los users mesa de todos modos.

Otra razón puede ser la agrupación de conexiones:usar una sola cuenta de usuario compartida, aunque sabemos que se puede resolver usando la herencia y SET ROLE (ver la publicación anterior).

Pero supongamos que no desea crear usuarios de base de datos separados; desea seguir usando una sola cuenta de base de datos compartida y usar RLS con usuarios de aplicaciones. ¿Cómo hacer eso?

Variables de sesión

Esencialmente, lo que necesitamos es pasar contexto adicional a la sesión de la base de datos, para que luego podamos usarlo desde la política de seguridad (en lugar del current_user variable). Y la forma más sencilla de hacerlo en PostgreSQL son las variables de sesión:

SET my.username = 'tomas'

Si esto se asemeja a los parámetros de configuración habituales (por ejemplo, SET work_mem = '...' ), tienes toda la razón, es casi lo mismo. El comando define un nuevo espacio de nombres (my ), y agrega un username variable en él. El nuevo espacio de nombres es obligatorio, ya que el global está reservado para la configuración del servidor y no podemos agregarle nuevas variables. Esto nos permite cambiar la política de seguridad de esta manera:

CREATE POLICY chat_policy ON chat
    USING (current_setting('my.username') IN (message_from, message_to))
    WITH CHECK (message_from = current_setting('my.username'))

Todo lo que tenemos que hacer es asegurarnos de que el conjunto de conexiones/la aplicación establezca el nombre de usuario cada vez que obtenga una nueva conexión y lo asigne a la tarea del usuario.

Permítanme señalar que este enfoque colapsa una vez que permite que los usuarios ejecuten SQL arbitrario en la conexión, o si el usuario logra descubrir una vulnerabilidad de inyección de SQL adecuada. En ese caso, no hay nada que pueda evitar que establezcan un nombre de usuario arbitrario. Pero no se desespere, hay un montón de soluciones a ese problema, y ​​las analizaremos rápidamente.

Variables de sesión firmadas

La primera solución es una mejora simple de las variables de sesión:en realidad no podemos evitar que los usuarios establezcan un valor arbitrario, pero ¿qué pasaría si pudiéramos verificar que el valor no se subvirtió? Eso es bastante fácil de hacer usando una simple firma digital. En lugar de simplemente almacenar el nombre de usuario, la parte de confianza (grupo de conexiones, aplicación) puede hacer algo como esto:

signature = sha256(username + timestamp + SECRET)

y luego almacene tanto el valor como la firma en la variable de sesión:

SET my.username = 'username:timestamp:signature'

Suponiendo que el usuario no conoce la cadena SECRET (por ejemplo, 128B de datos aleatorios), no debería ser posible modificar el valor sin invalidar la firma.

Nota :Esta no es una idea nueva, es esencialmente lo mismo que las cookies HTTP firmadas. Django tiene una documentación bastante buena sobre eso.

La forma más fácil de proteger el valor SECRET es almacenarlo en una tabla inaccesible para el usuario y proporcionar un security definer función, que requiere una contraseña (para que el usuario no pueda simplemente firmar valores arbitrarios).

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 función simplemente busca la clave de firma (secreto) en una tabla, calcula la firma y luego establece el valor en la variable de sesión. También devuelve el valor, principalmente por conveniencia.

Entonces, la parte de confianza puede hacer esto justo antes de entregar la conexión al usuario (obviamente, la "frase de contraseña" no es una contraseña muy buena para la producción):

SELECT set_username('tomas', 'passphrase')

Y luego, por supuesto, necesitamos otra función que simplemente verifique la firma y arroje un error o devuelva el nombre de usuario si la firma coincide.

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;

Y como esta función no necesita la frase de contraseña, el usuario simplemente puede hacer esto:

SELECT get_username()

Pero el get_username() La función está destinada a las políticas de seguridad, p. así:

CREATE POLICY chat_policy ON chat
    USING (get_username() IN (message_from, message_to))
    WITH CHECK (message_from = get_username())

Puede encontrar un ejemplo más completo, empaquetado como una extensión simple, aquí.

Observe que todos los objetos (tabla y funciones) pertenecen a un usuario privilegiado, no al usuario que accede a la base de datos. El usuario solo tiene EXECUTE privilegio sobre las funciones, que sin embargo se definen como SECURITY DEFINER . Eso es lo que hace que este esquema funcione mientras protege el secreto del usuario. Las funciones se definen como STABLE , para limitar el número de llamadas a crypt() función (que es intencionalmente costosa para evitar la fuerza bruta).

Las funciones de ejemplo definitivamente necesitan más trabajo. Pero espero que sea lo suficientemente bueno para una prueba de concepto que demuestre cómo almacenar contexto adicional en una variable de sesión protegida.

¿Qué necesita ser arreglado que usted pide? En primer lugar, las funciones no manejan muy bien varias condiciones de error. En segundo lugar, si bien el valor firmado incluye una marca de tiempo, en realidad no estamos haciendo nada con él; por ejemplo, puede usarse para caducar el valor. Es posible agregar bits adicionales al valor, p. un departamento del usuario, o incluso información sobre la sesión (por ejemplo, PID del proceso de back-end para evitar reutilizar el mismo valor en otras conexiones).

Cripto

Las dos funciones se basan en la criptografía:no usamos mucho, excepto algunas funciones simples de hashing, pero sigue siendo un esquema criptográfico simple. Y todos saben que no debes hacer tu propia criptografía. Es por eso que usé la extensión pgcrypto, particularmente crypt() función, para solucionar este problema. Pero no soy un criptógrafo, así que aunque creo que todo el esquema está bien, tal vez me esté perdiendo algo. Avísame si detectas algo.

Además, la firma sería una gran combinación para la criptografía de clave pública:podríamos usar una clave PGP normal con una frase de contraseña para la firma y la parte pública para la verificación de la firma. Lamentablemente, aunque pgcrypto admite PGP para el cifrado, no admite la firma.

Enfoques alternativos

Por supuesto, hay varias soluciones alternativas. Por ejemplo, en lugar de almacenar el secreto de firma en una tabla, puede codificarlo en la función (pero luego debe asegurarse de que el usuario no pueda ver el código fuente). O puede hacer la firma en una función C, en cuyo caso está oculta para todos los que no tienen acceso a la memoria (en cuyo caso la perdió de todos modos).

Además, si no le gusta el enfoque de firma, puede reemplazar la variable firmada con una solución de "bóveda" más tradicional. Necesitamos una forma de almacenar los datos, pero debemos asegurarnos de que el usuario no pueda ver o modificar los contenidos arbitrariamente, excepto de una manera definida. Pero bueno, eso es lo que implementan las tablas regulares con una API usando security definer funciones pueden hacer!

No voy a presentar todo el ejemplo modificado aquí (consulte esta extensión para ver un ejemplo completo), pero lo que necesitamos son sessions mesa que actúa como bóveda:

CREATE TABLE sessions (
    session_id    UUID PRIMARY KEY,
    session_user  NAME NOT NULL
)

Los usuarios regulares de la base de datos no deben tener acceso a la tabla:un simple REVOKE ALL FROM ... debería encargarse de eso. Y luego una API que consta de dos funciones principales:

  • set_username(user_name, passphrase) – genera un UUID aleatorio, inserta datos en la bóveda y almacena el UUID en una variable de sesión
  • get_username() – lee el UUID de una variable de sesión y busca la fila en la tabla (errores si no hay una fila coincidente)

Este enfoque reemplaza la protección de la firma con la aleatoriedad del UUID:el usuario puede modificar la variable de sesión, pero la probabilidad de acceder a un ID existente es insignificante (los UUID son valores aleatorios de 128 bits).

Es un enfoque un poco más tradicional, que se basa en la seguridad tradicional basada en roles, pero también tiene algunas desventajas; por ejemplo, en realidad escribe en la base de datos, lo que significa que es intrínsecamente incompatible con los sistemas de espera activa.

Deshacerse de la frase de contraseña

También es posible diseñar la bóveda para que la frase de contraseña no sea necesaria. Lo hemos introducido porque asumimos set_username sucede en la misma conexión:tenemos que mantener la función ejecutable (por lo que jugar con roles o privilegios no es una solución), y la frase de contraseña garantiza que solo el componente confiable pueda usarla.

Pero, ¿qué sucede si la firma/creación de la sesión ocurre en una conexión separada y solo el resultado (el valor firmado o el UUID de la sesión) se copia en la conexión entregada al usuario? Bueno, entonces ya no necesitamos la frase de contraseña. (Es un poco similar a lo que hace Kerberos:generar un ticket en una conexión confiable y luego usar el ticket para otros servicios).

Resumen

Permítanme resumir rápidamente esta publicación de blog:

  • Si bien todos los ejemplos de RLS usan usuarios de bases de datos (por medio de current_user ), no es muy difícil hacer que RLS funcione con los usuarios de la aplicación.
  • Las variables de sesión son una solución confiable y bastante simple, suponiendo que el sistema tenga un componente confiable que pueda establecer la variable antes de entregar la conexión a un usuario.
  • Cuando el usuario puede ejecutar SQL arbitrario (ya sea por diseño o gracias a una vulnerabilidad), una variable firmada evita que el usuario cambie el valor.
  • Otras soluciones son posibles, p. reemplazando las variables de sesión con tablas que almacenan información sobre sesiones identificadas por UUID aleatorio.
  • Lo bueno es que las variables de sesión no escriben en la base de datos, por lo que este enfoque puede funcionar en sistemas de solo lectura (por ejemplo, espera activa).

En la siguiente parte de esta serie de blogs, veremos el uso de usuarios de aplicaciones cuando el sistema no tiene un componente confiable (por lo que no puede establecer la variable de sesión o crear una fila en las sessions). table), o cuando queremos realizar una autenticación personalizada (adicional) dentro de la base de datos.