Недоверен вход
Накратко: Недовереният вход е всичко, което идва отвън (заявка, форма, файл); проверява се на сървъра със списък на разрешеното, а при грешка заявката се отказва.
Проблем
Слушалки за -177 €
Магазинът продава слушалки за 59 €. Формата на страницата не дава да въведеш количество под 1 или над 10, а цената стои в скрито поле:
<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>Купи</button></form>Сървърът взима полетата и смята сумата:
static String placeOrder(Map<String, String> form) { int price = Integer.parseInt(form.get("price")); int qty = Integer.parseInt(form.get("qty")); return "Поръчка: " + qty + " бр. по " + price + " € = " + qty * 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")));Поръчка: 2 бр. по 59 € = 118 €Поръчка: 2 бр. по 1 € = 2 €Поръчка: -3 бр. по 59 € = -177 €Първата заявка е от страницата. Другите две не са. Атакуващият е отворил инструментите на браузъра, или е пратил заявката с curl, и е написал каквото си иска:
POST /orders HTTP/1.1Content-Type: application/x-www-form-urlencoded
product=17&price=59&qty=-3Платежният доставчик получава сума -177 € и връща 177 € на картата. Проверката min="1" я нямаше: заявката изобщо не мина през формата.
Идеята
Границата на доверието
Браузърът е на купувача. Всичко в него, от формата до JavaScript-а на страницата, атакуващият може да промени или да заобиколи. Твоят код започва на сървъра. Мястото, където данните идват отвън навътре, е границата на доверието.
Каквото минава през нея, е недоверен вход (untrusted input), докато не го провериш:
- полетата на формата, и скритите също;
- адресът и параметрите в него;
- заглавките на заявката и бисквитките;
- името, типът и размерът на качен файл;
- отговорът от чужда система, например от платежния доставчик.
Скритото поле и бисквитката ги е сложил твоят сайт. Връща ги обаче браузърът на купувача, и по пътя те може да са сменени.
Да мислиш като атакуващия значи за всяко поле да питаш: ами ако е отрицателно? Празно? Огромно? Текст вместо число? Id на чужда поръчка? 10 MB вместо 10 знака?
Виж го
Какво пуска сървърът
Поправеният обработчик не вярва на нищо от заявката. Ето как минават през него двете поръчки:
static String placeOrder(Map<String, String> form) { String product = form.getOrDefault("product", ""); String qty = form.getOrDefault("qty", ""); if (!PRICES.containsKey(product)) { return "Отказ: няма такъв продукт"; } if (!qty.matches("[1-9]|10")) { return "Отказ: количеството е от 1 до 10"; } int count = Integer.parseInt(qty); int price = PRICES.get(product); // от каталога return "Поръчка: " + count + " бр. по " + price + " € = " + count * price + " €";}Стъпка по стъпка, с думи
- Пристигат две поръчки. Едната е от страницата, където браузърът е проверил qty ≥ 1. Другата е пратена на ръка: цена 1, количество -3. За сървъра и двете са само текст.
- Минават границата на доверието. Оттук нататък нищо не е проверено. placeOrder чете само product и qty; цената от заявката изобщо не се чете.
- Първа проверка: има ли го продукта в каталога? 17 го има и за двете. Непознато id спира тук.
- Втора проверка: количеството трябва да е едно от "1" … "10". "2" е в списъка. "-3" не е: отказ и поръчката не продължава.
- Цената идва от каталога: 59 €. Каквото пише в заявката за цената, няма значение.
- Честната поръчка: 2 × 59 € = 118 €. Подправената спря на проверките след границата. Влиза само това, което е минало всяка проверка.
Три правила:
- Не чети това, което знаеш. Цената е в каталога. Заявката казва само кой продукт и колко бройки.
- Проверявай срещу списък на разрешеното. Продуктът трябва да е в каталога. Количеството трябва да е точно един от текстовете от
"1"до"10". - Ако нещо не мине, отказвай. Всеки път, който не стига до поръчката, е отказ.
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 "Отказ: няма такъв продукт"; } if (!qty.matches("[1-9]|10")) { return "Отказ: количеството е от 1 до 10"; } int count = Integer.parseInt(qty); int price = PRICES.get(product); // от каталога return "Поръчка: " + count + " бр. по " + 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")));Поръчка: 2 бр. по 59 € = 118 €Поръчка: 2 бр. по 59 € = 118 €Отказ: количеството е от 1 до 10Отказ: няма такъв продуктmatches проверява целия текст спрямо шаблона: [1-9]|10 значи една цифра от 1 до 9 или точно 10.Пробвай
Откъде е цената
Довърши реда, на който обработчикът решава цената:
static String placeOrder(Map<String, String> form) {
String product = form.getOrDefault("product", "");
String qty = form.getOrDefault("qty", "");
if (!PRICES.containsKey(product)) {
return "Отказ: няма такъв продукт";
}
if (!qty.matches("[1-9]|10")) {
return "Отказ: количеството е от 1 до 10";
}
int count = Integer.parseInt(qty);
int price = ;
return "Поръчка: " + count + " бр. по " + price
+ " € = " + count * price + " €";
}Цената е в каталога по id на продукта. Заявката може да носи каквато цена си иска: обработчикът не я чете.
Пробвай
Три заявки
Какво отпечатва поправеният обработчик за тези три заявки?
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")));Първата е кабел (продукт 23) за 9 €: цената 0 от заявката не се чете. 11 не е в списъка от 1 до 10. " 3" има интервал отпред, а това не е нито един от разрешените текстове. Не „поправяй“ входа вместо купувача: каквото не отговаря точно, е отказ.
Идеята
Разрешено, а не забранено
Първата идея на много хора е да забранят лошото: „количеството не може да е отрицателно или нула“. Това е списък на забраненото (deny-list). Другият начин е да опишеш точно доброто: „количеството е един от текстовете от 1 до 10“. Това е списък на разрешеното (allow-list).
Ето двата върху едни и същи входове:
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 ? "приет" : "отказ";}
System.out.println(String.format("%-6s%-11s%s", "вход", "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))));}вход deny-list allow-list2 приет приет-3 отказ отказ0 отказ отказ500 приет отказ2.5 приет отказ1e3 приет отказСписъкът на забраненото пусна 500, 2.5 и 1e3. Никой не се беше сетил за тях, когато го пишеше. Атакуващият пробва точно това, за което не си се сетил. При списъка на разрешеното непознатото е отказ, без да трябва да го предвидиш.
Затова: опиши как изглежда правилният вход (тип, дължина, знаци, обхват, стойност от известен списък) и откажи всичко друго.
По-точно, за любопитните
- Валидно не значи твое.
1042е напълно правилен номер на поръчка, но може да е на друг купувач. Проверката за формат не стига: сървърът проверява и дали поръчката е на влезлия купувач. Без това имаш една от най-честите дупки в истинските сайтове (IDOR, insecure direct object reference). - Нормализирай, после проверявай. Ако системата нататък тълкува входа по-различно от проверката (интервали, главни и малки букви, Unicode, кодиране в адреса), проверявай вече приведения вход. Иначе проверката гледа едно, а системата получава друго.
- Ограничавай размера. Всяко поле има горна граница на дължината, а всеки качен файл на размера. Иначе 50 MB „име“ изяжда паметта на сървъра.
- Регулярните изрази и краят на текста.
String.matchesпроверява целия текст, аMatcher.findтърси шаблона някъде в него:findс[1-9]|10пуска и"500". - В истински проект валидацията е декларативна. Jakarta Bean Validation:
@Min(1) @Max(10) int qtyи@Validв контролера на Spring. Правилата са същите: allow-list, отказ при грешка, проверка на сървъра.
Проблем
Проверката, която пуска при грешка
Разпродажбата има лимит: от слушалките най-много 10 на поръчка. Проверката пита таблица с лимити, а за непознат продукт хвърля изключение. Кодът хваща изключението, записва го в лога и продължава:
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("непознат продукт " + product); } return limit;}
static boolean mayBuy(String product, int qty) { boolean allowed = true; try { allowed = qty <= limitOf(product); } catch (IllegalArgumentException e) { System.out.println("лог: " + e.getMessage()); } return allowed;}
System.out.println("17 x 3: " + (mayBuy("17", 3) ? "да" : "не"));System.out.println("17 x 500: " + (mayBuy("17", 500) ? "да" : "не"));System.out.println("99 x 500: " + (mayBuy("99", 500) ? "да" : "не"));17 x 3: да17 x 500: нелог: непознат продукт 9999 x 500: да500 бройки от продукт 99 минаха. Грешката в проверката стана „разрешено“.
Пробвай
Намери реда
Кой ред прави така, че при грешка поръчката да мине?
Избери реда с грешката.
Началната стойност. Ако limitOf хвърли, присвояването в try не се случва и остава началното „разрешено“. Започни с отказ: boolean allowed = false;. Тогава грешката значи „не“ и последният ред става 99 x 500: не.
Това е правилото при грешка откажи (fail closed). Неизвестното, празното, грешката в проверката и изключението, което не си очаквал, водят до „не“. Обратното (fail open) е удобно, докато някой не открие входа, който чупи проверката.
Упражнение
Кодът за отстъпка
Реши задачата в редактора си.
Купонът. Кошницата е за 120 €. Напиши discount(code), която връща сумата след отстъпката или „отказ“. Кодовете са AUTUMN26 (25%) и STUDENTS (10%). Пускай само точно 8 знака от A до Z и от 0 до 9, и само код, който е в таблицата. Всичко друго е отказ. Пробвай с тези входове:
"AUTUMN26": 90 €"autumn26": отказ"AUTUMN26' OR '1'='1": отказ"": отказ"STUDENTS": 108 €"NOPE1234": отказautumn26 е отказ: правилото е главни букви, а не „поправи го вместо купувача“. Магазинът може и да реши да приема малки букви, но тогава го пише в правилото и проверява вече него.
Още задачи
- Адресът за доставка. Опиши с правила как изглеждат пощенски код (4 цифри в България), град и телефон. Кое от тях може да е allow-list, и кое само ограничение по дължина и знаци?
- Чужда поръчка.
GET /orders/1042връща поръчка 1042. Входът1042минава всяка проверка за формат. Какво още трябва да провери сървърът?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Регулярни изрази · Списъци · HashMap и HashSet · Мрежово програмиране
- Продължава в: Инжекции: когато данните станат команда · XSS: чужд скрипт в твоята страница · Сесии, бисквитки и CSRF · JWT и капаните му
- Задачи по темата: Сигурност за програмисти