Zum Inhalt
wannacode

Nicht vertrauenswürdige Eingaben

Kurz gesagt: Nicht vertrauenswürdig ist alles, was von außen kommt (Anfrage, Formular, Datei); es wird auf dem Server gegen eine Allow-List geprüft, und bei jedem Fehler wird die Anfrage abgelehnt.

Problem

Kopfhörer für -177 €

Der Shop verkauft Kopfhörer für 59 €. Das Formular auf der Seite lässt keine Menge unter 1 oder über 10 zu, und der Preis steht in einem versteckten Feld:

<form method="post" action="/orders">
<input type="hidden" name="product" value="17">
<input type="hidden" name="price" value="59">
<input type="number" name="qty" min="1" max="10" value="1">
<button>Kaufen</button>
</form>

Der Server nimmt die Felder und rechnet die Summe aus:

def place_order(form):
price = int(form["price"])
qty = int(form["qty"])
return f"Bestellung: {qty} Stück à {price} € = {qty * price} €"
# Von der Seite: Der Browser hat das Formular geprüft
print(place_order({"product": "17", "price": "59", "qty": "2"}))
# Dieselbe Anfrage, von Hand geschickt
print(place_order({"product": "17", "price": "1", "qty": "2"}))
print(place_order({"product": "17", "price": "59", "qty": "-3"}))
Ausgabe
Bestellung: 2 Stück à 59 € = 118 €
Bestellung: 2 Stück à 1 € = 2 €
Bestellung: -3 Stück à 59 € = -177 €

Die erste Anfrage kommt von der Seite. Die anderen beiden nicht. Der Angreifer hat die Entwicklerwerkzeuge des Browsers geöffnet oder die Anfrage mit curl geschickt und hineingeschrieben, was er wollte:

POST /orders HTTP/1.1
Content-Type: application/x-www-form-urlencoded
product=17&price=59&qty=-3

Der Zahlungsanbieter bekommt die Summe -177 € und erstattet 177 € auf die Karte zurück. Die Prüfung min="1" war nicht da: Die Anfrage ist nie durch das Formular gegangen.

Die Idee

Die Vertrauensgrenze

Der Browser gehört dem Käufer. Alles darin, vom Formular bis zum JavaScript der Seite, kann der Angreifer ändern oder umgehen. Dein Code beginnt auf dem Server. Die Stelle, an der Daten von außen nach innen kommen, ist die Vertrauensgrenze.

Was sie überquert, ist nicht vertrauenswürdige Eingabe, bis du es geprüft hast:

  • die Felder des Formulars, auch die versteckten;
  • die Adresse und die Parameter darin;
  • die Header und Cookies der Anfrage;
  • Name, Typ und Größe einer hochgeladenen Datei;
  • eine Antwort eines fremden Systems, zum Beispiel des Zahlungsanbieters.

Deine Seite hat das versteckte Feld und das Cookie dort hingelegt. Aber der Browser des Käufers schickt sie zurück, und unterwegs können sie verändert worden sein.

Wie der Angreifer zu denken heißt, bei jedem Feld zu fragen: Was, wenn es negativ ist? Leer? Riesig? Text statt einer Zahl? Die Id einer fremden Bestellung? 10 MB statt 10 Zeichen?

Sieh es dir an

Was der Server hereinlässt

Der korrigierte Handler vertraut nichts in der Anfrage. So laufen die beiden Bestellungen durch ihn:

def place_order(form):
product = form.get("product", "")
qty = form.get("qty", "")
if product not in PRICES:
return "Abgelehnt: kein solches Produkt"
if not re.fullmatch(r"[1-9]|10", qty):
return "Abgelehnt: Die Menge ist 1 bis 10"
count = int(qty)
price = PRICES[product] # aus dem Katalog
total = count * price
return f"Bestellung: {count} Stück à {price} € = {total} €"
Schritt für Schritt, in Worten
  1. Zwei Bestellungen kommen an. Eine kommt von der Seite, wo der Browser qty ≥ 1 geprüft hat. Die andere wurde von Hand geschickt: Preis 1, Menge -3. Für den Server sind beide nur Text.
  2. Sie überqueren die Vertrauensgrenze. Ab hier ist nichts geprüft. place_order liest nur product und qty; der Preis aus der Anfrage wird nie gelesen.
  3. Erste Prüfung: Ist das Produkt im Katalog? 17 ist es, bei beiden. Eine unbekannte Id würde hier stoppen.
  4. Zweite Prüfung: Die Menge muss einer der Texte "1" … "10" sein. "2" steht in der Liste. "-3" nicht: abgelehnt, und die Bestellung geht nicht weiter.
  5. Der Preis kommt aus dem Katalog: 59 €. Was die Anfrage zum Preis sagt, spielt keine Rolle.
  6. Die ehrliche Bestellung: 2 × 59 € = 118 €. Die gefälschte ist an den Prüfungen hinter der Grenze hängen geblieben. Hinein kommt nur, was jede Prüfung bestanden hat.

Drei Regeln:

  1. Lies nicht, was du weißt. Der Preis steht im Katalog. Die Anfrage sagt nur, welches Produkt und wie viele.
  2. Prüfe gegen eine Allow-List. Das Produkt muss im Katalog sein. Die Menge muss genau einer der Texte "1" bis "10" sein.
  3. Wenn etwas nicht durchgeht, lehne ab. Jeder Weg, der nicht bei der Bestellung ankommt, ist eine Ablehnung.
import re
PRICES = {"17": 59, "23": 9}
def place_order(form):
product = form.get("product", "")
qty = form.get("qty", "")
if product not in PRICES:
return "Abgelehnt: kein solches Produkt"
if not re.fullmatch(r"[1-9]|10", qty):
return "Abgelehnt: Die Menge ist 1 bis 10"
count = int(qty)
price = PRICES[product] # aus dem Katalog
total = count * price
return f"Bestellung: {count} Stück à {price} € = {total} €"
print(place_order({"product": "17", "price": "59", "qty": "2"}))
print(place_order({"product": "17", "price": "1", "qty": "2"}))
print(place_order({"product": "17", "price": "59", "qty": "-3"}))
print(place_order({"product": "99", "price": "1", "qty": "1"}))
Ausgabe
Bestellung: 2 Stück à 59 € = 118 €
Bestellung: 2 Stück à 59 € = 118 €
Abgelehnt: Die Menge ist 1 bis 10
Abgelehnt: kein solches Produkt
re.fullmatch prüft den ganzen Text gegen das Muster: [1-9]|10 bedeutet eine Ziffer von 1 bis 9 oder genau 10.

Probier es aus

Woher der Preis kommt

Ergänze die Zeile, in der der Handler den Preis bestimmt:

def place_order(form):
    product = form.get("product", "")
    qty = form.get("qty", "")
    if product not in PRICES:
        return "Abgelehnt: kein solches Produkt"
    if not re.fullmatch(r"[1-9]|10", qty):
        return "Abgelehnt: Die Menge ist 1 bis 10"
    count = int(qty)
    price = 
    total = count * price
    return f"Bestellung: {count} Stück à {price} € = {total} €"

Probier es aus

Drei Anfragen

Was gibt der korrigierte Handler für diese drei Anfragen aus?

print(place_order({"product": "23", "price": "0", "qty": "10"}))
print(place_order({"product": "17", "qty": "11"}))
print(place_order({"product": "17", "qty": " 3"}))
Antwort

Die Idee

Erlaubt, nicht verboten

Viele denken zuerst daran, das Schlechte zu verbieten: „Die Menge darf nicht negativ oder null sein.“ Das ist eine Deny-List. Der andere Weg beschreibt genau das Gute: „Die Menge ist einer der Texte von 1 bis 10.“ Das ist eine Allow-List.

Hier sind beide mit denselben Eingaben:

import re
def deny_list(qty):
return not qty.startswith("-") and qty != "0"
def allow_list(qty):
return re.fullmatch(r"[1-9]|10", qty) is not None
def word(ok):
return "angenommen" if ok else "abgelehnt"
print(f"{'Wert':<6}{'deny-list':<11}allow-list")
for qty in ["2", "-3", "0", "500", "2.5", "1e3"]:
print(f"{qty:<6}{word(deny_list(qty)):<11}{word(allow_list(qty))}")
Ausgabe
Wert deny-list allow-list
2 angenommen angenommen
-3 abgelehnt abgelehnt
0 abgelehnt abgelehnt
500 angenommen abgelehnt
2.5 angenommen abgelehnt
1e3 angenommen abgelehnt

Die Deny-List hat 500, 2.5 und 1e3 durchgelassen. Beim Schreiben hat niemand an sie gedacht. Der Angreifer probiert genau das, woran du nicht gedacht hast. Bei einer Allow-List wird das Unbekannte abgelehnt, ohne dass du es vorhersehen musst.

Also: Beschreibe, wie eine richtige Eingabe aussieht (Typ, Länge, Zeichen, Bereich, ein Wert aus einer bekannten Liste), und lehne alles andere ab.

Genauer, für Neugierige
  • Gültig heißt nicht deins. 1042 ist eine völlig korrekte Bestellnummer, kann aber einem anderen Käufer gehören. Eine Formatprüfung reicht nicht: Der Server prüft auch, dass die Bestellung dem angemeldeten Käufer gehört. Ohne das hast du eine der häufigsten Lücken echter Seiten (IDOR, insecure direct object reference).
  • Erst normalisieren, dann prüfen. Liest das System weiter hinten die Eingabe anders als die Prüfung (Leerzeichen, Groß- und Kleinschreibung, Unicode, Kodierung in der Adresse), prüfe die Eingabe, nachdem sie normalisiert wurde. Sonst sieht die Prüfung das eine und das System bekommt das andere.
  • Begrenze die Größe. Jedes Feld hat eine Obergrenze für die Länge und jede hochgeladene Datei eine für die Größe. Sonst frisst ein „Name“ von 50 MB den Speicher des Servers.
  • Reguläre Ausdrücke und das Ende des Textes. re.search und re.match prüfen nicht den ganzen Text: Sie lassen auch "500" durch. $ akzeptiert auch ein \n am Ende, deshalb wird hier re.fullmatch benutzt.
  • In einem echten Projekt ist die Validierung deklarativ. pydantic oder die Formulare von Django und Flask-WTF: conint(ge=1, le=10) und ein Typ für jedes Feld. Die Regeln bleiben gleich: eine Allow-List, bei einem Fehler ablehnen, auf dem Server prüfen.

Problem

Die Prüfung, die bei einem Fehler durchlässt

Der Sale hat ein Limit: höchstens 10 Kopfhörer pro Bestellung. Die Prüfung fragt eine Tabelle mit Limits, und bei einem unbekannten Produkt wirft sie eine Exception. Der Code fängt die Exception, schreibt sie ins Log und macht weiter:

LIMITS = {"17": 10, "23": 50}
def limit_of(product):
if product not in LIMITS:
raise ValueError(f"unbekanntes Produkt {product}")
return LIMITS[product]
def may_buy(product, qty):
allowed = True
try:
allowed = qty <= limit_of(product)
except ValueError as e:
print(f"Log: {e}")
return allowed
print(f"17 x 3: {'ja' if may_buy('17', 3) else 'nein'}")
print(f"17 x 500: {'ja' if may_buy('17', 500) else 'nein'}")
print(f"99 x 500: {'ja' if may_buy('99', 500) else 'nein'}")
Ausgabe
17 x 3: ja
17 x 500: nein
Log: unbekanntes Produkt 99
99 x 500: ja

500 Stück von Produkt 99 sind durchgegangen. Der Fehler in der Prüfung wurde zu „erlaubt“.

Probier es aus

Finde die Zeile

Welche Zeile lässt die Bestellung bei einem Fehler durch?

Wähle die Zeile mit dem Fehler.

Das ist die Regel Fail closed. Das Unbekannte, das Leere, ein Fehler in der Prüfung und eine unerwartete Exception führen alle zu „nein“. Das Gegenteil (Fail open) ist bequem, bis jemand die Eingabe findet, die die Prüfung bricht.

Übung

Der Rabattcode

Löse die Aufgabe in deinem Editor.

Der Gutschein. Der Warenkorb ist 120 € wert. Schreibe discount(code), das die Summe nach dem Rabatt zurückgibt oder „abgelehnt“. Die Codes sind AUTUMN26 (25 %) und STUDENTS (10 %). Lass nur genau 8 Zeichen von A bis Z und 0 bis 9 durch, und nur einen Code, der in der Tabelle steht. Alles andere wird abgelehnt. Probiere diese Eingaben:

Erwartet
"AUTUMN26": 90 €
"autumn26": abgelehnt
"AUTUMN26' OR '1'='1": abgelehnt
"": abgelehnt
"STUDENTS": 108 €
"NOPE1234": abgelehnt

autumn26 wird abgelehnt: Die Regel sagt Großbuchstaben und nicht „repariere es für den Käufer“. Der Shop kann entscheiden, Kleinbuchstaben anzunehmen, aber dann schreibt er das in die Regel und prüft das.

Weitere Aufgaben

  • Die Lieferadresse. Beschreibe mit Regeln, wie eine Postleitzahl (in Bulgarien 4 Ziffern), ein Ort und eine Telefonnummer aussehen. Welche davon kann eine Allow-List sein und welche nur eine Begrenzung von Länge und Zeichen?
  • Eine fremde Bestellung. GET /orders/1042 gibt die Bestellung 1042 zurück. Die Eingabe 1042 besteht jede Formatprüfung. Was muss der Server noch prüfen?

Test

Teste dich

1. Das Mengenfeld auf der Seite ist <input type="number" min="1" max="10">. Muss der Server die Menge prüfen?
2. Was davon ist für den Server des Shops eine nicht vertrauenswürdige Eingabe?
3. Warum ist eine Allow-List sicherer als eine Deny-List?
4. Die Limit-Prüfung wirft eine Exception, weil sie das Produkt nicht kennt. Wofür soll sich der Code entscheiden?
5. Der Preis des Produkts steht in einem versteckten Feld des Formulars. Was macht der Server damit?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·