PostgreSQL
 sql >> Datenbank >  >> RDS >> PostgreSQL

Anwendungsbenutzer vs. Sicherheit auf Zeilenebene

Vor ein paar Tagen habe ich über die häufigsten Probleme mit Rollen und Berechtigungen gebloggt, die wir bei Sicherheitsüberprüfungen entdecken.

Natürlich bietet PostgreSQL viele erweiterte Sicherheitsfunktionen, darunter Row Level Security (RLS), die seit PostgreSQL 9.5 verfügbar ist.

Da 9.5 im Januar 2016 veröffentlicht wurde (also erst vor ein paar Monaten), ist RLS eine ziemlich neue Funktion, und wir haben es noch nicht wirklich mit vielen Produktionsbereitstellungen zu tun. Stattdessen ist RLS ein häufiges Thema von Diskussionen über die „Implementierung“, und eine der häufigsten Fragen ist, wie es mit Benutzern auf Anwendungsebene funktioniert. Sehen wir uns also an, welche möglichen Lösungen es gibt.

Einführung in RLS

Sehen wir uns zuerst ein sehr einfaches Beispiel an, das erklärt, worum es bei RLS geht. Nehmen wir an, wir haben einen chat Tabelle, in der Nachrichten gespeichert werden, die zwischen Benutzern gesendet werden – die Benutzer können Zeilen darin einfügen, um Nachrichten an andere Benutzer zu senden, und sie abfragen, um Nachrichten zu sehen, die ihnen von anderen Benutzern gesendet wurden. Die Tabelle könnte also so aussehen:

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

Die klassische rollenbasierte Sicherheit ermöglicht es uns nur, den Zugriff entweder auf die gesamte Tabelle oder auf vertikale Teile davon (Spalten) einzuschränken. Wir können es also nicht verwenden, um Benutzer daran zu hindern, Nachrichten zu lesen, die für andere Benutzer bestimmt sind, oder Nachrichten mit einem gefälschten message_from zu senden Feld.

Und genau dafür ist RLS da – es ermöglicht Ihnen, Regeln (Richtlinien) zu erstellen, die den Zugriff auf Teilmengen von Zeilen beschränken. So können Sie beispielsweise Folgendes tun:

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

Diese Richtlinie stellt sicher, dass ein Benutzer nur Nachrichten sehen kann, die von ihm gesendet wurden oder für ihn bestimmt sind – das ist die Bedingung in USING Klausel tut. Der zweite Teil der Richtlinie (WITH CHECK ) stellt sicher, dass ein Benutzer nur Nachrichten mit seinem Benutzernamen in message_from einfügen kann Spalte, verhindert Nachrichten mit gefälschtem Absender.

Sie können sich RLS auch als automatischen Weg zum Anhängen zusätzlicher WHERE-Bedingungen vorstellen. Sie könnten dies manuell auf Anwendungsebene tun (und bevor RLS dies oft taten), aber RLS tut dies auf zuverlässige und sichere Weise (zum Beispiel wurde viel Mühe darauf verwendet, verschiedene Informationslecks zu verhindern).

Hinweis :Vor RLS war es ein beliebter Weg, etwas Ähnliches zu erreichen, indem man die Tabelle direkt unzugänglich machte (alle Privilegien entzog) und eine Reihe von Sicherheitsdefinitionsfunktionen für den Zugriff bereitstellte. Das hat meistens das gleiche Ziel erreicht, aber Funktionen haben verschiedene Nachteile – sie verwirren den Optimierer und schränken die Flexibilität ernsthaft ein (wenn der Benutzer etwas tun muss und es keine passende Funktion dafür gibt, hat er Pech). Und natürlich müssen Sie diese Funktionen schreiben.

Anwendungsbenutzer

Wenn Sie die offizielle Dokumentation zu RLS lesen, fällt Ihnen vielleicht ein Detail auf – alle Beispiele verwenden current_user , also der aktuelle Datenbankbenutzer. Aber so funktionieren die meisten Datenbankanwendungen heutzutage nicht. Webanwendungen mit vielen registrierten Benutzern pflegen keine 1:1-Zuordnung zu Datenbankbenutzern, sondern verwenden stattdessen einen einzelnen Datenbankbenutzer, um Abfragen auszuführen und Anwendungsbenutzer selbst zu verwalten – möglicherweise in einem users Tabelle.

Technisch ist es kein Problem, viele Datenbankbenutzer in PostgreSQL anzulegen. Die Datenbank sollte das ohne Probleme handhaben, aber Anwendungen tun dies aus einer Reihe praktischer Gründe nicht. Beispielsweise müssen sie zusätzliche Informationen für jeden Benutzer nachverfolgen (z. B. Abteilung, Position innerhalb der Organisation, Kontaktdaten usw.), sodass die Anwendung die users benötigen würde Tisch trotzdem.

Ein weiterer Grund kann Verbindungspooling sein – die Verwendung eines einzelnen gemeinsam genutzten Benutzerkontos, obwohl wir wissen, dass dies mit Vererbung und SET ROLE lösbar ist (siehe vorherigen Beitrag).

Aber nehmen wir an, Sie möchten keine separaten Datenbankbenutzer erstellen – Sie möchten weiterhin ein einziges gemeinsames Datenbankkonto verwenden und RLS mit Anwendungsbenutzern verwenden. Wie geht das?

Sitzungsvariablen

Im Wesentlichen müssen wir zusätzlichen Kontext an die Datenbanksitzung übergeben, damit wir ihn später aus der Sicherheitsrichtlinie verwenden können (anstelle des current_user Variable). Und der einfachste Weg, dies in PostgreSQL zu tun, sind Sitzungsvariablen:

SET my.username = 'tomas'

Wenn dies den üblichen Konfigurationsparametern ähnelt (z. B. SET work_mem = '...' ), du hast völlig recht – es ist meistens dasselbe. Der Befehl definiert einen neuen Namespace (my ) und fügt einen username hinzu variabel hinein. Der neue Namespace ist erforderlich, da der globale für die Serverkonfiguration reserviert ist und wir ihm keine neuen Variablen hinzufügen können. Dadurch können wir die Sicherheitsrichtlinie wie folgt ändern:

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

Alles, was wir tun müssen, ist sicherzustellen, dass der Verbindungspool / die Anwendung den Benutzernamen festlegt, wenn er eine neue Verbindung erhält, und ihn der Benutzeraufgabe zuweist.

Lassen Sie mich darauf hinweisen, dass dieser Ansatz zusammenbricht, sobald Sie den Benutzern erlauben, beliebiges SQL auf der Verbindung auszuführen, oder wenn es dem Benutzer gelingt, eine geeignete SQL-Injection-Schwachstelle zu entdecken. In diesem Fall gibt es nichts, was sie daran hindern könnte, einen beliebigen Benutzernamen festzulegen. Aber verzweifeln Sie nicht, es gibt eine Reihe von Lösungen für dieses Problem, und wir werden sie schnell durchgehen.

Signierte Sitzungsvariablen

Die erste Lösung ist eine einfache Verbesserung der Sitzungsvariablen – wir können die Benutzer nicht wirklich daran hindern, einen beliebigen Wert festzulegen, aber was wäre, wenn wir überprüfen könnten, ob der Wert nicht untergraben wurde? Das geht ganz einfach mit einer einfachen digitalen Signatur. Anstatt nur den Benutzernamen zu speichern, kann der vertrauenswürdige Teil (Verbindungspool, Anwendung) etwa Folgendes tun:

signature = sha256(username + timestamp + SECRET)

und speichern Sie dann sowohl den Wert als auch die Signatur in der Sitzungsvariable:

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

Angenommen, der Benutzer kennt die SECRET-Zeichenfolge nicht (z. B. 128 B zufällige Daten), sollte es nicht möglich sein, den Wert zu ändern, ohne die Signatur ungültig zu machen.

Hinweis :Dies ist keine neue Idee – es ist im Wesentlichen dasselbe wie signierte HTTP-Cookies. Django hat dazu eine recht nette Dokumentation.

Der einfachste Weg, den SECRET-Wert zu schützen, besteht darin, ihn in einer Tabelle zu speichern, auf die der Benutzer nicht zugreifen kann, und einen security definer bereitzustellen Funktion, die ein Passwort erfordert (damit der Benutzer nicht einfach beliebige Werte signieren kann).

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;

Die Funktion schlägt einfach den Signaturschlüssel (geheim) in einer Tabelle nach, berechnet die Signatur und setzt dann den Wert in die Sitzungsvariable. Es gibt auch den Wert zurück, hauptsächlich aus Gründen der Bequemlichkeit.

Der vertrauenswürdige Teil kann dies also tun, bevor er die Verbindung an den Benutzer weitergibt (offensichtlich ist „Passphrase“ kein sehr gutes Passwort für die Produktion):

SELECT set_username('tomas', 'passphrase')

Und dann brauchen wir natürlich eine weitere Funktion, die einfach die Signatur überprüft und entweder einen Fehler ausgibt oder den Benutzernamen zurückgibt, wenn die Signatur übereinstimmt.

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;

Und da diese Funktion keine Passphrase benötigt, kann der Benutzer einfach Folgendes tun:

SELECT get_username()

Aber die get_username() Funktion ist für Sicherheitsrichtlinien gedacht, z. so:

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

Ein vollständigeres Beispiel, verpackt als einfache Erweiterung, finden Sie hier.

Beachten Sie, dass alle Objekte (Tabelle und Funktionen) einem privilegierten Benutzer gehören, nicht dem Benutzer, der auf die Datenbank zugreift. Der Benutzer hat nur EXECUTE Privileg auf die Funktionen, die jedoch als SECURITY DEFINER definiert sind . Deshalb funktioniert dieses Schema und schützt gleichzeitig das Geheimnis vor dem Benutzer. Die Funktionen sind als STABLE definiert , um die Anzahl der Aufrufe von crypt() zu begrenzen Funktion (die absichtlich teuer ist, um Bruteforce zu verhindern).

Die Beispielfunktionen erfordern definitiv mehr Arbeit. Aber hoffentlich ist es gut genug für einen Machbarkeitsnachweis, der zeigt, wie man zusätzlichen Kontext in einer geschützten Sitzungsvariable speichert.

Was muss repariert werden, fragen Sie? Erstens behandeln die Funktionen verschiedene Fehlerbedingungen nicht sehr gut. Zweitens, obwohl der signierte Wert einen Zeitstempel enthält, machen wir nicht wirklich etwas damit – er kann zum Beispiel verwendet werden, um den Wert ablaufen zu lassen. Es ist möglich, dem Wert zusätzliche Bits hinzuzufügen, z. eine Abteilung des Benutzers oder sogar Informationen über die Sitzung (z. B. PID des Backend-Prozesses, um zu verhindern, dass derselbe Wert auf anderen Verbindungen erneut verwendet wird).

Krypto

Die beiden Funktionen beruhen auf Kryptographie – wir verwenden nicht viel außer einigen einfachen Hash-Funktionen, aber es ist immer noch ein einfaches Krypto-Schema. Und jeder weiß, dass Sie keine eigene Krypto machen sollten. Aus diesem Grund habe ich die pgcrypto-Erweiterung verwendet, insbesondere crypt() Funktion, um dieses Problem zu umgehen. Aber ich bin kein Kryptograph, also glaube ich, dass das ganze Schema in Ordnung ist, aber vielleicht übersehe ich etwas – lassen Sie es mich wissen, wenn Sie etwas entdecken.

Außerdem würde das Signieren hervorragend zur Public-Key-Kryptografie passen – wir könnten einen normalen PGP-Schlüssel mit einer Passphrase für das Signieren und den öffentlichen Teil für die Signaturverifizierung verwenden. Obwohl pgcrypto PGP für die Verschlüsselung unterstützt, unterstützt es leider nicht das Signieren.

Alternative Ansätze

Natürlich gibt es verschiedene Alternativlösungen. Anstatt das Signaturgeheimnis beispielsweise in einer Tabelle zu speichern, können Sie es fest in die Funktion codieren (aber dann müssen Sie sicherstellen, dass der Benutzer den Quellcode nicht sehen kann). Oder Sie können das Signieren in einer C-Funktion durchführen, in diesem Fall ist es für alle verborgen, die keinen Zugriff auf den Speicher haben (in diesem Fall haben Sie sowieso verloren).

Wenn Ihnen der Signierungsansatz überhaupt nicht gefällt, können Sie die signierte Variable auch durch eine traditionellere „Tresor“-Lösung ersetzen. Wir brauchen eine Möglichkeit, die Daten zu speichern, aber wir müssen sicherstellen, dass der Benutzer die Inhalte nicht willkürlich sehen oder ändern kann, außer auf eine definierte Weise. Aber hey, das sind reguläre Tabellen mit einer API, die mit security definer implementiert wurde Funktionen können!

Ich werde hier nicht das gesamte überarbeitete Beispiel präsentieren (überprüfen Sie diese Erweiterung für ein vollständiges Beispiel), aber was wir brauchen, ist eine sessions Tabelle, die als Tresor fungiert:

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

Die Tabelle darf nicht für normale Datenbankbenutzer zugänglich sein – ein einfaches REVOKE ALL FROM ... sollte sich darum kümmern. Und dann eine API, die aus zwei Hauptfunktionen besteht:

  • set_username(user_name, passphrase) – generiert eine zufällige UUID, fügt Daten in den Tresor ein und speichert die UUID in einer Sitzungsvariablen
  • get_username() – liest die UUID aus einer Session-Variablen und sucht die Zeile in der Tabelle (Fehler, wenn keine passende Zeile)

Dieser Ansatz ersetzt den Signaturschutz durch Zufälligkeit der UUID – der Benutzer kann die Sitzungsvariable optimieren, aber die Wahrscheinlichkeit, eine vorhandene ID zu treffen, ist vernachlässigbar (UUIDs sind 128-Bit-Zufallswerte).

Es ist ein etwas traditionellerer Ansatz, der sich auf traditionelle rollenbasierte Sicherheit stützt, aber es hat auch einige Nachteile – zum Beispiel führt es tatsächlich Datenbankschreibvorgänge aus, was bedeutet, dass es von Natur aus nicht mit Hot-Standby-Systemen kompatibel ist.

Die Passphrase entfernen

Es ist auch möglich, den Tresor so zu gestalten, dass die Passphrase nicht erforderlich ist. Wir haben es eingeführt, weil wir von set_username ausgegangen sind geschieht auf derselben Verbindung – wir müssen die Funktion ausführbar halten (das Durcheinander mit Rollen oder Privilegien ist also keine Lösung), und die Passphrase stellt sicher, dass nur die vertrauenswürdige Komponente sie tatsächlich verwenden kann.

Was aber, wenn die Signierung/Sitzungserstellung über eine separate Verbindung erfolgt und nur das Ergebnis (signierter Wert oder Sitzungs-UUID) in die dem Benutzer übergebene Verbindung kopiert wird? Nun, dann brauchen wir die Passphrase nicht mehr. (Es ist ein bisschen ähnlich wie bei Kerberos – ein Ticket für eine vertrauenswürdige Verbindung erstellen und das Ticket dann für andere Dienste verwenden.)

Zusammenfassung

Lassen Sie mich diesen Blogpost also kurz zusammenfassen:

  • Während alle RLS-Beispiele Datenbankbenutzer verwenden (mittels current_user ), ist es nicht sehr schwierig, RLS mit Anwendungsbenutzern zum Laufen zu bringen.
  • Sitzungsvariablen sind eine zuverlässige und recht einfache Lösung, vorausgesetzt, das System verfügt über eine vertrauenswürdige Komponente, die die Variable setzen kann, bevor die Verbindung an einen Benutzer übergeben wird.
  • Wenn der Benutzer beliebiges SQL ausführen kann (entweder absichtlich oder dank einer Schwachstelle), hindert eine signierte Variable den Benutzer daran, den Wert zu ändern.
  • Andere Lösungen sind möglich, z.B. Ersetzen der Sitzungsvariablen durch eine Tabelle, in der Informationen über Sitzungen gespeichert sind, die durch eine zufällige UUID identifiziert werden.
  • Eine nette Sache ist, dass die Sitzungsvariablen keine Datenbankschreibvorgänge ausführen, sodass dieser Ansatz auf Nur-Lese-Systemen (z. B. Hot-Standby) funktionieren kann.

Im nächsten Teil dieser Blogserie werden wir uns mit der Verwendung von Anwendungsbenutzern befassen, wenn das System keine vertrauenswürdige Komponente hat (so dass es die Session-Variable nicht setzen oder keine Zeile in den sessions erstellen kann Tabelle), oder wenn wir eine (zusätzliche) benutzerdefinierte Authentifizierung innerhalb der Datenbank durchführen möchten.