Недоверен вход
Накратко: Недовереният вход е всичко, което идва отвън (заявка, форма, файл); проверява се на сървъра със списък на разрешеното, а при грешка заявката се отказва.
Проблем
Слушалки за -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>Сървърът взима полетата и смята сумата:
def place_order(form): price = int(form["price"]) qty = int(form["qty"]) return f"Поръчка: {qty} бр. по {price} € = {qty * price} €"
# От страницата: браузърът е проверил формата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"}))Поръчка: 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 знака?
Виж го
Какво пуска сървърът
Поправеният обработчик не вярва на нищо от заявката. Ето как минават през него двете поръчки:
def place_order(form): product = form.get("product", "") qty = form.get("qty", "") if product not in PRICES: return "Отказ: няма такъв продукт" if not re.fullmatch(r"[1-9]|10", qty): return "Отказ: количеството е от 1 до 10" count = int(qty) price = PRICES[product] # от каталога total = count * price return f"Поръчка: {count} бр. по {price} € = {total} €"Стъпка по стъпка, с думи
- Пристигат две поръчки. Едната е от страницата, където браузърът е проверил qty ≥ 1. Другата е пратена на ръка: цена 1, количество -3. За сървъра и двете са само текст.
- Минават границата на доверието. Оттук нататък нищо не е проверено. place_order чете само product и qty; цената от заявката изобщо не се чете.
- Първа проверка: има ли го продукта в каталога? 17 го има и за двете. Непознато id спира тук.
- Втора проверка: количеството трябва да е едно от "1" … "10". "2" е в списъка. "-3" не е: отказ и поръчката не продължава.
- Цената идва от каталога: 59 €. Каквото пише в заявката за цената, няма значение.
- Честната поръчка: 2 × 59 € = 118 €. Подправената спря на проверките след границата. Влиза само това, което е минало всяка проверка.
Три правила:
- Не чети това, което знаеш. Цената е в каталога. Заявката казва само кой продукт и колко бройки.
- Проверявай срещу списък на разрешеното. Продуктът трябва да е в каталога. Количеството трябва да е точно един от текстовете от
"1"до"10". - Ако нещо не мине, отказвай. Всеки път, който не стига до поръчката, е отказ.
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 "Отказ: няма такъв продукт" if not re.fullmatch(r"[1-9]|10", qty): return "Отказ: количеството е от 1 до 10" count = int(qty) price = PRICES[product] # от каталога total = count * price return f"Поръчка: {count} бр. по {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"}))Поръчка: 2 бр. по 59 € = 118 €Поръчка: 2 бр. по 59 € = 118 €Отказ: количеството е от 1 до 10Отказ: няма такъв продуктre.fullmatch проверява целия текст спрямо шаблона: [1-9]|10 значи една цифра от 1 до 9 или точно 10.Пробвай
Откъде е цената
Довърши реда, на който обработчикът решава цената:
def place_order(form):
product = form.get("product", "")
qty = form.get("qty", "")
if product not in PRICES:
return "Отказ: няма такъв продукт"
if not re.fullmatch(r"[1-9]|10", qty):
return "Отказ: количеството е от 1 до 10"
count = int(qty)
price =
total = count * price
return f"Поръчка: {count} бр. по {price} € = {total} €"Цената е в каталога по id на продукта. Заявката може да носи каквато цена си иска: обработчикът не я чете.
Пробвай
Три заявки
Какво отпечатва поправеният обработчик за тези три заявки?
print(place_order({"product": "23", "price": "0", "qty": "10"}))print(place_order({"product": "17", "qty": "11"}))print(place_order({"product": "17", "qty": " 3"}))Първата е кабел (продукт 23) за 9 €: цената 0 от заявката не се чете. 11 не е в списъка от 1 до 10. " 3" има интервал отпред, а това не е нито един от разрешените текстове. Не „поправяй“ входа вместо купувача: каквото не отговаря точно, е отказ.
Идеята
Разрешено, а не забранено
Първата идея на много хора е да забранят лошото: „количеството не може да е отрицателно или нула“. Това е списък на забраненото (deny-list). Другият начин е да опишеш точно доброто: „количеството е един от текстовете от 1 до 10“. Това е списък на разрешеното (allow-list).
Ето двата върху едни и същи входове:
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 "приет" if ok else "отказ"
print(f"{'вход':<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))}")вход deny-list allow-list2 приет приет-3 отказ отказ0 отказ отказ500 приет отказ2.5 приет отказ1e3 приет отказСписъкът на забраненото пусна 500, 2.5 и 1e3. Никой не се беше сетил за тях, когато го пишеше. Атакуващият пробва точно това, за което не си се сетил. При списъка на разрешеното непознатото е отказ, без да трябва да го предвидиш.
Затова: опиши как изглежда правилният вход (тип, дължина, знаци, обхват, стойност от известен списък) и откажи всичко друго.
По-точно, за любопитните
- Валидно не значи твое.
1042е напълно правилен номер на поръчка, но може да е на друг купувач. Проверката за формат не стига: сървърът проверява и дали поръчката е на влезлия купувач. Без това имаш една от най-честите дупки в истинските сайтове (IDOR, insecure direct object reference). - Нормализирай, после проверявай. Ако системата нататък тълкува входа по-различно от проверката (интервали, главни и малки букви, Unicode, кодиране в адреса), проверявай вече приведения вход. Иначе проверката гледа едно, а системата получава друго.
- Ограничавай размера. Всяко поле има горна граница на дължината, а всеки качен файл на размера. Иначе 50 MB „име“ изяжда паметта на сървъра.
- Регулярните изрази и краят на текста.
re.searchиre.matchне проверяват целия текст: те пускат и"500".$приема и\nв края, затова тук еre.fullmatch. - В истински проект валидацията е декларативна. pydantic или формите на Django и Flask-WTF:
conint(ge=1, le=10)и типове за всяко поле. Правилата са същите: allow-list, отказ при грешка, проверка на сървъра.
Проблем
Проверката, която пуска при грешка
Разпродажбата има лимит: от слушалките най-много 10 на поръчка. Проверката пита таблица с лимити, а за непознат продукт хвърля изключение. Кодът хваща изключението, записва го в лога и продължава:
LIMITS = {"17": 10, "23": 50}
def limit_of(product): if product not in LIMITS: raise ValueError(f"непознат продукт {product}") return LIMITS[product]
def may_buy(product, qty): allowed = True try: allowed = qty <= limit_of(product) except ValueError as e: print(f"лог: {e}") return allowed
print(f"17 x 3: {'да' if may_buy('17', 3) else 'не'}")print(f"17 x 500: {'да' if may_buy('17', 500) else 'не'}")print(f"99 x 500: {'да' if may_buy('99', 500) else 'не'}")17 x 3: да17 x 500: нелог: непознат продукт 9999 x 500: да500 бройки от продукт 99 минаха. Грешката в проверката стана „разрешено“.
Пробвай
Намери реда
Кой ред прави така, че при грешка поръчката да мине?
Избери реда с грешката.
Началната стойност. Ако limitOf хвърли, присвояването в try не се случва и остава началното „разрешено“. Започни с отказ: 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минава всяка проверка за формат. Какво още трябва да провери сървърът?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Регулярни изрази · Списъци: append, remove и sort · dict и set · Мрежово програмиране
- Продължава в: Инжекции: когато данните станат команда · XSS: чужд скрипт в твоята страница · Сесии, бисквитки и CSRF · JWT и капаните му
- Задачи по темата: Сигурност за програмисти