Към съдържанието
wannacode

Недоверен вход

Накратко: Недовереният вход е всичко, което идва отвън (заявка, форма, файл); проверява се на сървъра със списък на разрешеното, а при грешка заявката се отказва.

Проблем

Слушалки за -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.1
Content-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} €";
}
Стъпка по стъпка, с думи
  1. Пристигат две поръчки. Едната е от страницата, където браузърът е проверил qty ≥ 1. Другата е пратена на ръка: цена 1, количество -3. За сървъра и двете са само текст.
  2. Минават границата на доверието. Оттук нататък нищо не е проверено. PlaceOrder чете само product и qty; цената от заявката изобщо не се чете.
  3. Първа проверка: има ли го продукта в каталога? 17 го има и за двете. Непознато id спира тук.
  4. Втора проверка: количеството трябва да е едно от "1" … "10". "2" е в списъка. "-3" не е: отказ и поръчката не продължава.
  5. Цената идва от каталога: 59 €. Каквото пише в заявката за цената, няма значение.
  6. Честната поръчка: 2 × 59 € = 118 €. Подправената спря на проверките след границата. Влиза само това, което е минало всяка проверка.

Три правила:

  1. Не чети това, което знаеш. Цената е в каталога. Заявката казва само кой продукт и колко бройки.
  2. Проверявай срещу списък на разрешеното. Продуктът трябва да е в каталога. Количеството трябва да е точно един от текстовете от "1" до "10".
  3. Ако нещо не мине, отказвай. Всеки път, който не стига до поръчката, е отказ.
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} €";
}

Пробвай

Три заявки

Какво отпечатва поправеният обработчик за тези три заявки?

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" }));
Отговор

Идеята

Разрешено, а не забранено

Първата идея на много хора е да забранят лошото: „количеството не може да е отрицателно или нула“. Това е списък на забраненото (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-list
2 приет приет
-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: не
лог: непознат продукт 99
99 x 500: да

500 бройки от продукт 99 минаха. Грешката в проверката стана „разрешено“.

Пробвай

Намери реда

Кой ред прави така, че при грешка поръчката да мине?

Избери реда с грешката.

Това е правилото при грешка откажи (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 минава всяка проверка за формат. Какво още трябва да провери сървърът?

Тест

Провери се

1. Полето за количество на страницата е <input type="number" min="1" max="10">. Трябва ли сървърът да проверява количеството?
2. Кое от това е недоверен вход за сървъра на магазина?
3. Защо списък на разрешеното (allow-list) е по-сигурен от списък на забраненото (deny-list)?
4. Проверката на лимита хвърля изключение, защото не познава продукта. Какво трябва да реши кодът?
5. Цената на продукта стои в скрито поле на формата. Какво прави сървърът с нея?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·