~/blog/row-level-security-bypass-patternPOSTED
von Shady Nathan Tawfik · 6. Juni 2026 · 4 min

Eine Row-Level-Security-Policy ist eine Grenze, die man clientseitig testen muss

Eine plattformgenerierte Anwendung speicherte jede Mandantentabelle hinter Row Level Security. Die Policy war auf dem Papier korrekt und in der Praxis umgehbar, weil der Client einen Schlüssel hielt, dem die Policy zu sehr vertraute.

Interaktiv[blog-2.0]

TL;DR: Row-Level-Security war auf dem Papier korrekt und in der Praxis umgehbar, weil der Client den Schlüssel hielt, dem die Policy vertraute. Ich erkläre die Vertrauenskette, wie ich den Bypass bewiesen habe, und wo Autorisierung wirklich leben muss.

Ausgangslage

Ich habe das Ziel als Black-Box geprüft: keine Zugangsdaten, nur die öffentlich sichtbare Fläche. Das ist, was ich sah, bevor ich etwas angefasst habe.

Ein internes Plattformwerkzeug, erstellt mit einem generativen Anwendungsframework auf einem verwalteten relationalen Backend, vorgeschaltet durch ein Content Delivery Network. Geprüft wurden das Autorisierungsmodell, die Vertrauenskette zwischen Client und Backend und die Freigabe administrativer Endpunkte.

Ich habe alle identifizierenden Informationen entfernt. Das Ziel wird ausschließlich nach Branche und Technologieklasse beschrieben, damit das Muster übertragbar bleibt.

Das Muster

Das Muster, das ich erkannt habe — und das ich in ähnlichen Landschaften immer wieder finde:

Row Level Security verlagert Autorisierung aus der Anwendung in die Datenbank. Statt jedem Query des Backends zu vertrauen, prüft die Datenbank jede Zeile gegen eine Sessionvariable oder ein Claim und verweigert Zeilen, die der Aufrufer nicht sehen darf. Das ist stark, wenn es auf jede Tabelle und jede Rolle angewandt wird.

Der Fehlerfall ist fast nie ein Fehler im Policy-Text, sondern ein Fehler darin, wer den Kontext hält. Kann der Aufrufer die Sessionvariable beeinflussen, gegen die die Policy prüft, dann bewertet die Policy korrekt gegen einen vom Angreifer gewählten Mandanten.

Plattformgenerierte Anwendungen machen das vorhersagbar. Das Framework erzeugt einen permissiven Standardschlüssel, liefert ihn im Client-Bundle aus und verdrahtet die Policy darauf. Von aussen sieht es nach korrekt gesichertem Backend aus, denn die Tabellen-Policies existieren.

TL;DR: Eine plattformgenerierte Anwendung speicherte jede Mandantentabelle hinter Row Level Security. Die Policy war auf dem Papier korrekt und in der Praxis umgehbar, weil der Client einen Schlüssel hielt, dem die Policy zu sehr vertraute.

Befunde

SchweregradBefundNachweis
KRITISCHMandantenkontext kommt vom Client und wird von der Policy vertrautJeder angemeldete Nutzer konnte Zeilen fremder Mandanten lesen
HOCHAdministrative Endpunkte nach Anmeldung erreichbarKeine Rollentrennung zwischen Lese- und Schreibpfaden
MITTELServicerollen-Schlüssel im Client-BundleIm Bundle lesbar für jeden Besucher
MITTELSchema-Introspektion offenTabellen- und Spalteninventar preisgegeben
NIEDRIGCross-Origin-Policy zu permissivWildcard-Origin auf einem authentifizierten Endpunkt
INFOBackend korrekt gegen direkte Netzwerkzugriffe isoliertNur der Edge-Endpunkt erreichbar

Der Angriffspfad

Authenticated clientSupplies tenant identifierBackend forwards identifier as session variableRow level policy matches on that variableRows of the chosen tenant returnedAttacker reads another tenant data

Reproduktion im Labor

Jeder Befehl unten zielt auf einen Lab-Container, den ich selbst kontrolliere. Nichts davon ist auf ein laufendes System gerichtet.

Nachweisen, dass die Grenze hält, mit einem Labor mit zwei Mandanten

~/code/bash bash
# Ziel: eine Labor-Datenbank mit derselben Policy-Struktur
# Mandant A besitzt die Zeilen, Mandant B ist die Angreifer-Sitzung

# 1. Basislinie: nur lesen, was die Policy erlaubt
$ curl -s http://localhost:54321/rest/v1/records?tenant_id=tenant-a -H 'apikey: <anon-key>' | jq 'length'

# 2. der Test: nur die Mandantenkennung tauschen, Sitzung beibehalten
$ curl -s 'http://localhost:54321/rest/v1/records?tenant_id=tenant-b' -H 'apikey: <anon-key>' | jq 'length'

# Zeilen für tenant-b bedeuten, dass die Policy dem Request-Parameter vertraute
# ist length bei beiden 0, hält die Policy

Erkennung in eigener Infrastruktur

Vergleiche den Mandanten aus Sicht des Clients mit dem Mandanten der Zeile

~/code/bash bash
# niemals gegen Dritte, sondern gegen Ihre eigenen Staging-Daten
# 1. eine Canary-Zeile in Ihrem eigenen Mandanten einfügen
# 2. die Sammlung mit einer anderen Mandantenkennung anfordern
# 3. kommt die Canary-Zeile zurück, prüft die Policy gegen Aufrufer-Input

# zuerst die Exposition des eigenen Backends inventarisieren
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:54321/rest/v1/

Prüfen, ob der Vertrauensanker im Bundle liegt

~/code/bash bash
# eigene Build-Artefakte auf eingebettete Schlüssel durchsuchen
$ grep -rInE 'anon.?key|service_role|apikey' dist/ assets/ public/ | head

Behebung

Mandant aus der Sitzung ableiten, niemals aus dem Request

~/code/sql sql
-- die Policy muss den Mandanten aus einem Claim lesen, den der Client nicht direkt setzen kann
create policy tenant_isolation on records
  using (
    tenant_id = nullif(current_setting('request.jwt.claim.tenant', true), '')::uuid
  );

-- und die Möglichkeit entziehen, diese Einstellung als anonyme Rolle zu setzen
revoke set on parameter request.jwt.claim.tenant from anon;

-- zusätzlich den Tabelleneigner an eine Rolle binden, die kein Request-Pfad nutzt

Sobald ein Angreifer Zugriff hat, kann er folgende Aktionen durchführen:

Mandantenkontext kommt vom Client und wird von der Policy vertraut
Administrative Endpunkte nach Anmeldung erreichbar
Servicerollen-Schlüssel im Client-Bundle
Schema-Introspektion offen

Fazit — was ich daraus mitnehme

  • Row Level Security ist nur so stark wie die Identität hinter dem Kontext, auf den sie prüft.
  • Testen Sie die Policy mit dem vom Aufrufer gewählten Wert, nicht mit dem erwarteten.
  • Ein im Bundle liegender Servicerollen-Schlüssel ist eine vollständige Umgehung.
  • Die Datenbank kann nicht erkennen, dass der Mandant falsch ist, wenn Sie ihr nie sagen, wer der Aufrufer ist.

Nur zu Bildungszwecken. Alle reproduzierbaren Befehle zielen auf meine eigene Lab-Umgebung (localhost), nie auf ein laufendes System. © 2026