Angriffsklassen, Threat Modeling, Autorisierung und Multi-Tenancy

Track Konzepte · Security · ca. 70 Min.

Worum es geht

Ein Kunde meldet sich bei deiner Anwendung an, ändert in der Adresszeile die Zahl 2 in 3 und sieht plötzlich die Rechnung eines anderen Kunden. Kein Hacker-Werkzeug, kein Exploit, nur eine Zahl. Das ist Broken Access Control (kaputte Zugriffskontrolle), und es ist laut OWASP Top 10 seit Jahren eine der häufigsten Schwachstellen (Platzierung in der aktuellen Liste: bitte prüfen).

Am Ende der Lektion kannst du erklären, warum Prüfungen im Frontend keine Sicherheit sind und wo Autorisierung (authorization) hingehört. Du hast selbst gebaut: kontextabhängiges Escaping gegen XSS, eine Objekt-Autorisierung gegen IDOR, eine Mandanten-Isolation, bei der ein vergessener Filter nichts mehr leakt, und einen URL-Validator gegen SSRF. Außerdem kannst du Bedrohungen mit STRIDE einordnen und weißt, wann RBAC, ABAC und ReBAC passen.

Alles hier ist defensiv: verstehen, erkennen, verhindern. Die “Angriffsstrings” sind harmlose Markierungen wie MARKE1. Du siehst in der Simulation, dass etwas durchrutscht, nicht, wie man damit echten Schaden anrichtet.

SQL-Injection (Daten und Befehl trennen, Parameter statt String-Bauen) kennst du aus Python PCPP1 Block 5. Hier wird sie nicht wiederholt. Dasselbe Prinzip gilt für alle Injection-Arten: Wer fremde Daten in einen Befehl oder ein Dokument einbaut, ohne sie zu trennen, lässt den Fremden mitschreiben.

Plane ehrlich 70 Minuten ein: etwa 25 Minuten Lesen, 45 Minuten Übungen (fünf Stück). Eine gute Pause ist nach Übung 2.

Von JS/TS her gedacht

Du kennst die Angriffsfläche aus dem Frontend, vielleicht ohne den Namen:

Konzept JS/TS Python (hier)
XSS verhindern React escaped {text} automatisch, dangerouslySetInnerHTML ist die Ausnahme html.escape, Template-Engines mit Auto-Escaping
Eingabe prüfen Zod/Yup im Formular, required, maxLength dieselbe Prüfung noch einmal auf dem Server (pydantic)
Berechtigung Button ausblenden, Route per Guard sperren Prüfung im Handler am Objekt, nie nur im Menü
URL prüfen new URL(u).hostname urllib.parse.urlparse(u).hostname

Die Escape-Funktion in JavaScript, mit Node ausgeführt:

const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
console.log(esc('<b>MARKE</b> & "x"'));

Ausgabe:

&lt;b&gt;MARKE&lt;/b&gt; &amp; &quot;x&quot;

Und die typische Falle bei URL-Prüfungen, ebenfalls mit Node ausgeführt. startsWith sagt für beide Adressen true, aber der echte Host ist ein anderer:

for (const u of ["https://example.com.evil.test/x", "https://example.com@evil.test/"]) {
  console.log(u.startsWith("https://example.com"), new URL(u).hostname);
}

Ausgabe:

true example.com.evil.test
true evil.test

Konzept

Schritt 1: Warum Frontend-Validierung keine Sicherheit ist

Eine Formularprüfung im Browser ist Komfort, keine Sicherheit. Der Browser gehört dem Nutzer. Er kann die Prüfung abschalten, den Request mit curl nachbauen oder ein Skript schreiben, das den Server direkt anspricht. Der Server sieht nie das Formular, nur den Request.

Das geht in Python so, ohne Netzwerk, als Simulation:

Ausgabe:

False
{'name': 'Ayse', 'bestellt': 500}

Das Formular lehnt 500 ab. Der Server nimmt sie trotzdem, weil jemand den Request ohne Formular geschickt hat. Daraus folgt die Antwort auf den ersten Prüfstein:

  • Validierung (Form, Wertebereich, Länge) gehört zusätzlich auf den Server. Das Frontend darf sie doppeln, damit der Nutzer schnell Feedback bekommt.
  • Autorisierung (“darf dieser Nutzer dieses Objekt lesen oder ändern?”) gehört auf den Server, in den Code, der das Objekt lädt oder ändert. Nicht in das Menü, nicht in den Router des Frontends, nicht in ein verstecktes Feld.
  • Alles, was vom Client kommt (Body, Query, Header, Cookie, auch die Rolle im Token-Payload ohne Signaturprüfung), ist unvertrauenswürdige Eingabe.

Schritt 2: XSS und kontextabhängiges Escaping

XSS (Cross-Site Scripting) ist Injection in HTML: Fremde Daten landen so in deiner Seite, dass der Browser sie als Markup oder Skript liest statt als Text. Ein Kommentar <b>MARKE</b> soll als Text <b>MARKE</b> erscheinen, nicht als fetter Text. Wäre statt <b> ein Skript-Tag erlaubt, liefe fremder Code in deiner Seite, mit den Rechten des angemeldeten Nutzers.

Die Abwehr heißt Output Encoding (Escaping beim Ausgeben): Zeichen, die im Zielformat eine Bedeutung haben, werden durch ungefährliche Schreibweisen ersetzt. Entscheidend ist: Was “bedeutungsvoll” ist, hängt vom Kontext ab, in den du schreibst.

Kontext Gefährliche Zeichen Beispiel
HTML-Text zwischen Tags <, & (und > zur Sicherheit) <p>HIER</p>
Attributwert in Anführungszeichen zusätzlich das Anführungszeichen selbst <p title="HIER">
URL-Attribut (href, src) Escaping reicht nicht: Das Schema muss erlaubt sein (https: ja, javascript: nein) <a href="HIER">

Ein Zerleger, der wie ein Browser Tags und Text trennt, zeigt, was passiert (html.parser aus der Standardbibliothek, dieser Hilfsbau kommt in der Übung wieder):

Ausgabe:

([('p', {'title': ''}), ('b', {})], 'MARKE">Hallo')
([('p', {'title': '"><b>MARKE</b>'})], 'Hallo')
<p title="&quot;&gt;&lt;b&gt;MARKE&lt;/b&gt;">Hallo</p>

Im ersten Fall bricht das Anführungszeichen aus dem Attribut aus, und ein fremdes <b>-Tag entsteht (es hätte auch ein anderes Tag oder ein Event-Attribut sein können). Im zweiten Fall bleibt alles ein einziger Attributwert. Der Test, ob dein Escaping stimmt, ist darum immer derselbe: Parse die Ausgabe und prüfe, dass kein fremdes Tag und kein fremdes Attribut entstanden ist. Genau so prüft Übung 1.

Drei Regeln dazu:

  1. Escape beim Ausgeben, nicht beim Speichern. Gespeichert wird der Originaltext. Ob er in HTML, in JSON oder in einer Mail landet, entscheidet erst die Ausgabe.
  2. Reihenfolge: & zuerst ersetzen. Sonst wird das & aus &lt; noch einmal zu &amp;lt; (doppeltes Escaping).
  3. Frameworks escapen automatisch (React, Jinja2 mit Auto-Escape). Die Lücke steckt fast immer in der bewussten Ausnahme: dangerouslySetInnerHTML, |safe, selbst gebaute HTML-Strings.

Als zusätzliche Schicht gibt es die Content-Security-Policy (CSP): ein Header, der dem Browser sagt, woher Skripte kommen dürfen. Sie ist ein Sicherheitsnetz, kein Ersatz fürs Escaping (Defense in Depth, kommt in Schritt 5).

Schritt 3: CSRF in drei Sätzen

CSRF (Cross-Site Request Forgery) nutzt aus, dass der Browser Cookies automatisch zu deinem Server mitschickt, auch wenn der Request von einer fremden Seite ausgelöst wird. Ein Nutzer ist bei dir angemeldet und öffnet eine fremde Seite, die unsichtbar ein Formular an deine-app/konto/loeschen abschickt. Der Server sieht ein gültiges Cookie und führt aus.

Gegenmittel (alle serverseitig, kein Frontend-Trick):

  • Cookies mit SameSite=Lax oder Strict: Der Browser schickt sie bei fremd ausgelösten Requests nicht mit (bei Lax bleibt nur die einfache Navigation per Link mit GET erlaubt, darum nie ändernde Aktionen per GET).
  • CSRF-Token: ein zufälliger Wert pro Sitzung, den nur deine eigene Seite kennt und der bei jeder ändernden Anfrage mitkommen muss.
  • Ändernde Aktionen nie per GET.

Merke dir den Unterschied: XSS bringt fremden Code in deine Seite. CSRF bringt eine fremde Seite dazu, Requests an deine Seite zu schicken. Mit XSS lässt sich CSRF-Schutz umgehen, deshalb braucht es beides.

Schritt 4: Broken Access Control, IDOR und Autorisierungsmodelle

Authentifizierung (authentication) fragt: Wer bist du? Autorisierung (authorization) fragt: Was darfst du? Der häufigste Fehler: Der Endpunkt prüft nur die erste Frage.

Ausgabe: Passwort-Reset Plan. Ayse liest Cans Notiz. Das ist IDOR (Insecure Direct Object Reference): Eine ID im Request verweist direkt auf ein Objekt, und niemand prüft, ob der Anfragende es sehen darf. Zufällige IDs (UUIDs) machen Raten schwerer, ersetzen aber nie die Prüfung.

Die Reparatur ist eine Objekt-Prüfung am Server, direkt dort, wo das Objekt geladen wird:

Ausgabe:

1 Angebot an Firma X
2 NotFound
99 NotFound

Beachte: Für Cans Notiz (2) und die nicht existierende (99) kommt dieselbe Antwort. Würdest du für Fremdes Forbidden (403) und für Nicht-Vorhandenes NotFound (404) senden, könnte jemand durch Durchprobieren herausfinden, welche IDs existieren. Prüfe zwei Ebenen: Funktionsebene (darf diese Rolle die Aktion überhaupt?) und Objektebene (darf sie es mit diesem Objekt?). Beide, bei jeder Anfrage.

Wie legst du die Regeln fest? Drei Modelle, jeweils mit der kleinsten Version in Python:

RBAC (Role-Based Access Control): Rechte hängen an Rollen, Nutzer haben Rollen.

Ausgabe: True False. Einfach und gut zu prüfen. Schwach, wenn die Regel von Umständen abhängt (“nur Dokumente der eigenen Abteilung”, “nur zu Geschäftszeiten”): Dann entsteht eine Rolle pro Sonderfall (Rollenexplosion).

ABAC (Attribute-Based Access Control): Die Entscheidung wertet Attribute von Nutzer, Ressource und Umgebung aus.

Ausgabe: True False False. Ausdrucksstark, aber die Regeln können unübersichtlich werden.

ReBAC (Relationship-Based Access Control): Die Entscheidung folgt Beziehungen zwischen Objekten, wie in einem Graphen: “Ayse ist Mitglied von Team A, Team A besitzt Ordner 1, Ordner 1 enthält Dokument 7.”

Ausgabe: True False False. So funktionieren geteilte Ordner und Projekte, wie du sie aus Kollaborations-Tools kennst.

Wo die Regeln stehen, ist eine zweite Frage. Verstreute if-Zeilen in jedem Handler sind schwer zu überblicken und zu ändern. Eine Policy Engine (zentrale Stelle, die Regeln als Daten oder Code verwaltet und Entscheidungen liefert, Beispiele aus dem Ökosystem: OPA, Cedar, bitte prüfen) bündelt sie. Der Handler fragt “darf Nutzer X Aktion Y auf Objekt Z?” und führt dann aus oder nicht.

Schritt 5: Threat Modeling mit STRIDE

Threat Modeling heißt: Bedrohungen vor dem Bauen systematisch suchen, statt sie nach dem Vorfall zu entdecken. Der Kern sind vier Fragen: Was bauen wir? Was kann schiefgehen? Was tun wir dagegen? Haben wir genug getan?

Dafür zeichnest du das System mit Datenflüssen und Trust Boundaries (Vertrauensgrenzen: Stellen, an denen Daten von einer weniger vertrauenswürdigen Zone in eine vertrauenswürdigere wechseln). An jeder Grenze gilt: Alles, was ankommt, ist bis zur Prüfung unvertrauenswürdig. Die Summe der Stellen, an denen ein Angreifer ansetzen kann, ist die Angriffsfläche (attack surface).

flowchart LR
    subgraph Internet["Unvertrauenswürdig"]
        B["Browser des Nutzers"]
    end
    subgraph Intern["Dein Netz"]
        G["API Gateway"]
        A["Anwendung"]
        D[("Datenbank")]
    end
    B -->|"Grenze 1: alles prüfen"| G
    G --> A
    A -->|"Grenze 2: Mandant erzwingen"| D
    A -->|"Grenze 3: SSRF"| X["Fremder Dienst per URL"]

Für jede Kante und jeden Knoten fragst du mit STRIDE (ein Merkwort der sechs Bedrohungsklassen):

Buchstabe Bedrohung Verletzte Eigenschaft Beispiel (Foto-Upload-Dienst)
S Spoofing (Identität vortäuschen) Authentizität ein gefälschter Token gibt sich als anderer Nutzer aus
T Tampering (Daten verändern) Integrität ein gespeichertes Bild wird nachträglich ausgetauscht
R Repudiation (Handlung abstreiten) Nachweisbarkeit jemand bestreitet, ein Album gelöscht zu haben, und es gibt kein Audit-Log
I Information Disclosure (Daten preisgeben) Vertraulichkeit private Bilder sind über eine ratbare URL abrufbar
D Denial of Service (Dienst lahmlegen) Verfügbarkeit riesige Uploads füllen die Platte
E Elevation of Privilege (Rechte erweitern) Autorisierung ein normaler Nutzer führt eine Moderator-Funktion aus

Zwei Grundhaltungen, die daraus folgen:

  • Defense in Depth (gestaffelte Verteidigung): Keine einzelne Schicht muss perfekt sein, weil dahinter die nächste steht. Beispiel Multi-Tenancy gleich: Filter im Code, zusätzlich eine Sicht, die den Mandanten erzwingt, zusätzlich ein Test mit zwei Mandanten.
  • Zero Trust (null Vertrauen als Voreinstellung): Der Netzwerkstandort beweist nichts. Auch Anfragen aus dem “internen” Netz werden authentifiziert und autorisiert. Warum das nötig ist, siehst du in Schritt 7: SSRF macht deinen Server zum internen Absender.

Schritt 6: Multi-Tenancy, oder: der vergessene Filter

Multi-Tenancy (Mandantenfähigkeit) heißt: Eine Anwendung bedient viele Kunden (Tenants), und keiner darf die Daten eines anderen sehen. Es gibt drei Grundmodelle (qualitativ, ohne Zahlen):

Modell Isolation Aufwand und Kosten
Gemeinsame Tabellen mit Spalte tenant_id schwächste, hängt an jeder Query niedrigster, einfach zu betreiben
Ein Schema pro Mandant mittel mehr Betrieb (Migrationen pro Schema)
Eine Datenbank pro Mandant stärkste höchster Betrieb und Kosten

Beim ersten Modell steckt die Gefahr: Jede Query braucht WHERE tenant_id = .... Jede. Für immer. Auch die, die ein Kollege in zwei Jahren schreibt. So sieht der Fehler aus:

Ausgabe: ('Preisliste B1',). Mandant 1 liest die Notiz von Mandant 2, weil ein Filter fehlt. Die Antwort auf den zweiten Prüfstein ist: Verlass dich nicht darauf, dass jeder Entwickler jeden Filter schreibt, sondern baue eine Stelle, die den Mandanten erzwingt. Dafür gibt es zwei Wege:

  1. Zugriffsschicht (z. B. ein Repository, dessen Methoden keinen tenant_id-Parameter haben, weil der Mandant aus der angemeldeten Sitzung kommt, nie aus dem Request).
  2. Die Datenbank erzwingt es. PostgreSQL hat dafür Row Level Security (RLS, Policies pro Tabelle). SQLite hat das nicht. Wir bilden es nach, mit einer temporären Sicht (temp view), die die echte Tabelle verdeckt:

Ausgabe: None ('Plan A2',). Dieselbe Query, dieselbe Funktion, kein Filter darin: Die Notiz von Mandant 2 ist unsichtbar. Der Trick: SQLite sucht unqualifizierte Namen zuerst im temporären Bereich, dort steht jetzt die Sicht notizen. Innerhalb der Sicht muss die echte Tabelle mit main. angesprochen werden. Ohne main. würde die Sicht sich selbst aufrufen und SQLite meldet view notizen is circularly defined.

Zwei Fallen: Temporäre Sichten gehören zur Verbindung (connection). Wenn ein Connection Pool (Verbindungen werden wiederverwendet) eine Verbindung von Mandant 1 an Mandant 2 weitergibt, bleibt die Sicht von Mandant 1 stehen. Und DDL-Befehle (CREATE VIEW) kennen keine Platzhalter ?: Der Mandant muss im Text stehen, also vorher als Zahl geprüft werden, sonst ist es wieder eine Injection. Eine alte Sicht entfernst du vor dem Neuanlegen mit DROP VIEW IF EXISTS temp.name. Diese Fallen löst du in Übung 3.

Schritt 7: SSRF und weitere Angriffsklassen im Überblick

SSRF (Server-Side Request Forgery): Deine Anwendung holt auf Wunsch des Nutzers eine URL (Bildvorschau, Webhook, URL-Import). Der Nutzer gibt als Ziel eine interne Adresse an, und dein Server ruft sie ab, mit dem Vertrauen, das das interne Netz ihm gibt. Zum Beispiel eine Adresse wie 169.254.169.254, unter der Cloud-Anbieter Metadaten bereitstellen (laut gängiger Praxis, bitte prüfen).

Die naive Prüfung scheitert sofort. Hier die Python-Version des Node-Beispiels:

Ausgabe:

True bilder.example
True bilder.example.evil.test
True evil.test
False 169.254.169.254

Die Prüfung erlaubt drei von vier, aber nur die erste gehört wirklich zu bilder.example. Bei der zweiten ist der Host eine Subdomain eines fremden Namens, bei der dritten steht bilder.example als Benutzername vor dem @.

Der richtige Ansatz ist eine Allowlist (Erlaubtliste: nur was ausdrücklich erlaubt ist, geht durch) statt einer Blocklist (alles außer dem Bösen, das du aufgezählt hast). Die Entscheidung hat mehrere Stufen:

  1. Schema: nur https. Nicht file:, gopher: oder http:.
  2. Zugangsdaten und Port: kein user@ in der URL, nur Standardport.
  3. Host: exakter Vergleich mit der Allowlist, nicht startswith, nicht endswith.
  4. Aufgelöste IP-Adressen: Auch ein erlaubter Name darf nicht auf eine private, Loopback- oder Link-Local-Adresse zeigen. Die Standardbibliothek ipaddress kennt dafür Eigenschaften wie is_global, is_loopback und is_multicast.
  5. Redirects: Folgt der HTTP-Client einer Weiterleitung, wird das neue Ziel von vorn geprüft (oder Redirects werden ausgeschaltet).
  6. DNS-Umbindung (DNS rebinding): Zwischen Prüfung und Verbindung kann ein Name auf eine andere IP zeigen. Darum verbindest du dich mit der geprüften IP statt den Namen ein zweites Mal aufzulösen.

Stufe 5 und 6 gehören in den HTTP-Client und lassen sich hier nicht simulieren. In Übung 4 baust du die Stufen 1 bis 4. Dafür brauchst du zwei Werkzeuge der Standardbibliothek:

  • urlparse(url) liefert .scheme, .hostname (schon kleingeschrieben), .username, .password und .port. Ein kaputter Port (:abc oder :99999) löst erst beim Lesen von .port einen ValueError aus.
  • ipaddress.ip_address(text) liefert ein Objekt mit Eigenschaften wie is_private, is_loopback, is_link_local, is_multicast, is_reserved, is_unspecified und is_global. Bei IPv6-Adressen in IPv4-Schreibweise (::ffff:10.0.0.5) steckt die enthaltene IPv4-Adresse in .ipv4_mapped.

So sieht is_global bei einigen Adressen aus (ausgeführt):

Adresse is_global
93.184.216.34 True
10.0.0.5 False
169.254.169.254 False
127.0.0.1 False

Achtung: is_global allein reicht nicht, wie Übung 4 zeigt.

Kurzer Überblick über weitere Angriffsklassen. Die Tiefe kommt jeweils später:

Klasse Idee Gegenmittel
Unsichere Deserialisierung Beim Laden fremder Bytes mit pickle wird Code ausgeführt (siehe Python Block 1, Teil 3b) nie fremde Daten mit pickle laden, Datenformate wie JSON mit Schema
Request Smuggling Proxy und Server lesen die Grenzen eines HTTP-Requests unterschiedlich, ein versteckter zweiter Request rutscht durch gleiche, aktuelle HTTP-Software und Konfiguration auf beiden Seiten (bitte prüfen)
Timing-Angriff Ein Vergleich, der beim ersten falschen Zeichen aufhört, verrät durch seine Laufzeit, wie viel stimmt zeitkonstanter Vergleich: hmac.compare_digest
Prompt Injection Fremder Text im Kontext eines Sprachmodells wird als Anweisung gelesen Tiefe im KI-Track

Der zeitkonstante Vergleich in Python:

Ausgabe: False False. Das Ergebnis ist gleich, der Unterschied liegt in der Laufzeit, die du hier nicht siehst. == darf bei Token und Signaturen nicht verwendet werden.

Falle

  1. Login mit Berechtigung verwechseln. “Angemeldet” heißt nicht “darf”. Jeder Handler, der ein Objekt über eine ID lädt, prüft Besitz oder Berechtigung am Objekt (Schritt 4).
  2. Autorisierung im Frontend. Ein ausgeblendeter Button, eine gesperrte Route oder ein versteckter Menüpunkt schützt nichts. Der Server entscheidet (Schritt 1).
  3. Blocklist statt Allowlist. “javascript:” oder “localhost” zu verbieten übersieht Schreibweisen, Zahlenformate und IPv6. Erlaube nur, was du ausdrücklich willst.
  4. Ein Escaping für alle Kontexte. Text, Attribut und URL brauchen verschiedene Behandlung. Und URL-Attribute brauchen zusätzlich eine Schema-Prüfung (Schritt 2).
  5. Filter pro Query statt eine erzwingende Stelle. Es ist nur eine Frage der Zeit, bis einer fehlt (Schritt 6).
  6. Interne Adressen als vertrauenswürdig behandeln. Was aus dem eigenen Netz kommt, kann auf Wunsch eines Fremden dort hingeschickt worden sein (SSRF, Schritt 7).

Übungen

Übung 1: Escaping nach Kontext (ca. 9 Min.)

Ein Gästebuch baut für jeden Eintrag dieses HTML. Alle Attribute stehen in doppelten Anführungszeichen.

<div data-name="ATTR1"><p>TEXT</p><a href="ATTR2">Profil</a></div>

TEXT ist der Kommentar des Gastes, ATTR1 sein Name, ATTR2 der Link des Gastes. Du schreibst drei Funktionen:

  • esc_text(s) macht s sicher für den Platz TEXT (HTML-Text).
  • esc_attr(s) macht s sicher für einen Attributwert in doppelten Anführungszeichen.
  • sichere_url(url) gibt url unverändert zurück, wenn sie mit http://, https:// oder / (eigener Pfad) beginnt. Sonst gibt sie "#" zurück. Das Ergebnis wird danach von esc_attr behandelt (nicht von dir in sichere_url).

Der Check baut das Gästebuch-HTML aus deinen Funktionen, lässt einen Browser-ähnlichen Parser darüber laufen und prüft, dass kein fremdes Tag, kein fremdes Attribut entsteht und dass der Text nach dem Parsen genau der Originaltext ist. Als Testdaten dienen harmlose Markierungs-Strings wie <b>MARKE1</b> oder " onmouseover="MARKE3. Die letzte Zeile gibt die drei Funktionen zurück.

Welche Zeichen darf ein Attributwert in Anführungszeichen nicht enthalten, die im Text unproblematisch wären? Und was passiert mit dem Text &lt;, wenn du die Zeichen in der falschen Reihenfolge ersetzt? Bei der URL: Wähle, was du erlaubst, statt aufzuzählen, was du verbietest.

def esc_text(s):
    return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")

def esc_attr(s):
    return esc_text(s).replace('"', "&quot;")

def sichere_url(url):
    if url.startswith(("http://", "https://", "/")):
        return url
    return "#"

esc_text, esc_attr, sichere_url

Auch html.escape ist für Text und Attribut richtig. esc_attr braucht zusätzlich das Anführungszeichen, & muss zuerst ersetzt werden. Die URL ist eine Allowlist über den Anfang: JaVaScRiPt:, führende Leerzeichen oder java<Tab>script: beginnen nicht mit einem erlaubten Präfix und fallen automatisch durch.

Übung 2: IDOR reparieren, Autorisierung am Objekt (ca. 9 Min.)

Ein Rechnungsportal hat drei Funktionen. db ist ein Dict von Rechnungsnummer auf Rechnung ({"besitzer": ..., "betrag": ..., "status": ...}). nutzer ist None (nicht angemeldet) oder ein Dict {"name": ..., "rolle": "kunde" | "buchhaltung"}. Alle drei prüfen bisher nur den Login. Das ist der Fehler.

Setze diese Regeln durch (die Ausnahmen Unauthorized, Forbidden und NotFound sind schon definiert):

  • Nicht angemeldet (nutzer is None): immer Unauthorized.
  • rechnung_holen(db, nutzer, nr): Ein Kunde bekommt nur seine eigenen Rechnungen. Für fremde und für nicht vorhandene Nummern gibt es dieselbe Antwort NotFound, damit niemand herausfindet, welche Nummern existieren. Die Buchhaltung darf jede vorhandene Rechnung lesen (nicht vorhandene: NotFound).
  • rechnungen_liste(db, nutzer): sortierte Liste der Nummern, die der Nutzer sehen darf (Kunde: eigene, Buchhaltung: alle).
  • rechnung_stornieren(db, nutzer, nr): Nur die Buchhaltung darf stornieren, sie setzt status auf "storniert" und gibt die Rechnung zurück. Ein Kunde bekommt für eine fremde oder nicht vorhandene Nummer NotFound, für seine eigene Rechnung Forbidden (er sieht sie ja, darf aber nicht stornieren). Nichts darf sich ändern, wenn eine Ausnahme kommt.

Welche zwei Fragen muss jede Funktion beantworten, bevor sie ein Objekt herausgibt oder ändert? Und was verrät dem Anfragenden eine unterschiedliche Antwort für “fremd” und “nicht vorhanden”?

def _sichtbar(db, nutzer, nr):
    rechnung = db.get(nr)
    if rechnung is None:
        raise NotFound()
    if nutzer["rolle"] != "buchhaltung" and rechnung["besitzer"] != nutzer["name"]:
        raise NotFound()
    return rechnung

def rechnung_holen(db, nutzer, nr):
    if nutzer is None:
        raise Unauthorized()
    return _sichtbar(db, nutzer, nr)

def rechnungen_liste(db, nutzer):
    if nutzer is None:
        raise Unauthorized()
    if nutzer["rolle"] == "buchhaltung":
        return sorted(db)
    return sorted(nr for nr, r in db.items() if r["besitzer"] == nutzer["name"])

def rechnung_stornieren(db, nutzer, nr):
    if nutzer is None:
        raise Unauthorized()
    rechnung = _sichtbar(db, nutzer, nr)
    if nutzer["rolle"] != "buchhaltung":
        raise Forbidden()
    rechnung["status"] = "storniert"
    return rechnung

rechnung_holen, rechnungen_liste, rechnung_stornieren

Eine einzige Hilfsfunktion _sichtbar entscheidet, was ein Nutzer sehen darf, und alle Endpunkte benutzen sie. So kann kein Endpunkt die Objekt-Prüfung vergessen. Die Funktions-Prüfung (nur Buchhaltung darf stornieren) kommt danach.

Übung 3: Mandanten-Isolation, die ein vergessener Filter nicht umgeht (ca. 10 Min.)

Ein Ticket-System speichert die Tickets und Kommentare aller Kunden in gemeinsamen Tabellen (tickets und kommentare, beide mit Spalte tenant_id). Die App-Funktionen sind fehlerhaft: Keine einzige filtert nach Mandant. Sie sehen so aus und du änderst sie nicht:

def ticket_nach_id(conn, ticket_id):
    row = conn.execute("SELECT titel FROM tickets WHERE id = ?", (ticket_id,)).fetchone()
    return row[0] if row else None

def alle_titel(conn):
    return [r[0] for r in conn.execute("SELECT titel FROM tickets ORDER BY id")]

def kommentare_zu(conn, ticket_id):
    return [r[0] for r in conn.execute("SELECT text FROM kommentare WHERE ticket_id = ? ORDER BY id", (ticket_id,))]

def anzahl(conn):
    return conn.execute("SELECT COUNT(*) FROM tickets").fetchone()[0]

Du schreibst mandant_sicht(conn, tenant_id). Sie richtet auf der Verbindung für jede Tabelle in TABELLEN eine temporäre Sicht gleichen Namens ein, die nur die Zeilen des Mandanten zeigt. Danach sieht jede App-Funktion nur noch diesen Mandanten, ohne dass ihr Code sich ändert. Regeln:

  • Die Funktion kann mehrmals auf derselben Verbindung mit verschiedenen Mandanten aufgerufen werden (Connection Pool). Danach gilt der zuletzt gesetzte Mandant.
  • Ein ungültiger Mandant (alles, was keine ganze Zahl ist, zum Beispiel "1 OR 1=1" oder None) löst eine Ausnahme aus, bevor irgendetwas verändert wird. Nach so einem Fehler darf nie der Zugriff auf andere Mandanten offen sein.
  • Die echten Tabellen bleiben unverändert.

Ein Befehl, der einen Namen anlegt, darf keine Platzhalter benutzen. Wie kommt der Mandant also in den Text, und wann prüfst du ihn? Was musst du mit einer schon vorhandenen Sicht tun, bevor du eine neue anlegst, und wie sprichst du in der Sicht die echte Tabelle an?

TABELLEN = ["tickets", "kommentare"]

def mandant_sicht(conn, tenant_id):
    tid = int(tenant_id)            # zuerst prüfen: wirft bei "1 OR 1=1" und None
    for tabelle in TABELLEN:
        conn.execute(f"DROP VIEW IF EXISTS temp.{tabelle}")
        conn.execute(f"CREATE TEMP VIEW {tabelle} AS SELECT * FROM main.{tabelle} WHERE tenant_id = {tid}")

mandant_sicht

int() prüft vor dem ersten Befehl, deshalb bleibt bei einem Fehler der alte Zustand erhalten. Der Mandant steht als geprüfte Zahl im Text, weil DDL keine Platzhalter kennt. Das DROP VIEW IF EXISTS ist nötig, damit ein Mandantenwechsel auf derselben Verbindung wirkt (CREATE ... IF NOT EXISTS würde die alte Sicht stehen lassen und den alten Mandanten behalten). main. verhindert, dass die Sicht sich selbst aufruft. Die Tabellennamen kommen aus einer festen Liste, nicht aus einer Eingabe.

Zur Einordnung: Das ist eine Simulation der Idee hinter Row Level Security. Sie schützt vor dem Vergessen eines Filters beim Lesen. Für Schreibzugriffe (INSERT mit falscher tenant_id) bräuchte es zusätzliche Regeln, und PostgreSQL bietet sie mit Policies. Gerade deshalb bleibt Defense in Depth nötig: Zugriffsschicht, erzwingende Datenbank-Regel und ein Test, der jede neue Query mit zwei Mandanten ausführt.

Übung 4: SSRF-Validator als Allowlist (ca. 10 Min.)

Dein Dienst ruft Webhook-Ziele ab, die Kunden eintragen. Du schreibst ziel_erlaubt(url, erlaubte_hosts, dns):

  • erlaubte_hosts ist ein Set kleingeschriebener Hostnamen.
  • dns simuliert die Namensauflösung: ein Dict vom Hostnamen auf eine Liste von IP-Adressen als Text ({"hooks.partner.example": ["93.184.216.34"]}).
  • Gib True zurück, nur wenn alle Bedingungen stimmen, sonst False. Die Funktion löst nie eine Ausnahme aus (auch nicht bei Müll wie None, "" oder einem kaputten Port).

Bedingungen:

  1. Schema genau https (Groß- und Kleinschreibung egal).
  2. Keine Zugangsdaten in der URL (kein name@ oder name:pw@ vor dem Host).
  3. Kein Port oder Port 443.
  4. Host (klein geschrieben) steht exakt in erlaubte_hosts. Subdomains sind nicht automatisch erlaubt.
  5. Für den Host liefert dns mindestens eine IP, und alle IPs sind öffentlich: nicht privat, Loopback, Link-Local, Multicast, reserviert oder unspezifiziert. Eine IPv6-Adresse in IPv4-Schreibweise (::ffff:10.0.0.5) wird wie die enthaltene IPv4-Adresse behandelt.

Beispiele:

URL Ergebnis (bei passendem dns)
https://hooks.partner.example/in/42 (löst auf öffentliche IP auf) True
http://hooks.partner.example/in/42 False (Schema)
https://hooks.partner.example.evil.test/ False (Host)
https://hooks.partner.example:8443/ False (Port)
https://intern.kunde.example/ (erlaubter Name, löst auf 10.0.0.5 auf) False (IP)

Der Check testet noch weitere Fälle, die du aus den fünf Regeln ableiten kannst.

Was liefert urlparse für Benutzername, Host und Port, und wann löst .port eine Ausnahme aus? Mehrere IPs: Welche Verknüpfung (alle oder eine) willst du? Reicht eine einzelne Eigenschaft von ipaddress, um “öffentlich” zu beschreiben, und was macht ein IPv6-Wrapper um eine IPv4-Adresse?

import ipaddress
from urllib.parse import urlparse

def _oeffentlich(ip_text):
    ip = ipaddress.ip_address(ip_text)
    if ip.version == 6 and ip.ipv4_mapped is not None:
        ip = ip.ipv4_mapped
    return (ip.is_global and not ip.is_multicast and not ip.is_loopback
            and not ip.is_link_local and not ip.is_reserved and not ip.is_unspecified)

def ziel_erlaubt(url, erlaubte_hosts, dns):
    try:
        p = urlparse(url)
        if p.scheme != "https":
            return False
        if p.username is not None or p.password is not None:
            return False
        if p.port not in (None, 443):
            return False
        host = p.hostname
        if host is None or host not in erlaubte_hosts:
            return False
        ips = dns.get(host, [])
        if not ips:
            return False
        return all(_oeffentlich(i) for i in ips)
    except Exception:
        return False

ziel_erlaubt

Der Host wird exakt verglichen, die Adressen alle geprüft (eine einzige interne IP reicht für einen Angriff). is_global allein lässt Multicast-Adressen durch, deshalb die zusätzliche Prüfung. Bei jedem Fehler lautet die Antwort False: Der sichere Standard ist “nein”.

Übung 5: Bedrohungen mit STRIDE zuordnen (ca. 5 Min.)

Ein Reisebuchungs-Portal wurde untersucht. Ordne jedem Befund den einen passenden STRIDE-Buchstaben zu ("S", "T", "R", "I", "D", "E"). Jeder Buchstabe kommt genau einmal vor. Gib ein Tupel mit sechs Buchstaben in der Reihenfolge der Befunde zurück, zum Beispiel ("S", "T", "R", "I", "D", "E").

  1. Eine Fehlerseite zeigt bei einer Ausnahme den kompletten Stacktrace samt Zugangsdaten der Datenbank an.
  2. Ein Skript schickt in Sekunden zehntausende Suchanfragen mit Platzhaltern, bis das Portal für alle zu langsam wird.
  3. Jemand meldet sich mit dem abgefangenen Sitzungs-Cookie einer Kundin an und bucht in ihrem Namen.
  4. Ein Kunde behauptet, nie storniert zu haben. Das System hat kein Protokoll darüber, wer wann storniert hat, und kann es nicht widerlegen.
  5. Ein normaler Kunde ruft die Funktion “Rabattcode erzeugen” auf, die nur im Menü ausgeblendet war, und der Server führt sie aus.
  6. Auf dem Weg zum Server (ungesicherte Verbindung) wird in einer Buchungsanfrage der Preis geändert.

Frage dich bei jedem Befund: Welche Sicherheitseigenschaft wird verletzt (Echtheit der Identität, Unveränderlichkeit, Nachweisbarkeit, Vertraulichkeit, Verfügbarkeit oder die Berechtigung)? Entscheide nach dem, was zuerst schiefgeht.

antwort = ("I", "D", "S", "R", "E", "T")
antwort

1: Vertraulichkeit verletzt, Information Disclosure. 2: Verfügbarkeit, Denial of Service. 3: Jemand gibt sich als eine andere Person aus, Spoofing. 4: Es fehlt der Nachweis, Repudiation. 5: Eine Funktion läuft ohne Berechtigung, Elevation of Privilege. 6: Daten werden auf dem Weg verändert, Tampering.

Merksatz

Der Client ist unvertrauenswürdig, darum gehören Validierung und Autorisierung auf den Server, und zwar dort, wo das Objekt geladen wird. Wo ein vergessener Filter Daten leaken würde, baust du eine Stelle, die ihn erzwingt.

Prüfstein

  1. Warum reicht Eingabevalidierung im Frontend nicht, und wo gehört Autorisierung hin?
  2. Wie baust du eine Multi-Tenant-Anwendung, in der ein vergessener Filter in einer Query nicht zu einem Datenleck führt?

Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitt “8. Security und Qualität” (Angriffe: OWASP Top 10, vor allem Injection, XSS, CSRF, Broken Access Control; Prüfstein zur Frontend-Validierung und zum Ort der Autorisierung). quellen/konzeptuebersicht-software-fortgeschritten.docx, Abschnitt “8. Security, Qualität und Engineering-Praxis” (Threat Modeling: STRIDE, Trust Boundaries, Angriffsfläche, Defense in Depth, Zero Trust; Autorisierungsmodelle RBAC, ABAC, ReBAC, Policy Engines, Multi-Tenancy-Isolation; Angriffsklassen SSRF, unsichere Deserialisierung, Request Smuggling, Timing-Angriffe, Prompt Injection). Der Prüfstein zur Multi-Tenancy steht im .docx in Schicht 8 (Autorisierungsmodelle, Multi-Tenancy-Isolation).

Über die Quelle hinaus (allgemeines Fachwissen): die Erklärungen zu XSS-Kontexten und zur Reihenfolge beim Escaping, CSP, SameSite-Cookies und CSRF-Token, die Unterscheidung 401/403/404 und das Antwortverhalten gegen Durchprobieren, die Python-Beispiele zu RBAC, ABAC und ReBAC, die Beispiele OPA und Cedar für Policy Engines (bitte prüfen), die vier Fragen des Threat Modelings, die STRIDE-Tabelle mit Eigenschaften, die drei Multi-Tenancy-Modelle, PostgreSQL Row Level Security, das Verhalten temporärer Sichten in SQLite (mit Python ausgeführt), die Stufen der SSRF-Abwehr samt Redirect und DNS-Umbindung, die Metadaten-Adresse von Cloud-Anbietern (bitte prüfen), hmac.compare_digest als zeitkonstanter Vergleich, die Platzierung von Broken Access Control in der aktuellen OWASP-Liste (bitte prüfen), die Aussagen zu Request Smuggling (bitte prüfen). Alle Code-Ausgaben in dieser Lektion stammen aus dem Ausführen des Codes mit Python 3 beziehungsweise Node. Die Eigenschaft is_global und das Verhalten von IPv4-gemappten IPv6-Adressen können je nach Python-Version abweichen (bitte prüfen).