Недоверен вход
Накратко: Недовереният вход е всичко, което идва отвън (заявка, форма, файл); проверява се на сървъра със списък на разрешеното, а при грешка заявката се отказва.
Проблем
Слушалки за -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>Сървърът взима полетата и смята сумата:
string PlaceOrder(Dictionary<string, string> form){ int price = int.Parse(form["price"]); int qty = int.Parse(form["qty"]); return $"Поръчка: {qty} бр. по {price} € = {qty * price} €";}
// От страницата: браузърът е проверил форматаConsole.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "59", ["qty"] = "2" }));// Същата заявка, пратена на ръкаConsole.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "1", ["qty"] = "2" }));Console.WriteLine(PlaceOrder(new() { ["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 знака?
Валидацията на модела в „Изгледи с Razor“ ([Range(1, 10)] и ModelState.IsValid) е точно такава проверка на сървъра. Тук я пишем на ръка, за да се види какво прави.Виж го
Какво пуска сървърът
Поправеният обработчик не вярва на нищо от заявката. Ето как минават през него двете поръчки:
string PlaceOrder(Dictionary<string, string> form){ string product = form.GetValueOrDefault("product", ""); string qty = form.GetValueOrDefault("qty", ""); if (!prices.ContainsKey(product)) { return "Отказ: няма такъв продукт"; } if (!Regex.IsMatch(qty, @"^([1-9]|10)\z")) { return "Отказ: количеството е от 1 до 10"; } int count = int.Parse(qty); int price = prices[product]; // от каталога int total = count * price; return $"Поръчка: {count} бр. по {price} € = {total} €";}Стъпка по стъпка, с думи
- Пристигат две поръчки. Едната е от страницата, където браузърът е проверил qty ≥ 1. Другата е пратена на ръка: цена 1, количество -3. За сървъра и двете са само текст.
- Минават границата на доверието. Оттук нататък нищо не е проверено. PlaceOrder чете само product и qty; цената от заявката изобщо не се чете.
- Първа проверка: има ли го продукта в каталога? 17 го има и за двете. Непознато id спира тук.
- Втора проверка: количеството трябва да е едно от "1" … "10". "2" е в списъка. "-3" не е: отказ и поръчката не продължава.
- Цената идва от каталога: 59 €. Каквото пише в заявката за цената, няма значение.
- Честната поръчка: 2 × 59 € = 118 €. Подправената спря на проверките след границата. Влиза само това, което е минало всяка проверка.
Три правила:
- Не чети това, което знаеш. Цената е в каталога. Заявката казва само кой продукт и колко бройки.
- Проверявай срещу списък на разрешеното. Продуктът трябва да е в каталога. Количеството трябва да е точно един от текстовете от
"1"до"10". - Ако нещо не мине, отказвай. Всеки път, който не стига до поръчката, е отказ.
using System.Text.RegularExpressions;
var prices = new Dictionary<string, int> { ["17"] = 59, ["23"] = 9 };
string PlaceOrder(Dictionary<string, string> form){ string product = form.GetValueOrDefault("product", ""); string qty = form.GetValueOrDefault("qty", ""); if (!prices.ContainsKey(product)) { return "Отказ: няма такъв продукт"; } if (!Regex.IsMatch(qty, @"^([1-9]|10)\z")) { return "Отказ: количеството е от 1 до 10"; } int count = int.Parse(qty); int price = prices[product]; // от каталога int total = count * price; return $"Поръчка: {count} бр. по {price} € = {total} €";}
Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "59", ["qty"] = "2" }));Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "1", ["qty"] = "2" }));Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["price"] = "59", ["qty"] = "-3" }));Console.WriteLine(PlaceOrder(new() { ["product"] = "99", ["price"] = "1", ["qty"] = "1" }));Поръчка: 2 бр. по 59 € = 118 €Поръчка: 2 бр. по 59 € = 118 €Отказ: количеството е от 1 до 10Отказ: няма такъв продуктRegex.IsMatch проверява дали текстът отговаря на шаблон (регулярен израз). ^ е началото на текста, \z е краят, а [1-9]|10 значи една цифра от 1 до 9 или точно 10.Пробвай
Откъде е цената
Довърши реда, на който обработчикът решава цената:
string PlaceOrder(Dictionary<string, string> form)
{
string product = form.GetValueOrDefault("product", "");
string qty = form.GetValueOrDefault("qty", "");
if (!prices.ContainsKey(product))
{
return "Отказ: няма такъв продукт";
}
if (!Regex.IsMatch(qty, @"^([1-9]|10)\z"))
{
return "Отказ: количеството е от 1 до 10";
}
int count = int.Parse(qty);
int price = ;
int total = count * price;
return $"Поръчка: {count} бр. по {price} € = {total} €";
}Цената е в каталога по id на продукта. Заявката може да носи каквато цена си иска: обработчикът не я чете.
Пробвай
Три заявки
Какво отпечатва поправеният обработчик за тези три заявки?
Console.WriteLine(PlaceOrder(new() { ["product"] = "23", ["price"] = "0", ["qty"] = "10" }));Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["qty"] = "11" }));Console.WriteLine(PlaceOrder(new() { ["product"] = "17", ["qty"] = " 3" }));Първата е кабел (продукт 23) за 9 €: цената 0 от заявката не се чете. 11 не е в списъка от 1 до 10. " 3" има интервал отпред, а това не е нито един от разрешените текстове. Не „поправяй“ входа вместо купувача: каквото не отговаря точно, е отказ.
Идеята
Разрешено, а не забранено
Първата идея на много хора е да забранят лошото: „количеството не може да е отрицателно или нула“. Това е списък на забраненото (deny-list). Другият начин е да опишеш точно доброто: „количеството е един от текстовете от 1 до 10“. Това е списък на разрешеното (allow-list).
Ето двата върху едни и същи входове:
using System.Text.RegularExpressions;
bool DenyList(string qty) => !qty.StartsWith("-") && qty != "0";
bool AllowList(string qty) => Regex.IsMatch(qty, @"^([1-9]|10)\z");
string Word(bool ok) => ok ? "приет" : "отказ";
Console.WriteLine($"{"вход",-6}{"deny-list",-11}allow-list");foreach (string qty in new[] { "2", "-3", "0", "500", "2.5", "1e3" }){ Console.WriteLine($"{qty,-6}{Word(DenyList(qty)),-11}{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 „име“ изяжда паметта на сървъра.
- Регулярните изрази и краят на текста.
Regex.IsMatchтърси шаблона където и да е в текста: без^и\zтой пуска и"500".$в .NET приема и\nв края, затова краят е\z. - В истински проект валидацията е декларативна. Атрибути като
[Range(1, 10)]и[RegularExpression], които ASP.NET Core проверява при свързването на модела. Правилата са същите: allow-list, отказ при грешка, проверка на сървъра.
Проблем
Проверката, която пуска при грешка
Разпродажбата има лимит: от слушалките най-много 10 на поръчка. Проверката пита таблица с лимити, а за непознат продукт хвърля изключение. Кодът хваща изключението, записва го в лога и продължава:
var limits = new Dictionary<string, int> { ["17"] = 10, ["23"] = 50 };
int LimitOf(string product){ if (!limits.TryGetValue(product, out int limit)) { throw new ArgumentException($"непознат продукт {product}"); } return limit;}
bool MayBuy(string product, int qty){ bool allowed = true; try { allowed = qty <= LimitOf(product); } catch (ArgumentException e) { Console.WriteLine($"лог: {e.Message}"); } return allowed;}
Console.WriteLine($"17 x 3: {(MayBuy("17", 3) ? "да" : "не")}");Console.WriteLine($"17 x 500: {(MayBuy("17", 500) ? "да" : "не")}");Console.WriteLine($"99 x 500: {(MayBuy("99", 500) ? "да" : "не")}");17 x 3: да17 x 500: нелог: непознат продукт 9999 x 500: да500 бройки от продукт 99 минаха. Грешката в проверката стана „разрешено“.
Пробвай
Намери реда
Кой ред прави така, че при грешка поръчката да мине?
Избери реда с грешката.
Началната стойност. Ако limitOf хвърли, присвояването в try не се случва и остава началното „разрешено“. Започни с отказ: bool 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минава всяка проверка за формат. Какво още трябва да провери сървърът?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Списъци, речници и LINQ · Как работи интернет · Уеб сървър с Minimal API · Изгледи с Razor
- Продължава в: Инжекции: когато данните станат команда · XSS: чужд скрипт в твоята страница · Сесии, бисквитки и CSRF · JWT и капаните му
- Задачи по темата: Сигурност за програмисти