Модуль I · IT Skills (Cloud Services & Cyber Security) · тема 2, урок 2 з 2 · 120 хвилин
На минулому уроці ти зібрав ланцюжок «форма → Apps Script → твій сервер».
І навчив ~/www/api/inbox.php питати токен. Тепер туди летить те,
що ти не контролюєш. Чужі браузери, чужі скрипти, боти. І однокласник,
який дуже хоче побачити своє ім’я в чужому вікні alert().
Головне правило уроку. Дані, що прийшли ззовні, — ворожі, доки не доведено протилежне. Не «підозрілі», не «можливо неправильні», а ворожі. Доводить протилежне не форма в браузері, а твій код на сервері.
413, 422, 429;Слова «валідація», XSS, «екранування» і honeypot поки нічого не означають — розберемо їх по черзі.
~/www/api/tests.md — це третій файл, який ти здаєш.| Етап | Хв | Що робимо |
|---|---|---|
| Актуалізація | 10 | XSS у полі «Ім’я» на стенді вчителя |
| Теорія: недовірений ввід | 20 | Білий список проти чорного, конструктор валідації |
| Практика: валідація | 25 | Правила полів у inbox.php, код 422, тести curl |
| Перерва | 10 | — |
| Теорія + практика: honeypot | 15 | Приховане поле в формі й тиха відмова в приймачі |
Практика: сторінка /leads/ | 25 | Останні 10 записів з екрануванням при виводі |
| «Диверсії» і журнал атак | 10 | Шість власних атак, tests.md |
| Автоперевірка і підсумок | 5 | Чеклист теми 2 до зеленого |
| Разом | 120 |
~/www/api/inbox.php — оновлений приймач із валідацією, honeypot
і лімітами; ~/www/leads/index.php — сторінка останніх 10 записів
з екрануванням; ~/www/api/tests.md — журнал власних атак.
~/.env і ~/data/leads.jsonl у публічну частину
не потрапляють ніколи — як і на минулому уроці.Найдорожча ілюзія початківця — «у формі стоїть required, отже поле не буде порожнім»
Твій inbox.php не бачить форми. Він бачить HTTP-запит:
метод, заголовки, тіло. Форма в браузері — лише один із багатьох способів
такий запит зібрати. Інші способи не менш законні й доступні кожному:
curl у терміналі — три секунди, жодного браузера;maxlength зникає;Тому перевірка у формі — це зручність для чесного користувача. Вона підсвічує помилку одразу, без походу на сервер. Захистом вона не є і бути не може. Цей код виконується на комп’ютері атакувальника — отже, атакувальник ним і керує.
Ні required, ні type="email", ні maxlength
тут не брали участі. Єдине, що стоїть між цим запитом і твоїм файлом даних, —
чотири перевірки на сервері.
/leads/
показуватиме збережене. Про це — далі.Однокласник каже: «У мене в формі стоїть maxlength="60"
і type="email", тому серверна перевірка — зайвий код».
За хвилину ти складаєш у нього в базі рядок на 200 000 символів
і email не-емейл. Як саме?
У твоєму приймачі перевірки йдуть у такому порядку: спершу розбір JSON і валідація всіх полів, потім — звірка токена. Бот шле 5000 запитів за хвилину без токена. Що не так із таким порядком?
Валідація — не «почистити рядок», а відповісти «так» або «ні» і не мати третього варіанта
Валідація не виправляє дані. Вона їх пропускає або відкидає. Спокуса «підрізати зайве і зберегти» дає третій варіант. У базі опиняється не те, що прислали, і не те, що дозволено.
| Питання | Що перевіряємо | Приклад провалу |
|---|---|---|
| Наявність | поле взагалі прийшло і не порожнє після 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=…> — скрипта немає, код виконується |
забороняємо слово alert | window['al'+'ert'](1) |
Білий список перелічує дозволене. «Клас — одне з трьох значень». «Вік — ціле від 6 до 120». «Ім’я — від 2 до 60 символів». Усе інше відсікається без розбору. Вгадувати, що придумає атакувальник, не треба. Ти описуєш скінченну множину доброго, а не нескінченну множину поганого.
Перша: strlen() рахує байти, а не символи. У UTF-8 кожна
українська літера — це два байти. Тому strlen('Олена') дає
10, а mb_strlen('Олена') — 5. Постав ліміт довжини
через strlen — і половина класу не впише своє прізвище.
А англомовний спамер пройде вільно.
Друга: власна регулярка для email. Повний формат адреси описаний у RFC 5322, і він дуже складний. «Коротка регулярка» або відсікає живі адреси, або пропускає сміття. У 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 — маленькою латинською.
Що правильніше зробити?
Задай правила для полів — отримай готовий фрагмент inbox.php
і одразу побач, що крізь нього проходить, а що ні
Нижче — чотири поля твоєї форми. Міняй тип, довжину, діапазон і список дозволених значень. Код перегенерується миттєво. А під ним пройде набір тестових значень — і мирних, і ворожих.
Кожен рядок — це справжній POST, який може прилетіти тобі
сьогодні ввечері. Колонка «вердикт» рахується тими самими правилами,
що згенеровані вище.
| Поле | Значення | Вердикт |
|---|
У конструкторі рядок <script>alert(1)</script>
спокійно проходить валідацію поля «Ім’я»: 25 символів, більше двох,
менше шістдесяти. Це помилка правил чи ні?
Браузер не бачить різниці між розміткою, яку написав ти, і розміткою, яка приїхала з чужої форми
Сторінка /leads/ будується просто: беремо рядок з файлу
й вставляємо в HTML. Для браузера немає «даних» і «коду». Є один потік
символів, який він читає зліва направо. Побачив <, за ним
літеру — почався тег. Тег script — виконати вміст. Атрибут
onerror — виконати при помилці завантаження.
Візьмімо рядок <img src=x onerror=alert(1)> у полі «Ім’я».
Потрапив у HTML без екранування — і він уже не ім’я. Він елемент сторінки.
Картинки x не існує, тож спрацьовує onerror.
Чужий JavaScript виконується у твоєму домені.
Це і є суть XSS (cross-site scripting). Чужий скрипт виконується
на твоїй сторінці — отже, має всі права твоєї сторінки. Він читає її cookie,
якщо на них не стоїть HttpOnly. Бачить, що на екрані. І робить
запити від імені відвідувача, який зараз залогінений.
innerHTML). Лікується так само —
не збирати HTML із рядків, а класти текст через textContent.Ти перевірив свою сторінку /leads/: усі записи в файлі
мирні, нічого не виконується. Наступного дня однокласник надсилає
тобі посилання /leads/?class=<script>…</script>,
ти його відкриваєш — і бачиш чуже вікно. Чому файл даних чистий?
Це не стильова вподоба, а різниця між цілими даними й зіпсованими
Деякі символи щось означають для браузера. Екранування — це заміна
таких символів на записи, які означають просто себе. < стає
<. Браузер малює на екрані знак «менше», а не початок тега.
| Символ | Стає | Чому небезпечний без заміни |
|---|---|---|
| & | & | початок будь-якої іншої сутності; замінюється першим, інакше вийде &lt; |
| < | < | відкриває тег |
| > | > | закриває тег |
| " | " | закриває значення атрибута в подвійних лапках |
| ' | ' | закриває значення атрибута в одинарних лапках |
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 байти замінювались
на «�», а не перетворювали весь рядок на порожній.Учениця Оксана О’Коннор заповнила форму. Однокласник екранує на вході,
ти — на виході. Через тиждень обидва вивантажуєте leads.jsonl
у CSV для класного керівника. Що він побачить у двох файлах?
Одне поле — дві панелі. Ліворуч видно, що рядок став частиною розмітки; праворуч — що він лишився текстом
Порівняй дві панелі уважно. У сховищі — однаковий рядок. Різниця
виникає рівно в одному рядку коду сторінки /leads/: чи пройшло
значення через htmlspecialchars() перед тим, як стати HTML.
Однокласник вписав у поле «Ім’я» рядок із тегом, і тепер твоя сторінка
відгуків показує чуже вікно alert() кожному, хто її відкриє.
Що ти зробив не так і де саме це лагодити?
«Я ж поставив htmlspecialchars» — цього достатньо
рівно в одному з чотирьох місць
Екранування залежить від того, куди ти вставляєш значення. Браузер
читає текст сторінки за одними правилами, атрибут — за іншими. Ще інші
правила в URL і всередині <script>. Тому й «небезпечні
символи» в кожному місці свої.
var n = "●"; достатньо надіслати ";alert(1);//,
і жодного < для атаки не потрібно — а отже
htmlspecialchars нічого не помітить.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: default-src 'self' забороняє
браузеру виконувати вбудовані скрипти й вантажити чуже. Це не заміна
екранування, а страховка на випадок, коли ти десь його забув. Захист
будують шарами: пропустив один — тримає наступний.Ти вивів клас у пошуковому посиланні так:
<a href="/leads/?class=<?= h($class) ?>"> —
через htmlspecialchars, як усюди. Чому цього мало
і що станеться, якщо в $class прилетить
javascript:alert(1)?
Найдешевший фільтр у всьому ланцюжку — і найтихіший
Спамерський бот не дивиться на сторінку очима. Він завантажує HTML,
знаходить усі <input> і заповнює їх підряд. Причина проста:
порожнє поле може виявитись обов’язковим, і форма не відправиться. На цьому
й будується пастка. Додаємо поле, якого людина фізично не бачить,
і чекаємо. Порожнє — швидше за все людина. Заповнене — точно не вона.
~/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">, прихований класом, для бота —
звичайне текстове поле, яке треба заповнити.
/* у зовнішньому 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,
сховав його інлайновим style="display:none" і повертає
ботам 403 Спам виявлено. Через тиждень спам повернувся
у повному обсязі. Назви три причини.
Валідація захищає дані. Ліміти захищають сам сервер — диск, пам’ять і твою квоту
Поле без обмеження довжини — це запрошення надіслати мегабайт. Адреса без обмеження частоти — запрошення надіслати мільйон запитів. Жоден із них не «зламає» код. Вони просто заповнять диск і з’їдять процесор. І покладуть твій сабдомен разом із сусідами по серверу.
| Що обмежуємо | Розумне значення | Код відповіді | Хто перевіряє |
|---|---|---|---|
| довжина поля | ім’я 60, email 254, повідомлення 2000 символів | 422 | твій inbox.php |
| розмір тіла запиту | 64 КБ на цю форму | 413 | nginx, потім 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. Це страховка від зовсім грубих речей,
а не твій ліміт. Мегабайт у полі «Ім’я» — усе одно катастрофа.
| Код | Назва | Коли саме |
|---|---|---|
| 400 | Bad Request | тіло взагалі не розбирається як JSON |
| 401 | Unauthorized | заголовка X-Auth-Token немає взагалі |
| 403 | Forbidden | заголовок є, але значення не збігається з ~/.env |
| 405 | Method Not Allowed | прийшов GET там, де приймається лише POST |
| 413 | Content Too Large | тіло більше за дозволене |
| 422 | Unprocessable Content | JSON правильний, але значення не за правилами |
| 429 | Too Many Requests | ліміт частоти вичерпано; додай заголовок Retry-After |
Різниця 400 і 422 варта окремого речення.
400 — «я не зміг це прочитати». 422 —
«я прочитав, зрозумів, і воно не годиться». Разом із 422
корисно віддавати перелік полів із помилками, щоб фронтенд підсвітив саме їх.
README.md змінюється
На уроці 3 нерозбірне тіло діставало 422 bad_json. Сьогодні ми
це уточнюємо: нерозбірне тіло — це 400, а 422
лишається для правильного JSON із поганими значеннями. Решта контракту
та сама: 401 — заголовка немає, 403 — токен чужий,
405 — метод не той. Виправ цей рядок у api/README.md
сьогодні ж, інакше твій контракт розійдеться з твоїм кодом.<?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 КБ тисячу разів. Що станеться
з сервером і в чому помилка?
tests.md«Я перевірив, усе працює» — це не перевірка. Перевірка — це список рядків «що робив → що очікував → що сталося»
Захист, який ніхто не пробував зламати, — це припущення, а не захист. Тому третій файл, який ти сьогодні здаєш, — журнал власних атак. Формат простий і той самий, яким користуються дорослі: три колонки й жодних «начебто працює».
| Колонка | Що туди пишеш |
|---|---|
| Що робив | точна команда curl, яку можна повторити слово в слово |
| Що очікував | код відповіді й що має статися з файлом даних |
| Що сталося | фактичний код, фактична кількість рядків, фактичний вигляд сторінки |
Третя колонка — найважливіша, і саме її найчастіше підганяють під другу.
Якщо очікував 422, а отримав 200 — так і пиши,
а поруч додай рядок про те, що виправив. Розбіжність між очікуваним
і фактичним — це і є результат роботи; журнал без жодної розбіжності
зазвичай означає, що тестів не було.
не-емейл · ім’я на 5000 символів ·
<script>alert(1)</script> в імені й перевірка сторінки
/leads/ · заповнений honeypot · запит без заголовка
X-Auth-Token. Хто встигає — додає сьомий: 25 запитів поспіль
і перевірка 429.curl звичайний. Якщо ж стріляєш зі свого комп’ютера з
Windows PowerShell, пиши не curl, а curl.exe.
Там curl без розширення — це псевдонім
Invoke-WebRequest, і ключі -X, -H,
-d означають зовсім інше.Обидві частини важливі: код відповіді і те, що сталося з файлом.
Обробник, який чесно віддає 422, а рядок усе одно дописує, —
найгірший із можливих варіантів, бо виглядає він бездоганно.
У журналі однокласника всі шість рядків такі: «надіслав поганий email →
очікував 422 → отримав 422 ✓». Учитель дивиться leads.jsonl
і бачить там рядок з email ні. Чого бракує в його тестах?
Кроки 1—11 — у редакторі на твоєму комп’ютері, 12—14 — проти живого сабдомена. Галочки зберігаються, сторінку можна закрити.
api/inbox.php,
leads/index.php, api/tests.md. ~/.env,
~/data/leads.jsonl і тека ~/data/rate/ у веб-корінь
не потрапляють ніколи. Права після заливання: файли 644,
теки 755, ~/.env — 600.Чекер робить із твоїм ендпойнтом рівно те саме, що робив ти сам: шле погані запити й дивиться на код відповіді та на файл даних. Спроби не обмежені.
Демонстраційний режим: результат згенеровано для показу.
На сервері ця кнопка викликає POST /api/check, який запускає
/opt/lessons/04/check.sh від імені учня.
type="hidden"
і не інлайновим стилем;/leads/ з реальним записом, що містить тег:
учитель дивиться «View source» і бачить <script>;tests.md відкрито поруч із терміналом: будь-який тест
із журналу повторюється просто зараз і дає той самий код;Це те, що бачить учитель, коли виставляє оцінку.
| Складник | Вага | Результат |
|---|
200, як і успіх, — інакше він сам себе видає.required, type="email") — прямий POST проходить її наскрізь;leads.jsonl лежить
<, дані зіпсовані, а при повторному виводі
з’являється &lt;;strlen — українські імена «задовгі» вдвічі;honeypot і сховано інлайновим display:none;403 з поясненням — фактично інструкція, як обійти пастку;422 і все одно дописує рядок: забули exit.Дописати в ~/www/api/tests.md ще три власні негативні тести
з фактичними кодами відповіді — такі, яких сьогодні на уроці не було.
Наприклад: поле class зі значенням 10Ж; тіло, яке
взагалі не є JSON; двадцять п’ять запитів поспіль і перевірка 429.
Для кожного — три рядки: команда, очікуваний код, фактичний результат
разом із кількістю рядків у leads.jsonl до і після.
Усе на цій сторінці — з офіційної документації, стандартів і матеріалів OWASP. Перевіряти дозволено й корисно.
htmlspecialchars — перелік символів, прапорці
ENT_QUOTES, ENT_SUBSTITUTE і зміна типових прапорців у 8.1.
php.net/htmlspecialcharsfilter_var і FILTER_VALIDATE_EMAIL.
php.net/filter.filters.validatemb_strlen — чому символи, а не байти.
php.net/mb_strlenhash_equals — порівняння рядків за сталий час.
php.net/hash_equals400, 401,
405, 413 Content Too Large,
422 Unprocessable Content.
datatracker.ietf.org/rfc9110429 Too Many Requests
і заголовок Retry-After.
datatracker.ietf.org/rfc6585client_max_body_size — типове значення
1m і відповідь 413.
nginx.org/docspost_max_size і max_input_vars.
php.net/ini.corearia-hidden — чому пастку треба ховати
і від екранного диктора теж.
developer.mozilla.org/aria-hiddenПовний розбір із поясненнями, що саме звідки взято, —
у файлі urok-04-джерела.md поруч із цією сторінкою.