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:
static String placeOrder(Map<String, String> form) { int price = Integer.parseInt(form.get("price")); int qty = Integer.parseInt(form.get("qty")); return "Bestellung: " + qty + " Stück à " + price + " € = " + qty * price + " €";}
// Von der Seite: Der Browser hat das Formular geprüftSystem.out.println(placeOrder(Map.of("product", "17", "price", "59", "qty", "2")));// Dieselbe Anfrage, von Hand geschicktSystem.out.println(placeOrder(Map.of("product", "17", "price", "1", "qty", "2")));System.out.println(placeOrder(Map.of("product", "17", "price", "59", "qty", "-3")));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.1Content-Type: application/x-www-form-urlencoded
product=17&price=59&qty=-3Der 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:
static String placeOrder(Map<String, String> form) { String product = form.getOrDefault("product", ""); String qty = form.getOrDefault("qty", ""); if (!PRICES.containsKey(product)) { return "Abgelehnt: kein solches Produkt"; } if (!qty.matches("[1-9]|10")) { return "Abgelehnt: Die Menge ist 1 bis 10"; } int count = Integer.parseInt(qty); int price = PRICES.get(product); // aus dem Katalog return "Bestellung: " + count + " Stück à " + price + " € = " + count * price + " €";}Schritt für Schritt, in Worten
- 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.
- Sie überqueren die Vertrauensgrenze. Ab hier ist nichts geprüft. placeOrder liest nur product und qty; der Preis aus der Anfrage wird nie gelesen.
- Erste Prüfung: Ist das Produkt im Katalog? 17 ist es, bei beiden. Eine unbekannte Id würde hier stoppen.
- 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.
- Der Preis kommt aus dem Katalog: 59 €. Was die Anfrage zum Preis sagt, spielt keine Rolle.
- 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:
- Lies nicht, was du weißt. Der Preis steht im Katalog. Die Anfrage sagt nur, welches Produkt und wie viele.
- Prüfe gegen eine Allow-List. Das Produkt muss im Katalog sein. Die Menge muss genau einer der Texte
"1"bis"10"sein. - Wenn etwas nicht durchgeht, lehne ab. Jeder Weg, der nicht bei der Bestellung ankommt, ist eine Ablehnung.
static final Map<String, Integer> PRICES = Map.of("17", 59, "23", 9);
static String placeOrder(Map<String, String> form) { String product = form.getOrDefault("product", ""); String qty = form.getOrDefault("qty", ""); if (!PRICES.containsKey(product)) { return "Abgelehnt: kein solches Produkt"; } if (!qty.matches("[1-9]|10")) { return "Abgelehnt: Die Menge ist 1 bis 10"; } int count = Integer.parseInt(qty); int price = PRICES.get(product); // aus dem Katalog return "Bestellung: " + count + " Stück à " + price + " € = " + count * price + " €";}
System.out.println(placeOrder(Map.of("product", "17", "price", "59", "qty", "2")));System.out.println(placeOrder(Map.of("product", "17", "price", "1", "qty", "2")));System.out.println(placeOrder(Map.of("product", "17", "price", "59", "qty", "-3")));System.out.println(placeOrder(Map.of("product", "99", "price", "1", "qty", "1")));Bestellung: 2 Stück à 59 € = 118 €Bestellung: 2 Stück à 59 € = 118 €Abgelehnt: Die Menge ist 1 bis 10Abgelehnt: kein solches Produktmatches 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:
static String placeOrder(Map<String, String> form) {
String product = form.getOrDefault("product", "");
String qty = form.getOrDefault("qty", "");
if (!PRICES.containsKey(product)) {
return "Abgelehnt: kein solches Produkt";
}
if (!qty.matches("[1-9]|10")) {
return "Abgelehnt: Die Menge ist 1 bis 10";
}
int count = Integer.parseInt(qty);
int price = ;
return "Bestellung: " + count + " Stück à " + price
+ " € = " + count * price + " €";
}Der Preis steht im Katalog, über die Id des Produkts. Die Anfrage kann beliebig viele Preise mitschicken: Der Handler liest sie nicht.
Probier es aus
Drei Anfragen
Was gibt der korrigierte Handler für diese drei Anfragen aus?
System.out.println(placeOrder(Map.of("product", "23", "price", "0", "qty", "10")));System.out.println(placeOrder(Map.of("product", "17", "qty", "11")));System.out.println(placeOrder(Map.of("product", "17", "qty", " 3")));Die erste ist ein Kabel (Produkt 23) für 9 €: Der Preis 0 in der Anfrage wird nicht gelesen. 11 steht nicht in der Liste von 1 bis 10. " 3" hat ein Leerzeichen davor, und das ist keiner der erlaubten Texte. „Repariere“ die Eingabe nicht für den Käufer: Was nicht genau passt, wird abgelehnt.
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:
static boolean denyList(String qty) { return !qty.startsWith("-") && !qty.equals("0");}
static boolean allowList(String qty) { return qty.matches("[1-9]|10");}
static String word(boolean ok) { return ok ? "angenommen" : "abgelehnt";}
System.out.println(String.format("%-6s%-11s%s", "Wert", "deny-list", "allow-list"));for (String qty : List.of("2", "-3", "0", "500", "2.5", "1e3")) { System.out.println(String.format("%-6s%-11s%s", qty, word(denyList(qty)), word(allowList(qty))));}Wert deny-list allow-list2 angenommen angenommen-3 abgelehnt abgelehnt0 abgelehnt abgelehnt500 angenommen abgelehnt2.5 angenommen abgelehnt1e3 angenommen abgelehntDie 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.
1042ist 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.
String.matchesprüft den ganzen Text, währendMatcher.finddas Muster irgendwo darin sucht:findmit[1-9]|10lässt auch"500"durch. - In einem echten Projekt ist die Validierung deklarativ. Jakarta Bean Validation:
@Min(1) @Max(10) int qtyund@Validin einem Spring-Controller. 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:
static final Map<String, Integer> LIMITS = Map.of("17", 10, "23", 50);
static int limitOf(String product) { Integer limit = LIMITS.get(product); if (limit == null) { throw new IllegalArgumentException("unbekanntes Produkt " + product); } return limit;}
static boolean mayBuy(String product, int qty) { boolean allowed = true; try { allowed = qty <= limitOf(product); } catch (IllegalArgumentException e) { System.out.println("Log: " + e.getMessage()); } return allowed;}
System.out.println("17 x 3: " + (mayBuy("17", 3) ? "ja" : "nein"));System.out.println("17 x 500: " + (mayBuy("17", 500) ? "ja" : "nein"));System.out.println("99 x 500: " + (mayBuy("99", 500) ? "ja" : "nein"));17 x 3: ja17 x 500: neinLog: unbekanntes Produkt 9999 x 500: ja500 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.
Der Startwert. Wirft limitOf, passiert die Zuweisung in try nie, und das anfängliche „erlaubt“ bleibt. Fang mit einer Ablehnung an: boolean allowed = false;. Dann bedeutet ein Fehler „nein“, und die letzte Zeile wird zu 99 x 500: nein.
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:
"AUTUMN26": 90 €"autumn26": abgelehnt"AUTUMN26' OR '1'='1": abgelehnt"": abgelehnt"STUDENTS": 108 €"NOPE1234": abgelehntautumn26 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/1042gibt die Bestellung 1042 zurück. Die Eingabe1042besteht jede Formatprüfung. Was muss der Server noch prüfen?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Reguläre Ausdrücke · Listen · HashMap und HashSet · Netzwerkprogrammierung
- Geht weiter in: Injektionen: wenn Daten zum Befehl werden · XSS: ein fremdes Skript in deiner Seite · Sitzungen, Cookies und CSRF · JWT und seine Fallen
- Aufgaben zum Thema: Sicherheit für Entwickler