До списку уроків
10 клас · Урок 4 з 16

Недовірений ввід: валідація, honeypot, екранування XSS

виконано0%
Крок 1

Що робимо сьогодні

Модуль I · IT Skills (Cloud Services & Cyber Security) · тема 2, урок 2 з 2 · 120 хвилин

На минулому уроці ти зібрав ланцюжок «форма → Apps Script → твій сервер». І навчив ~/www/api/inbox.php питати токен. Тепер туди летить те, що ти не контролюєш. Чужі браузери, чужі скрипти, боти. І однокласник, який дуже хоче побачити своє ім’я в чужому вікні alert().

Головне правило уроку. Дані, що прийшли ззовні, — ворожі, доки не доведено протилежне. Не «підозрілі», не «можливо неправильні», а ворожі. Доводить протилежне не форма в браузері, а твій код на сервері.

Після уроку ти вмієш

Слова «валідація», XSS, «екранування» і honeypot поки нічого не означають — розберемо їх по черзі.

Ти сьогодні по обидва боки барикади Спершу ти захищаєш свій обробник, а потім сам його ламаєш: шість атак власними руками, кожна — з очікуваним кодом відповіді й фактичним. Журнал цих атак — ~/www/api/tests.md — це третій файл, який ти здаєш.

Хід уроку

ЕтапХвЩо робимо
Актуалізація10XSS у полі «Ім’я» на стенді вчителя
Теорія: недовірений ввід20Білий список проти чорного, конструктор валідації
Практика: валідація25Правила полів у inbox.php, код 422, тести curl
Перерва10
Теорія + практика: honeypot15Приховане поле в формі й тиха відмова в приймачі
Практика: сторінка /leads/25Останні 10 записів з екрануванням при виводі
«Диверсії» і журнал атак10Шість власних атак, tests.md
Автоперевірка і підсумок5Чеклист теми 2 до зеленого
Разом120
Що ти здаєш по FTP ~/www/api/inbox.php — оновлений приймач із валідацією, honeypot і лімітами; ~/www/leads/index.php — сторінка останніх 10 записів з екрануванням; ~/www/api/tests.md — журнал власних атак. ~/.env і ~/data/leads.jsonl у публічну частину не потрапляють ніколи — як і на минулому уроці.
Одразу про межу дозволеного Усе, що ти сьогодні ламаєш, — твоє власне: твій обробник, твоя сторінка, твій сабдомен. Ті самі дії проти чужого сайту — не «жарт» і не «дослідження», а стаття 361 Кримінального кодексу України. Зветься вона «Несанкціоноване втручання в роботу інформаційних (автоматизованих), електронних комунікаційних систем». Наслідки настають незалежно від того, чи щось зламалося.
Крок 2

Ввід приходить не з форми. Він приходить з мережі

Найдорожча ілюзія початківця — «у формі стоїть required, отже поле не буде порожнім»

Твій inbox.php не бачить форми. Він бачить HTTP-запит: метод, заголовки, тіло. Форма в браузері — лише один із багатьох способів такий запит зібрати. Інші способи не менш законні й доступні кожному:

Тому перевірка у формі — це зручність для чесного користувача. Вона підсвічує помилку одразу, без походу на сервер. Захистом вона не є і бути не може. Цей код виконується на комп’ютері атакувальника — отже, атакувальник ним і керує.

обхід форми за три секунди
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://91.219.61.4/s/ТВІЙ-ЛОГІН/api/inbox.php -H 'Content-Type: application/json' -d '{"name":"","email":"ні"}' 422

Ні required, ні type="email", ні maxlength тут не брали участі. Єдине, що стоїть між цим запитом і твоїм файлом даних, — чотири перевірки на сервері.

POST від будь-кого 1 · Метод і токен 2 · Розмір і частота 3 · Правила полів 4 · Honeypot хто стукає скільки і як часто що саме прислали людина чи бот 200 записано не POST → 405 чужий токен → 403 тіло > 64 КБ → 413 надто часто → 429 не той тип, довжина, формат → 422 пастка заповнена → 200, але тиша порядок не випадковий: спершу найдешевші перевірки, найдорожча — остання
Зверни увагу: екранування в цьому ланцюжку немає. Воно спрацює зовсім в іншому місці — коли сторінка /leads/ показуватиме збережене. Про це — далі.
Питання

Однокласник каже: «У мене в формі стоїть maxlength="60" і type="email", тому серверна перевірка — зайвий код». За хвилину ти складаєш у нього в базі рядок на 200 000 символів і email не-емейл. Як саме?

Питання

У твоєму приймачі перевірки йдуть у такому порядку: спершу розбір JSON і валідація всіх полів, потім — звірка токена. Бот шле 5000 запитів за хвилину без токена. Що не так із таким порядком?

Крок 3

Шість питань до кожного поля

Валідація — не «почистити рядок», а відповісти «так» або «ні» і не мати третього варіанта

Валідація не виправляє дані. Вона їх пропускає або відкидає. Спокуса «підрізати зайве і зберегти» дає третій варіант. У базі опиняється не те, що прислали, і не те, що дозволено.

ПитанняЩо перевіряємоПриклад провалу
Наявністьполе взагалі прийшло і не порожнє після trim()ключа email у JSON просто немає
Типрядок там, де чекали рядок; число там, де числоприйшов масив ["a","b"] замість рядка
Довжинамінімум і максимум у символах, не в байтахім’я на 200 000 символів
Діапазончисло в межах здорового глуздувік -5 або 99999
Форматemail, телефон, дата — за перевіреним правиломні в полі email
Білий списокзначення входить у заздалегідь відомий перелікклас 10Ж, якого не існує

Білий список проти чорного

Чорний список перелічує заборонене: «викидаємо <script>, слово alert, символ <». Такий список завжди неповний. Перелічити всі шкідливі варіанти неможливо. А обійти конкретний варіант легко:

Фільтр із чорного спискуОбхід за 10 секунд
вирізаємо рядок <script><scr<script>ipt> — після вирізання середини склеюється робочий тег
шукаємо <script у нижньому регістрі<ScRiPt>
забороняємо тег script<img src=x onerror=…>, <svg onload=…> — скрипта немає, код виконується
забороняємо слово alertwindow['al'+'ert'](1)

Білий список перелічує дозволене. «Клас — одне з трьох значень». «Вік — ціле від 6 до 120». «Ім’я — від 2 до 60 символів». Усе інше відсікається без розбору. Вгадувати, що придумає атакувальник, не треба. Ти описуєш скінченну множину доброго, а не нескінченну множину поганого.

Правило, яке працює всюди Заборонити все, крім переліченого, — завжди коротший і надійніший список, ніж дозволити все, крім переліченого. Те саме правило далі в модулі повернеться двічі. У правах доступу: ти перелічуєш, кому можна, а не кому не можна. І в мережевому екрані: відкриті лише перелічені порти, решта закрита. Так само можна вчинити зі сторінкою — перелічити браузеру, звідки йому дозволено вантажити скрипти. Цей перелік зветься CSP, Content Security Policy.

Дві пастки саме в PHP

Перша: strlen() рахує байти, а не символи. У UTF-8 кожна українська літера — це два байти. Тому strlen('Олена') дає 10, а mb_strlen('Олена')5. Постав ліміт довжини через strlen — і половина класу не впише своє прізвище. А англомовний спамер пройде вільно.

Друга: власна регулярка для email. Повний формат адреси описаний у RFC 5322, і він дуже складний. «Коротка регулярка» або відсікає живі адреси, або пропускає сміття. У PHP уже є перевірка, яку супроводжують за тебе:

inbox.php · дві перевірки, які пишуть неправильно найчастіше
// ✗ рахує байти: «Олена» = 10, «Володимир» = 18
if (strlen($name) > 60) { ... }

// ✓ рахує символи
if (mb_strlen($name, 'UTF-8') > 60) { ... }

// ✗ своя регулярка: відкидає живі адреси, пропускає сміття
if (!preg_match('/^\w+@\w+\.\w+$/', $email)) { ... }

// ✓ фільтр, який супроводжує спільнота PHP
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { ... }

Максимальна довжина адреси теж не вигадана. RFC 5321 обмежує шлях відправника 256 октетами разом із кутовими дужками. На саму адресу лишається 254 символи. Це і є розумний max для поля email.

Питання

Ти зробив фільтр: перед записом вирізаєш із рядка всі входження <script>. Учень надсилає в полі «Ім’я» рядок <scr<script>ipt>alert(1)</script>. Що опиниться в базі?

Питання

У полі «Клас» чекаєш одне з трьох: 10A, 10B, 10C. Приходить 10a — маленькою латинською. Що правильніше зробити?

Крок 4

Конструктор валідації

Задай правила для полів — отримай готовий фрагмент inbox.php і одразу побач, що крізь нього проходить, а що ні

Нижче — чотири поля твоєї форми. Міняй тип, довжину, діапазон і список дозволених значень. Код перегенерується миттєво. А під ним пройде набір тестових значень — і мирних, і ворожих.

Згенерований фрагмент

~/www/api/inbox.php · валідація

  

Прогін тестових значень

Кожен рядок — це справжній POST, який може прилетіти тобі сьогодні ввечері. Колонка «вердикт» рахується тими самими правилами, що згенеровані вище.

ПолеЗначенняВердикт
Питання

У конструкторі рядок <script>alert(1)</script> спокійно проходить валідацію поля «Ім’я»: 25 символів, більше двох, менше шістдесяти. Це помилка правил чи ні?

Крок 5

XSS: чому чужий текст стає твоїм кодом

Браузер не бачить різниці між розміткою, яку написав ти, і розміткою, яка приїхала з чужої форми

Сторінка /leads/ будується просто: беремо рядок з файлу й вставляємо в HTML. Для браузера немає «даних» і «коду». Є один потік символів, який він читає зліва направо. Побачив <, за ним літеру — почався тег. Тег script — виконати вміст. Атрибут onerror — виконати при помилці завантаження.

Візьмімо рядок <img src=x onerror=alert(1)> у полі «Ім’я». Потрапив у HTML без екранування — і він уже не ім’я. Він елемент сторінки. Картинки x не існує, тож спрацьовує onerror. Чужий JavaScript виконується у твоєму домені.

Це і є суть XSS (cross-site scripting). Чужий скрипт виконується на твоїй сторінці — отже, має всі права твоєї сторінки. Він читає її cookie, якщо на них не стоїть HttpOnly. Бачить, що на екрані. І робить запити від імені відвідувача, який зараз залогінений.

Два різні сценарії

Збережений (stored): payload лежить у твоєму файлі даних Атакувальник leads.jsonl Сторінка /leads/ Кожен відвідувач одна відправка форми рядок збережено вивід без екранування код виконався одна відправка — необмежена кількість жертв, зокрема вчитель, який відкриє сторінку на перевірку Відбитий (reflected): payload їде в посиланні й ніде не зберігається Атакувальник Жертва клікає Сервер відповідає Код виконався шле посилання ?q=… у чаті, у пошті вставляє q у сторінку у того, хто клікнув у файлі даних чисто, у логах теж — тому відбитий XSS зазвичай помічають найпізніше
Є і третій різновид — DOM-based: сервер віддає чисту сторінку, а payload у розмітку вставляє вже твій власний JavaScript (наприклад, через innerHTML). Лікується так само — не збирати HTML із рядків, а класти текст через textContent.
Питання

Ти перевірив свою сторінку /leads/: усі записи в файлі мирні, нічого не виконується. Наступного дня однокласник надсилає тобі посилання /leads/?class=<script>…</script>, ти його відкриваєш — і бачиш чуже вікно. Чому файл даних чистий?

Крок 6

Екранувати при виводі, а не при вводі

Це не стильова вподоба, а різниця між цілими даними й зіпсованими

Деякі символи щось означають для браузера. Екранування — це заміна таких символів на записи, які означають просто себе. < стає &lt;. Браузер малює на екрані знак «менше», а не початок тега.

СимволСтаєЧому небезпечний без заміни
&&amp;початок будь-якої іншої сутності; замінюється першим, інакше вийде &amp;lt;
<&lt;відкриває тег
>&gt;закриває тег
"&quot;закриває значення атрибута в подвійних лапках
'&#039;закриває значення атрибута в одинарних лапках
Лапки екрануються не завжди У PHP до версії 8.1 htmlspecialchars() за замовчуванням не чіпав одинарну лапку (режим ENT_COMPAT). З 8.1 типові прапорці стали ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401. Версію свого сервера дивись командою php -v: тип повернення never у лістингах цього уроку теж працює з 8.1 і новіших. А на чужому сервері на версію не покладайся — пиши прапорці явно: htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'). ENT_SUBSTITUTE потрібен, щоб биті UTF-8 байти замінювались на «�», а не перетворювали весь рядок на порожній.

Чому саме на виході

✗ Екранування на вході: у сховищі лежить уже не те, що прислали учень вписав 5 < 7 & O'Brien звичайне ім’я і звичайний текст htmlspecialchars ДО запису одна перевірка на вході — «щоб уже точно було безпечно» у leads.jsonl лежить 5 &lt; 7 &amp; O&#039;Brien дані зіпсовані назавжди у CSV і в листі читач бачить «&amp;»; довжина рядка виросла; при повторному виводі екранування накладається вдруге і дає «&amp;lt;»; відновити оригінал уже нічим ✓ Екранування на виході: сховище чисте, безпечним робиться показ учень вписав 5 < 7 & O'Brien той самий рядок у leads.jsonl лежить 5 < 7 & O'Brien байт у байт як прислали на кожен вихід свій HTML → htmlspecialchars URL і JS — свої кодувальники дані лишаються цілими: їх можна віддати в CSV, у лист, у JSON і в HTML — і в кожному місці застосувати те екранування, якого це місце вимагає
Валідація на вході — так: відкидай те, що не відповідає правилам. Модифікація на вході — ні: збережене має дорівнювати надісланому, інакше сховище перестає бути джерелом правди.
Питання

Учениця Оксана О’Коннор заповнила форму. Однокласник екранує на вході, ти — на виході. Через тиждень обидва вивантажуєте leads.jsonl у CSV для класного керівника. Що він побачить у двох файлах?

Крок 7

Пісочниця XSS

Одне поле — дві панелі. Ліворуч видно, що рядок став частиною розмітки; праворуч — що він лишився текстом

Це навчальний стенд, і він нічого не виконує Панель «без екранування» показує, що побачив би розбирач браузера: теги підсвічені, але вставляються на сторінку як звичайний текст. Жоден скрипт звідси не запуститься — інакше стенд сам був би дірою.

І нагадування з першого кроку: ті самі рядки на чужому сайті — це вже стаття 361, про яку ми говорили на початку. Ламай тільки своє.
✗ без екранування · echo $name;
✓ з екрануванням · h($name)

Порівняй дві панелі уважно. У сховищі — однаковий рядок. Різниця виникає рівно в одному рядку коду сторінки /leads/: чи пройшло значення через htmlspecialchars() перед тим, як стати HTML.

Питання

Однокласник вписав у поле «Ім’я» рядок із тегом, і тепер твоя сторінка відгуків показує чуже вікно alert() кожному, хто її відкриє. Що ти зробив не так і де саме це лагодити?

Крок 8

Один рядок — чотири різні екранування

«Я ж поставив htmlspecialchars» — цього достатньо рівно в одному з чотирьох місць

Екранування залежить від того, куди ти вставляєш значення. Браузер читає текст сторінки за одними правилами, атрибут — за іншими. Ще інші правила в URL і всередині <script>. Тому й «небезпечні символи» в кожному місці свої.

той самий рядок ● = " onmouseover=alert(1) x=" Текст сторінки Значення атрибута Частина URL Усередині <script> куди підставляємо куди підставляємо куди підставляємо куди підставляємо <p>Привіт, ●</p> <input value="●"> <a href="/?q=●"> var n = ●; чим екранувати чим екранувати чим екранувати чим екранувати htmlspecialchars достатньо htmlspecialchars + ENT_QUOTES, лапки навколо значення rawurlencode + дозволити тільки http і https json_encode із JSON_HEX_TAG і рештою HEX-прапорців без нього: рядок стає тегом і виконується без лапок або без ENT_QUOTES: атрибут закрито, обробник додано javascript: у href спрацює по кліку — htmlspecialchars мовчить htmlspecialchars тут не рятує взагалі: < і > коду не потрібні просте правило: не клади недовірені дані в URL і в JavaScript — передавай через data-атрибут і читай через dataset
Найтонший випадок — четвертий. У рядку var n = "●"; достатньо надіслати ";alert(1);//, і жодного < для атаки не потрібно — а отже htmlspecialchars нічого не помітить.

Як це виглядає у твоєму leads/index.php

~/www/leads/index.php · три контексти в одному рядку
<?php
// єдине місце, де народжується HTML-екранування
function h(?string $s): string {
    return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<li>
  <!-- 1. текст сторінки -->
  <b><?= h($r['name'] ?? '') ?></b>

  <!-- 2. значення атрибута: лапки обов’язкові -->
  <span title="<?= h($r['class'] ?? '') ?>"><?= h($r['class'] ?? '') ?></span>

  <!-- 3. частина URL: інший кодувальник -->
  <a href="/leads/?class=<?= rawurlencode($r['class'] ?? '') ?>">такі самі</a>
</li>
Другий рубіж: Content-Security-Policy Заголовок Content-Security-Policy: default-src 'self' забороняє браузеру виконувати вбудовані скрипти й вантажити чуже. Це не заміна екранування, а страховка на випадок, коли ти десь його забув. Захист будують шарами: пропустив один — тримає наступний.
Питання

Ти вивів клас у пошуковому посиланні так: <a href="/leads/?class=<?= h($class) ?>"> — через htmlspecialchars, як усюди. Чому цього мало і що станеться, якщо в $class прилетить javascript:alert(1)?

Крок 9

Honeypot: поле, яке заповнюють тільки боти

Найдешевший фільтр у всьому ланцюжку — і найтихіший

Спамерський бот не дивиться на сторінку очима. Він завантажує HTML, знаходить усі <input> і заповнює їх підряд. Причина проста: порожнє поле може виявитись обов’язковим, і форма не відправиться. На цьому й будується пастка. Додаємо поле, якого людина фізично не бачить, і чекаємо. Порожнє — швидше за все людина. Заповнене — точно не вона.

форма на твоїй сторінці name — Ім’яemail — Поштаmessage — Повідомлення website — пастка клас .hp виносить поле за межі екрана: у HTML воно є, на екрані його немає Людина в браузері CSS застосовано, поля не видно → website = "" Бот-парсер CSS не читає, заповнює всі input підряд 200 · {"ok":true} рядок додано до leads.jsonl 200 · {"ok":true} той самий текст, але нічого не записано відповідь однакова навмисно: автор бота не має дізнатися, що пастку помічено 403 або «спам виявлено» — це підказка, як переписати бота, щоб він пройшов
У логах сервера обидва запити виглядають як успішні. Свою статистику ведеш окремо: тихий лічильник спрацювань пастки в ~/data/ — щоб бачити, скільки ботів приходить.

Як зробити правильно

Роби такЧому
ховати класом у зовнішньому CSSінлайновий style="display:none" прямо в теґу — перше, що перевіряє сучасний бот
назвати поле правдоподібно: website, company, urlполе з іменем honeypot або trap бот пропустить свідомо
додати tabindex="-1" і autocomplete="off"інакше людина потрапить у поле клавішею Tab, а браузер підставить туди збережену адресу
додати aria-hidden="true" і підпис «не заповнюйте»екранний диктор читає й невидиме: без цього незрячий користувач заповнить пастку і його заявка зникне
відповідати тим самим 200, що й при успіхубудь-яка інша відповідь — безкоштовна підказка авторові бота

Чому не type="hidden"

Приховане поле — це штатний механізм HTML для службових значень: CSRF-токен, ідентифікатор форми, крок майстра. Бот це знає. Такі поля він відправляє назад як є, не чіпаючи. Інакше зламає половину форм в інтернеті. Тому пастка з type="hidden" ніколи не спрацює. А ось <input type="text">, прихований класом, для бота — звичайне текстове поле, яке треба заповнити.

форма + inbox.php · пастка і тиха відмова
/* у зовнішньому style.css, не інлайном */
.hp { position: absolute; left: -9999px; width: 1px; height: 1px;
      overflow: hidden; }

<!-- у формі -->
<div class="hp" aria-hidden="true">
  <label for="website">Не заповнюйте це поле</label>
  <input type="text" id="website" name="website"
         tabindex="-1" autocomplete="off">
</div>

// в inbox.php — одразу після розбору JSON
if (trim((string)($data['website'] ?? '')) !== '') {
    // та сама відповідь, що й при успіху
    http_response_code(200);
    header('Content-Type: application/json');
    echo '{"ok":true}';
    exit;                     // але в leads.jsonl нічого не пішло
}
Honeypot — не єдиний захист і не найголовніший Він зупиняє масові тупі скрипти, які ходять по всьому інтернету. Цілеспрямований бот, написаний саме під твою форму, відкриє її в справжньому браузері, побачить, що поле не видно, і залишить порожнім. Тому honeypot стоїть останнім у ланцюжку, після токена, лімітів і валідації, — він їх доповнює, а не замінює.
Питання

Однокласник поставив honeypot, назвав поле honeypot, сховав його інлайновим style="display:none" і повертає ботам 403 Спам виявлено. Через тиждень спам повернувся у повному обсязі. Назви три причини.

Крок 10

Ліміти: довжина, розмір тіла, частота

Валідація захищає дані. Ліміти захищають сам сервер — диск, пам’ять і твою квоту

Поле без обмеження довжини — це запрошення надіслати мегабайт. Адреса без обмеження частоти — запрошення надіслати мільйон запитів. Жоден із них не «зламає» код. Вони просто заповнять диск і з’їдять процесор. І покладуть твій сабдомен разом із сусідами по серверу.

Що обмежуємоРозумне значенняКод відповідіХто перевіряє
довжина поляім’я 60, email 254, повідомлення 2000 символів422твій inbox.php
розмір тіла запиту64 КБ на цю форму413nginx, потім PHP, потім ти
кількість поліврівно ті, що в $RULES; зайві ігноруються422твій inbox.php
частота запитів20 на IP за годину429твій inbox.php

Дещо обмежено вже до тебе. nginx за замовчуванням не пропускає тіло більше 1 МБ (client_max_body_size 1m) і сам віддає 413. Типовий php.ini ставить post_max_size = 8M і max_input_vars = 1000. Це страховка від зовсім грубих речей, а не твій ліміт. Мегабайт у полі «Ім’я» — усе одно катастрофа.

Що означають ці коди

КодНазваКоли саме
400Bad Requestтіло взагалі не розбирається як JSON
401Unauthorizedзаголовка X-Auth-Token немає взагалі
403Forbiddenзаголовок є, але значення не збігається з ~/.env
405Method Not Allowedприйшов GET там, де приймається лише POST
413Content Too Largeтіло більше за дозволене
422Unprocessable ContentJSON правильний, але значення не за правилами
429Too Many Requestsліміт частоти вичерпано; додай заголовок Retry-After

Різниця 400 і 422 варта окремого речення. 400 — «я не зміг це прочитати». 422 — «я прочитав, зрозумів, і воно не годиться». Разом із 422 корисно віддавати перелік полів із помилками, щоб фронтенд підсвітив саме їх.

Один рядок у твоєму README.md змінюється На уроці 3 нерозбірне тіло діставало 422 bad_json. Сьогодні ми це уточнюємо: нерозбірне тіло — це 400, а 422 лишається для правильного JSON із поганими значеннями. Решта контракту та сама: 401 — заголовка немає, 403 — токен чужий, 405 — метод не той. Виправ цей рядок у api/README.md сьогодні ж, інакше твій контракт розійдеться з твоїм кодом.
що відсіює кожен рубіж — по черзі, від найдешевшого прийшло всього 405 · не POST 401/403 · токен 413 · тіло > 64 КБ 429 · > 20 за годину 422 · не за правилами honeypot · тиша 200 · записано 1000 820 580 565 520 460 350 350 числа умовні — схема показує порядок перевірок і те, що кожна дешевша за наступну, а не реальну статистику твого сайту
Порядок економить ресурси: перевірити метод — це одне порівняння рядків; перевірити правила всіх полів — це вже розбір JSON і робота з кожним значенням. Немає сенсу розбирати тіло запиту, який і так прилетів без токена.
~/www/api/inbox.php · початок обробника і ліміт частоти
<?php
declare(strict_types=1);

// 0. те саме, що на уроці 3: __DIR__ = ~/www/api, два рівні вгору = ~
$home  = dirname(__DIR__, 2);
$env   = @parse_ini_file($home . '/.env');
$token = $env['WEBHOOK_TOKEN'] ?? '';

function fail(int $code, string $why): never {
    http_response_code($code);
    header('Content-Type: application/json');
    echo json_encode(['ok' => false, 'error' => $why], JSON_UNESCAPED_UNICODE);
    exit;
}

// 1. метод — одне порівняння рядків
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { fail(405, 'only POST'); }

// 2. розмір тіла — до того, як щось читати
if ((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 65536) { fail(413, 'body too large'); }

// 3. токен із ~/.env; hash_equals не «зливає» відповідь через час порівняння
$got = $_SERVER['HTTP_X_AUTH_TOKEN'] ?? '';
if ($got === '')                  { fail(401, 'no token'); }
if (!hash_equals($token, $got))   { fail(403, 'bad token'); }

// 4. ліміт частоти: 20 запитів з одного IP за годину
$ip   = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$file = $home . '/data/rate/' . hash('sha256', $ip) . '.txt';
$now  = time();
$hits = is_readable($file)
      ? array_filter(explode(',', trim(file_get_contents($file))),
                     fn($t) => $now - (int)$t < 3600)
      : [];
if (count($hits) >= 20) {
    header('Retry-After: 600');
    fail(429, 'too many requests');
}
$hits[] = $now;
file_put_contents($file, implode(',', $hits), LOCK_EX);

// 5. і тільки тепер розбираємо тіло
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) { fail(400, 'not json'); }
Лічильник теж живе поза ~/www Тека ~/data/rate/ — це стан твого сервісу, а не публічний контент. У веб-корінь вона не потрапляє з тієї самої причини, що й leads.jsonl на минулому уроці.
Теку ~/data/rate/ створи заздалегідь Це та сама пастка, що з ~/data/ на уроці 3. file_put_contents створює файл, але не теку. Якщо теки немає, запис мовчки провалиться. Лічильник щоразу буде порожній, ліміт не спрацює жодного разу, а помилки ти не побачиш. Один рядок у SSH-сесії: mkdir -p ~/data/rate && chmod 700 ~/data/rate.
Питання

Твій обробник читає тіло запиту в змінну, розбирає JSON, валідує всі поля — і лише в кінці перевіряє Content-Length і повертає 413. Бот шле файл на 800 КБ тисячу разів. Що станеться з сервером і в чому помилка?

Крок 11

Журнал атак: tests.md

«Я перевірив, усе працює» — це не перевірка. Перевірка — це список рядків «що робив → що очікував → що сталося»

Захист, який ніхто не пробував зламати, — це припущення, а не захист. Тому третій файл, який ти сьогодні здаєш, — журнал власних атак. Формат простий і той самий, яким користуються дорослі: три колонки й жодних «начебто працює».

КолонкаЩо туди пишеш
Що робивточна команда curl, яку можна повторити слово в слово
Що очікувавкод відповіді й що має статися з файлом даних
Що сталосяфактичний код, фактична кількість рядків, фактичний вигляд сторінки

Третя колонка — найважливіша, і саме її найчастіше підганяють під другу. Якщо очікував 422, а отримав 200 — так і пиши, а поруч додай рядок про те, що виправив. Розбіжність між очікуваним і фактичним — це і є результат роботи; журнал без жодної розбіжності зазвичай означає, що тестів не було.

Мінімум шість записів Порожнє обов’язкове поле · email не-емейл · ім’я на 5000 символів · <script>alert(1)</script> в імені й перевірка сторінки /leads/ · заповнений honeypot · запит без заголовка X-Auth-Token. Хто встигає — додає сьомий: 25 запитів поспіль і перевірка 429.
шаблон одного тесту
$ curl -s -o body.txt -w '%{http_code}\n' -X POST http://91.219.61.4/s/ТВІЙ-ЛОГІН/api/inbox.php -H 'X-Auth-Token: ...' -H 'Content-Type: application/json' -d '{"name":"Оля","email":"ні","class":"10A"}' 422 $ wc -l ~/data/leads.jsonl 17 /home/koval.d.10b/data/leads.jsonl ← було 17, лишилося 17: рядок не додано
Windows Обидва рядки шаблону виконуються в SSH-сесії на сервері — там curl звичайний. Якщо ж стріляєш зі свого комп’ютера з Windows PowerShell, пиши не curl, а curl.exe. Там curl без розширення — це псевдонім Invoke-WebRequest, і ключі -X, -H, -d означають зовсім інше.

Обидві частини важливі: код відповіді і те, що сталося з файлом. Обробник, який чесно віддає 422, а рядок усе одно дописує, — найгірший із можливих варіантів, бо виглядає він бездоганно.

Питання

У журналі однокласника всі шість рядків такі: «надіслав поганий email → очікував 422 → отримав 422 ✓». Учитель дивиться leads.jsonl і бачить там рядок з email ні. Чого бракує в його тестах?

Крок 12

Практика: захистити й зламати

Кроки 1—11 — у редакторі на твоєму комп’ютері, 12—14 — проти живого сабдомена. Галочки зберігаються, сторінку можна закрити.

Що йде на сервер, а що ні По FTP їдуть три текстові файли: api/inbox.php, leads/index.php, api/tests.md. ~/.env, ~/data/leads.jsonl і тека ~/data/rate/ у веб-корінь не потрапляють ніколи. Права після заливання: файли 644, теки 755, ~/.env600.
Крок 13

Автоперевірка

Чекер робить із твоїм ендпойнтом рівно те саме, що робив ти сам: шле погані запити й дивиться на код відповіді та на файл даних. Спроби не обмежені.

Демонстраційний режим: результат згенеровано для показу. На сервері ця кнопка викликає POST /api/check, який запускає /opt/lessons/04/check.sh від імені учня.

Що вчитель дивиться на твоєму екрані

Крок 14

Шкала виконання

Це те, що бачить учитель, коли виставляє оцінку.

СкладникВагаРезультат
Виконано
0%
Рекомендований бал за 12-бальною

Чотири речення, які варто запам’ятати

Де найчастіше помиляються

Домашнє завдання

Дописати в ~/www/api/tests.md ще три власні негативні тести з фактичними кодами відповіді — такі, яких сьогодні на уроці не було. Наприклад: поле class зі значенням 10Ж; тіло, яке взагалі не є JSON; двадцять п’ять запитів поспіль і перевірка 429.

Для кожного — три рядки: команда, очікуваний код, фактичний результат разом із кількістю рядків у leads.jsonl до і після.

Джерела

Звідки взяті правила й числа

Усе на цій сторінці — з офіційної документації, стандартів і матеріалів OWASP. Перевіряти дозволено й корисно.

Повний розбір із поясненнями, що саме звідки взято, — у файлі urok-04-джерела.md поруч із цією сторінкою.