PostgreSQL
 sql >> Base de données >  >> RDS >> PostgreSQL

Utilisateurs de l'application et sécurité au niveau de la ligne

Il y a quelques jours, j'ai rédigé un blog sur les problèmes courants liés aux rôles et aux privilèges que nous découvrons lors des examens de sécurité.

Bien sûr, PostgreSQL offre de nombreuses fonctionnalités avancées liées à la sécurité, l'une d'entre elles étant la sécurité au niveau des lignes (RLS), disponible depuis PostgreSQL 9.5.

Comme la version 9.5 est sortie en janvier 2016 (donc il y a quelques mois à peine), RLS est une fonctionnalité relativement nouvelle et nous n'avons pas encore vraiment affaire à de nombreux déploiements de production. Au lieu de cela, RLS est un sujet commun de discussions sur «comment mettre en œuvre», et l'une des questions les plus courantes est de savoir comment le faire fonctionner avec les utilisateurs au niveau de l'application. Voyons donc quelles sont les solutions possibles.

Présentation du RLS

Voyons d'abord un exemple très simple, expliquant ce qu'est RLS. Disons que nous avons une chat table stockant les messages envoyés entre les utilisateurs - les utilisateurs peuvent y insérer des lignes pour envoyer des messages à d'autres utilisateurs et l'interroger pour voir les messages qui leur sont envoyés par d'autres utilisateurs. Ainsi, le tableau pourrait ressembler à ceci :

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 sécurité classique basée sur les rôles nous permet uniquement de restreindre l'accès à l'ensemble du tableau ou à des tranches verticales de celui-ci (colonnes). Nous ne pouvons donc pas l'utiliser pour empêcher les utilisateurs de lire des messages destinés à d'autres utilisateurs ou d'envoyer des messages avec un faux message_from champ.

Et c'est exactement à cela que sert RLS - il vous permet de créer des règles (politiques) limitant l'accès à des sous-ensembles de lignes. Ainsi, par exemple, vous pouvez faire ceci :

CRÉER UNE POLITIQUE chat_policy SUR le chat EN UTILISANT ((message_to =current_user) OU (message_from =current_user)) AVEC VÉRIFICATION (message_from =current_user)

Cette politique garantit qu'un utilisateur ne peut voir que les messages qu'il a envoyés ou qui lui sont destinés - c'est ce que la condition dans USING clause le fait. La deuxième partie de la politique (WITH CHECK ) assure qu'un utilisateur ne peut insérer des messages qu'avec son nom d'utilisateur dans message_from colonne, empêchant les messages avec un expéditeur falsifié.

Vous pouvez également imaginer RLS comme un moyen automatique d'ajouter des conditions WHERE supplémentaires. Vous pouviez le faire manuellement au niveau de l'application (et avant que les gens de RLS ne le fassent souvent), mais RLS le fait de manière fiable et sûre (beaucoup d'efforts ont été déployés pour empêcher diverses fuites d'informations, par exemple).

Remarque :Avant RLS, un moyen courant d'obtenir quelque chose de similaire consistait à rendre la table inaccessible directement (révoquer tous les privilèges) et à fournir un ensemble de fonctions de définition de la sécurité pour y accéder. Cela atteint principalement le même objectif, mais les fonctions ont divers inconvénients - elles ont tendance à confondre l'optimiseur et limitent sérieusement la flexibilité (si l'utilisateur a besoin de faire quelque chose et qu'il n'y a pas de fonction appropriée pour cela, il n'a pas de chance). Et bien sûr, vous devez écrire ces fonctions.

Utilisateurs de l'application

Si vous lisez la documentation officielle sur RLS, vous remarquerez peut-être un détail - tous les exemples utilisent current_user , c'est-à-dire l'utilisateur actuel de la base de données. Mais ce n'est pas ainsi que fonctionnent la plupart des applications de base de données de nos jours. Les applications Web avec de nombreux utilisateurs enregistrés ne maintiennent pas un mappage 1:1 avec les utilisateurs de la base de données, mais utilisent à la place un seul utilisateur de la base de données pour exécuter des requêtes et gérer les utilisateurs de l'application par eux-mêmes - peut-être dans un users tableau.

Techniquement, ce n'est pas un problème de créer de nombreux utilisateurs de base de données dans PostgreSQL. La base de données devrait gérer cela sans aucun problème, mais les applications ne le font pas pour un certain nombre de raisons pratiques. Par exemple, ils doivent suivre des informations supplémentaires pour chaque utilisateur (par exemple, service, poste au sein de l'organisation, coordonnées, …), de sorte que l'application aurait besoin des users table quand même.

Une autre raison peut être le regroupement de connexions - en utilisant un seul compte d'utilisateur partagé, bien que nous sachions que cela peut être résolu en utilisant l'héritage et SET ROLE (voir le message précédent).

Mais supposons que vous ne souhaitiez pas créer d'utilisateurs de base de données distincts - vous souhaitez continuer à utiliser un seul compte de base de données partagé et utiliser RLS avec les utilisateurs de l'application. Comment faire ?

Variables de session

Essentiellement, ce dont nous avons besoin est de transmettre un contexte supplémentaire à la session de base de données, afin que nous puissions l'utiliser ultérieurement à partir de la politique de sécurité (au lieu de current_user variable). Et le moyen le plus simple de le faire dans PostgreSQL sont les variables de session :

SET my.username ='tomas'

Si cela ressemble aux paramètres de configuration habituels (par exemple, SET work_mem = '...' ), vous avez tout à fait raison, c'est essentiellement la même chose. La commande définit un nouvel espace de noms (my ), et ajoute un username variable en elle. Le nouvel espace de noms est requis, car le global est réservé à la configuration du serveur et nous ne pouvons pas y ajouter de nouvelles variables. Cela nous permet de changer la politique de sécurité comme ceci :

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

Tout ce que nous devons faire est de nous assurer que le pool de connexions / l'application définit le nom d'utilisateur chaque fois qu'il obtient une nouvelle connexion et l'attribue à la tâche utilisateur.

Permettez-moi de souligner que cette approche s'effondre une fois que vous autorisez les utilisateurs à exécuter du SQL arbitraire sur la connexion, ou si l'utilisateur parvient à découvrir une vulnérabilité d'injection SQL appropriée. Dans ce cas, rien ne pourrait les empêcher de définir un nom d'utilisateur arbitraire. Mais ne désespérez pas, il existe un tas de solutions à ce problème, et nous les passerons rapidement en revue.

Variables de session signées

La première solution est une simple amélioration des variables de session - nous ne pouvons pas vraiment empêcher les utilisateurs de définir une valeur arbitraire, mais que se passerait-il si nous pouvions vérifier que la valeur n'a pas été subvertie ? C'est assez facile à faire en utilisant une simple signature numérique. Au lieu de simplement stocker le nom d'utilisateur, la partie de confiance (pool de connexion, application) peut faire quelque chose comme ceci :

signature =sha256(nom d'utilisateur + horodatage + SECRET)

puis stockez à la fois la valeur et la signature dans la variable de session :

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

En supposant que l'utilisateur ne connaisse pas la chaîne SECRET (par exemple, 128 B de données aléatoires), il ne devrait pas être possible de modifier la valeur sans invalider la signature.

Remarque :Ce n'est pas une idée nouvelle - c'est essentiellement la même chose que les cookies HTTP signés. Django a une assez belle documentation à ce sujet.

Le moyen le plus simple de protéger la valeur SECRET consiste à la stocker dans une table inaccessible par l'utilisateur et à fournir un security definer fonction, nécessitant un mot de passe (afin que l'utilisateur ne puisse pas simplement signer des valeurs arbitraires).

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 || ':' || extrait(époque à partir de maintenant())::int; valeur_v :=valeur_v || ':' || 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 fonction recherche simplement la clé de signature (secrète) dans une table, calcule la signature, puis définit la valeur dans la variable de session. Il renvoie également la valeur, principalement pour plus de commodité.

Ainsi, la partie de confiance peut le faire juste avant de transmettre la connexion à l'utilisateur (évidemment, la "phrase de passe" n'est pas un très bon mot de passe pour la production) :

SELECT set_username('tomas', 'passphrase')

Et puis, bien sûr, nous avons besoin d'une autre fonction qui vérifie simplement la signature et génère une erreur ou renvoie le nom d'utilisateur si la signature correspond.

CREATE FUNCTION get_username() RENVOIE le texte AS $DECLARE v_key TEXT ; v_parts TEXTE[] ; v_uname TEXTE ; v_value TEXTE ; v_horodatage INT ; v_signature TEXT;BEGIN -- pas de vérification de mot de passe cette fois 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_horodatage || ':' || v_key ; SI v_signature =crypt(v_value, v_signature) ALORS RETOUR v_uname ; FIN SI; RAISE EXCEPTION 'nom d'utilisateur/horodatage invalide';END;$ LANGUAGE plpgsql SECURITY DEFINER STABLE;

Et comme cette fonction n'a pas besoin de la phrase secrète, l'utilisateur peut simplement faire ceci :

SELECT get_username()

Mais le get_username() La fonction est destinée aux politiques de sécurité, par ex. comme ceci :

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

Un exemple plus complet, emballé comme une simple extension, peut être trouvé ici.

Notez que tous les objets (table et fonctions) appartiennent à un utilisateur privilégié, et non à l'utilisateur accédant à la base de données. L'utilisateur n'a que EXECUTE privilège sur les fonctions, qui sont cependant définies comme SECURITY DEFINER . C'est ce qui fait que ce schéma fonctionne tout en protégeant le secret de l'utilisateur. Les fonctions sont définies comme STABLE , pour limiter le nombre d'appels au crypt() fonction (qui est intentionnellement coûteuse pour empêcher le bruteforcing).

Les exemples de fonctions ont définitivement besoin de plus de travail. Mais j'espère que c'est assez bon pour une preuve de concept démontrant comment stocker un contexte supplémentaire dans une variable de session protégée.

Qu'est-ce qui doit être corrigé, demandez-vous ? Premièrement, les fonctions ne gèrent pas très bien les diverses conditions d'erreur. Deuxièmement, alors que la valeur signée inclut un horodatage, nous ne faisons rien avec elle - elle peut être utilisée pour faire expirer la valeur, par exemple. Il est possible d'ajouter des bits supplémentaires dans la valeur, par ex. un service de l'utilisateur, ou même des informations sur la session (par exemple, le PID du processus backend pour éviter de réutiliser la même valeur sur d'autres connexions).

Crypto

Les deux fonctions reposent sur la cryptographie - nous n'utilisons pas grand-chose à l'exception de quelques fonctions de hachage simples, mais il s'agit toujours d'un schéma de chiffrement simple. Et tout le monde sait que vous ne devriez pas faire votre propre crypto. C'est pourquoi j'ai utilisé l'extension pgcrypto, en particulier le crypt() fonction, pour contourner ce problème. Mais je ne suis pas un cryptographe, donc même si je pense que tout le schéma est bon, il me manque peut-être quelque chose - faites-moi savoir si vous remarquez quelque chose.

De plus, la signature correspondrait parfaitement à la cryptographie à clé publique - nous pourrions utiliser une clé PGP standard avec une phrase de passe pour la signature et la partie publique pour la vérification de la signature. Malheureusement, bien que pgcrypto supporte PGP pour le chiffrement, il ne supporte pas la signature.

Approches alternatives

Bien sûr, il existe diverses solutions alternatives. Par exemple, au lieu de stocker le secret de signature dans une table, vous pouvez le coder en dur dans la fonction (mais vous devez ensuite vous assurer que l'utilisateur ne peut pas voir le code source). Ou vous pouvez faire la signature dans une fonction C, auquel cas elle est cachée à tous ceux qui n'ont pas accès à la mémoire (auquel cas vous avez perdu de toute façon).

De plus, si vous n'aimez pas du tout l'approche de signature, vous pouvez remplacer la variable signée par une solution de « coffre-fort » plus traditionnelle. Nous avons besoin d'un moyen de stocker les données, mais nous devons nous assurer que l'utilisateur ne peut pas voir ou modifier le contenu arbitrairement, sauf d'une manière définie. Mais bon, c'est ce que les tables régulières avec une API implémentée à l'aide de security definer fonctions peuvent faire !

Je ne vais pas présenter ici l'ensemble de l'exemple retravaillé (vérifiez cette extension pour un exemple complet), mais ce dont nous avons besoin, c'est d'une sessions table faisant office de coffre :

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

La table ne doit pas être accessible aux utilisateurs réguliers de la base de données - un simple REVOKE ALL FROM ... devrait s'en occuper. Et puis une API composée de deux fonctions principales :

  • set_username(user_name, passphrase) – génère un UUID aléatoire, insère des données dans le coffre et stocke l'UUID dans une variable de session
  • get_username() - lit l'UUID à partir d'une variable de session et recherche la ligne dans le tableau (erreurs si aucune ligne ne correspond)

Cette approche remplace la protection de la signature par le caractère aléatoire de l'UUID - l'utilisateur peut modifier la variable de session, mais la probabilité d'atteindre un ID existant est négligeable (les UUID sont des valeurs aléatoires de 128 bits).

C'est une approche un peu plus traditionnelle, qui s'appuie sur la sécurité traditionnelle basée sur les rôles, mais elle présente également quelques inconvénients :par exemple, elle effectue en fait des écritures de base de données, ce qui signifie qu'elle est intrinsèquement incompatible avec les systèmes de secours à chaud.

Se débarrasser de la phrase secrète

Il est également possible de concevoir le coffre-fort de sorte que la phrase de passe ne soit pas nécessaire. Nous l'avons introduit parce que nous avons supposé set_username se produit sur la même connexion - nous devons garder la fonction exécutable (donc jouer avec les rôles ou les privilèges n'est pas une solution), et la phrase de passe garantit que seul le composant de confiance peut réellement l'utiliser.

Mais que se passe-t-il si la signature/création de session se produit sur une connexion distincte et que seul le résultat (valeur signée ou UUID de session) est copié dans la connexion remise à l'utilisateur ? Eh bien, nous n'avons plus besoin de la phrase de passe. (C'est un peu similaire à ce que fait Kerberos :générer un ticket sur une connexion approuvée, puis utiliser le ticket pour d'autres services.)

Résumé

Alors permettez-moi de récapituler rapidement cet article de blog :

  • Alors que tous les exemples RLS utilisent des utilisateurs de base de données (au moyen de current_user ), il n'est pas très difficile de faire fonctionner RLS avec les utilisateurs de l'application.
  • Les variables de session sont une solution fiable et assez simple, en supposant que le système dispose d'un composant de confiance qui peut définir la variable avant de transmettre la connexion à un utilisateur.
  • Lorsque l'utilisateur peut exécuter du SQL arbitraire (soit par conception, soit grâce à une vulnérabilité), une variable signée empêche l'utilisateur de modifier la valeur.
  • D'autres solutions sont possibles, par ex. remplacer les variables de session par une table stockant des informations sur les sessions identifiées par un UUID aléatoire.
  • Ce qui est bien, c'est que les variables de session n'effectuent aucune écriture dans la base de données. Cette approche peut donc fonctionner sur des systèmes en lecture seule (par exemple, une mise en veille automatique).

Dans la prochaine partie de cette série de blogs, nous examinerons l'utilisation des utilisateurs d'application lorsque le système n'a pas de composant de confiance (il ne peut donc pas définir la variable de session ou créer une ligne dans les sessions table), ou lorsque nous voulons effectuer une authentification personnalisée (supplémentaire) dans la base de données.