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

Збірка командного продукту і гігієна вебформ

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

Чотирнадцять уроків перетворюються на один продукт

Сьогодні команда зводить усе зроблене за модуль в один сайт і доводить його форми до стану, у якому їх не соромно віддати на злом сусідній команді

У кожного з вас на сервері лежить купка окремих сторінок. Книга з теми 1. Дашборд із теми 3. Опис AI-медіа з теми 4. Бот із теми 7. Журнал автоматизації з теми 6.

Кожна з них працює. Але працює вона тільки тоді, коли ти сам знаєш її адресу й сам її відкриваєш. Така окрема готова штука зветься артефактом.

З продуктом усе інакше. У нього є один вхід і спільна навігація. Є головна сторінка, яка за десять секунд пояснює чужій людині, що це й для кого. І є документація, за якою чужа людина знайде потрібне без вас. Саме цю різницю ви сьогодні й закриваєте.

Чого навчишся за цей урок

Хід уроку

ЕтапХвЩо робимо
Команди й ролі10Розподіляємо ролі, підписуємо правила аудиту
Від артефактів до продукту15Схема архітектури: 5 компонентів модуля в одному продукті
Практика: інтеграція30Публікуємо продукт на team-<N>, зводимо 5 компонентів під спільну навігацію
Теорія: CSRF15Підроблена відправка форми — з токеном і без; чому HTTPS сам не рятує
Гігієна форм25CSRF-токен, honeypot, серверна валідація, ліміт частоти — 6-пунктний чеклист
Документація15Пишемо README.md (архітектура і ролі) і SECURITY.md
Автоперевірка5up, nav, 🔒 csrf, form_validate, form_escape
Підсумок5Пари команд для крос-аудиту й межі дозволеного на урок 16
Разом120

Слова CSRF, honeypot і «відповідальне розкриття» поки нічого не означають. Розберемо кожне на своєму місці.

Було: п'ять артефактів

п'ять адрес, п'ять стилів, п'ять авторів

  • кожен знає тільки свою частину;
  • гість не здогадається, що це одна робота;
  • форми написані по-різному, дірки — теж різні;
  • якщо автор захворів, його блок ніхто не полагодить.
Стане: один продукт

одна адреса, одна навігація, одна документація

  • головна пояснює, що це і для кого;
  • README.md каже, хто що зробив і де воно лежить;
  • усі форми проходять один і той самий конвеєр фільтрів;
  • SECURITY.md каже, куди писати про знайдену дірку.

Що команда зробить за 120 хвилин

  1. домовиться про ролі так, щоб ніхто нікого не блокував;
  2. складе карту продукту: п'ять компонентів, у кожного власник;
  3. опублікує все на спільному 91.219.61.4/s/team-<N> під однією навігацією;
  4. напише README.md (архітектура + ролі) і SECURITY.md (куди писати про вразливість);
  5. розбереться, що таке CSRF — це головна нова тема уроку;
  6. додасть у кожну форму CSRF-токен, honeypot, серверну валідацію й ліміт частоти;
  7. прожене чек-лист готовності з 15 пунктів і закриє всі блокуючі.
Межа «локально / на сервер» лишається тією самою Зведення сторінок, правки обробників і збірка демо — у себе на комп'ютері. По FTP у кабінет їдуть тільки сторінки продукту, README.md, SECURITY.md і PHP-обробники форм. AI-медіа команда показує з екрана — відео й аудіо сервер не приймає, на сервері живуть лише описи й media.json.
Наступного уроку ваш продукт перевірятимуть чужі люди Через тиждень сусідня команда шукатиме у вас дірки за списком із 12 перевірок. Ви — у них. Усе, що сьогодні лишите недоробленим, вам знайдуть і запишуть у звіт. Це не покарання. Краще, щоб дірку знайшов однокласник за сусідньою партою, ніж хтось чужий через рік.
Карта командного продукту: п'ять компонентів модуля під однією головною Угорі головна сторінка продукту з навігацією. Від неї стрілки йдуть до п'яти компонентів: книга з теми 1, дашборд із теми 3, опис AI-медіа з теми 4, чат-бот із теми 7 і журнал автоматизації з теми 6. Під кожним компонентом підписано, з якого уроку він походить і хто в команді його власник. Унизу спільний шар: одна таблиця стилів, один набір обробників форм, README і SECURITY. 91.219.61.4/s/team-3 — головна що це · для кого · навігація на 5 блоків власник: усі, редагує тімлід /book/ AI-книга тема 1, урок 2 власник: Оля /dash/ інфографіка тема 3, урок 6 власник: Максим /media/ опис AI-медіа тема 4, урок 7 власник: Даша /bot/ чат-бот тема 7, урок 13 власник: Тарас /events/ автоматизація тема 6, урок 11 власник: Ніна Спільний шар — його не «володіє» ніхто окремо team.css · шапка з навігацією · api/form.php з шістьма фільтрами правило: правку спільного шару робить тімлід, і одразу для всіх п'яти блоків Документація — теж частина продукту README.md — що це, з чого складається, хто власник кожного блоку SECURITY.md — куди й як писати, якщо знайшов дірку; за скільки ми відповімо
Імена авторів тут — приклад для схеми. Головне в ній інше. У кожного блоку є один власник, а за спільний шар відповідає окрема людина. Без цієї колонки будь-яка правка перетворюється на «хтось колись зробить».
Крок 2

Три речі, які роблять із набору сторінок один продукт

Спільна навігація, один стиль і головна сторінка, яка пояснює себе за десять секунд

1. Спільна навігація — той самий блок на кожній сторінці

Найпоширеніша помилка збірки одна: навігація є тільки на головній. Гість заходить у /bot/ за прямим посиланням — і не бачить, куди йти далі. Про решту чотирьох блоків він просто не дізнається. Правило просте: з будь-якої сторінки продукту видно всі інші.

шапка, яку кожен вставляє у свою сторінку без змін
<header class="site">
  <a class="brand" href="/">Команда 3 · Шкільний медіацентр</a>
  <nav>
    <a href="/book/">Книга</a>
    <a href="/dash/">Дашборд</a>
    <a href="/media/">AI-медіа</a>
    <a href="/bot/">Бот</a>
    <a href="/events/">Автоматизація</a>
  </nav>
</header>

Зверніть увагу на шляхи від кореня: /book/, а не ../book/. Такий шлях зветься абсолютним. Відносний ламається, щойно сторінку перекладуть на рівень глибше. І ламається тихо, без жодного повідомлення про помилку.

2. Один стиль — одна таблиця, а не п'ять

Якщо кожен лишить свій CSS, вийде п'ять різних сайтів в одній рамці. Домовтеся про один файл /team.css. У ньому кольори, шрифт, шапка й вигляд форм.

Особисті стилі блоку кладуть окремим файлом, після спільного. І тільки на те, що справді унікальне саме для цього блоку.

Що узгоджуємоДомовленість командиЧому саме так
Головна сторінка блокузавжди index.html або index.phpадреса без імені файла: /bot/ замість /bot/chat-page-final2.html
Імена текмалими латиницею, без пробілів і без транслітераціїсервер розрізняє регістр: /Book/ і /book/ — різні адреси
Спільні стилі/team.css, підключається першим одна правка кольору — і змінилися всі п'ять блоків
Обробники формусі в /api/, по одному файлу на формуперевіряти безпеку в одному місці значно легше, ніж у п'яти
Дані~/data/, поза ~/www усе, що в ~/www, віддається по HTTP усьому світу

3. Головна, яку розуміє чужа людина

Уявіть, що на сторінку зайшов учитель фізики. Про ваш модуль він не знає нічого. За десять секунд він має зрозуміти три речі: що це, для кого і що тут можна відкрити просто зараз.

«Проєкт учнів 10-Б» не відповідає на жодне з них. А ось так — відповідає: «Медіацентр школи: розклад заходів, бот-довідник і дашборд відвідуваності. Для учнів і батьків».

Перевірка на «десять секунд» Покажіть головну людині з іншої команди на 10 секунд і заберіть екран. Попросіть переказати, що це. Якщо не переказала — переписуйте перший абзац, а не додавайте картинок.
Питання

Команда звела продукт: у кожного блоку своя тека, посилання з головної працюють. Але однокласник надіслав другові пряме посилання на /dash/, і той написав: «а де решта? тут просто графік». Що саме забули зробити?

Крок 3

Ролі: як не заблокувати одне одного на дві години

У команді з чотирьох найдорожча втрата — не помилка в коді, а троє, які сидять і чекають на четвертого

Заблокувати одне одного можна двома способами. Або двоє правлять той самий файл. Або одна робота не може початися, поки не готова інша.

Обидві біди лікуються заздалегідь, поки ви ще домовляєтеся. У процесі вони вже не лікуються — тільки перечікуються.

РольЗа що відповідаєЩо віддає команді
Тімлід / інтеграторголовна сторінка, спільна навігація, team.css, злиття блоків каркас і шапку — у перші 20 хвилин, щоб решта мала куди вставлятися
Інженер безпеки формодин спільний api/form.php з шістьма фільтрами, CSRF-токен, honeypot готовий обробник, який решта просто підключає до своїх форм
Технічний письменникREADME.md, SECURITY.md, тексти головної документацію, яку пише паралельно, а не наприкінці
Тестувальникчек-лист із 15 пунктів, прогін перевірок, ведення списку знахідок список того, що ще червоне, — оновлюваний уголос кожні 20 хвилин

Ролей чотири, а команда буває з трьох або з п'яти. Тоді ролі об'єднують або ділять. Головне інше: біля кожної ролі має стояти ім'я. Роль без імені означає, що її не робить ніхто.

Порядок, який знімає очікування

  1. Перші 20 хвилин — тімлід кладе на сервер порожній каркас: головну, team.css, п'ять порожніх тек. Решта в цей час готує свої файли локально, нічого не заливаючи.
  2. Далі паралельно — кожен заливає свій блок у свою теку. Конфліктів немає: у різних теках різні файли.
  3. Спільний шар чіпає тільки тімлід. Хочеш змінити team.css — кажеш уголос, тімлід міняє. Так не буває, що двоє залили різні версії одного файла по FTP і другий стер першого.
  4. Тестувальник починає, коли є перший блок, а не коли готове все. Знахідка на 40-й хвилині коштує п'ять хвилин, на 110-й — уроку.
FTP не вміє зливати правки Git показав би конфлікт і не дав загубити чужу роботу. FTP так не вміє. Він перезаписує файл цілком: хто залив останнім, того й версія. Тому правило «спільні файли — одна пара рук» тут не бюрократія. Це єдиний захист від утрати роботи.
Питання

Через годину роботи виявилося, що шапка на трьох сторінках із п'яти стара: у ній немає посилання на /events/. Двоє учасників по черзі заливали team.css і головну по FTP. Що пішло не так і як цього уникнути на наступному уроці?

Крок 4

Де що лежить: структура репозиторію команди

Одна теча — публічна й видима всьому світу, друга — приватна. Плутанина між ними коштує модуля

Структура кабінету команди: що віддається по HTTP, а що ні Дерево тек акаунта команди. Тека www повністю віддається по HTTP: у ній головна, team.css, README.md, SECURITY.md, п'ять тек компонентів і тека api з обробниками форм. Теки data, upload і файл .env лежать поза www і по HTTP недосяжні. Праворуч у кожного рядка стоїть позначка, видно його з інтернету чи ні. /home/team3/ видно з інтернету? www/ — усе тут віддається по HTTP усьому світу ├── index.html головна: що це і для кого так, і це правильно ├── team.css спільні кольори й шапка так, і це правильно ├── README.md архітектура + ролі так, і це навмисно ├── SECURITY.md куди писати про дірку так, обов'язково ├── book/ dash/ media/ по index.html у кожній так ├── bot/ events/ бот і журнал подій так └── api/ серверні обробники так, викликаються з форм ├── form.php шість фільтрів форми ├── chat.php проксі бота, ключ із .env └── hook.php приймач подій із підписом увага: файл, покладений сюди «на хвилинку», уже опубліковано поза www/ — сюди HTTP не дотягується взагалі ├── data/ form.jsonl, events.jsonl ні ├── upload/ FTP-приймальня ні ├── .env ключі, права 600 ні, і ніколи └── notes/ чернетки команди ні Просте правило: файл у www/ — це публікація. Немає «тимчасово поклав у www/».
Червона рамка — не «погано», а «публічно». README і SECURITY лежать там навмисно: їх мають знайти. А от data/, upload/ і .env у червону рамку не потрапляють ніколи.

Узгоджені імена: домовленість на п'ять рядків

Домовленість про імена виглядає дріб'язком. Рівно доти, доки хтось не назве файл Індекс копія (2).html.

Кирилиця й пробіли в іменах на сервері працюють. Але посилання на такий файл перетворюється на %D0%86%D0%BD%D0%B4%D0%B5%D0%BA%D1%81. Прочитати це неможливо, і помилка в такому посиланні шукається годинами.

Пастка Windows, на яку наступають щоуроку Windows не розрізняє великі й малі літери в іменах файлів. Linux — розрізняє. Сторінка, яка чудово працювала на ноутбуці з <link href="Team.css">, на сервері відкриється без стилів. У консолі при цьому буде тихий 404. Тому перевіряйте не в себе, а на сервері.
Питання

Тестувальник команди зайшов на http://91.219.61.4/s/team-3/data/ і побачив список файлів, а в ньому form.jsonl з іменами й телефонами всіх, хто заповнював форму. Що сталося і що робити насамперед?

Крок 5

README.md і SECURITY.md: два файли, які читають чужі

Один пояснює, як усе влаштовано. Другий каже, що робити людині, яка знайшла у вас дірку, — і саме він відрізняє серйозний проєкт від шкільного

README.md — не про ідею, а про пристрій

Найчастіша помилка — написати README як твір. «Ми вирішили зробити сайт, який допоможе учням». Такий текст не читає ніхто.

Цей файл відповідає на питання людини, яка вже на сайті. Її цікавить дві речі: де що лежить і до кого йти з питанням.

~/www/README.md — робочий шаблон, підставте своє
# Шкільний медіацентр — команда 3

Сайт про заходи школи: розклад, бот-довідник і дашборд відвідуваності.
Для учнів 5–11 класів і батьків. Адреса: http://91.219.61.4/s/team-3/

## З чого складається

| Блок      | Адреса     | Що робить                          | Звідки | Власник |
|-----------|------------|------------------------------------|--------|---------|
| Книга     | /book/     | історія школи, зроблена з AI       | тема 1 | Оля     |
| Дашборд   | /dash/     | 3 діаграми відвідуваності гуртків  | тема 3 | Максим  |
| AI-медіа  | /media/    | описи згенерованих ілюстрацій      | тема 4 | Даша    |
| Бот       | /bot/      | відповідає на питання про школу    | тема 7 | Тарас   |
| Події     | /events/   | журнал заявок із форми Google      | тема 6 | Ніна    |

## Як воно працює технічно

- сторінки статичні, спільна шапка й стилі — `/team.css`;
- усі форми шлють POST на `/api/form.php`; він перевіряє CSRF-токен,
  валідує поля, екранує вивід і дописує рядок у `~/data/form.jsonl`;
- бот звертається до `/api/chat.php`, ключ моделі лежить у `~/.env` (права 600);
- журнал подій наповнює `/api/hook.php` — приймає підписані події ззовні;
- дані живуть у `~/data/`, поза `~/www`, і по HTTP не віддаються.

## Чого тут навмисно немає

- відео й аудіо: правило модуля — медіа показуємо з екрана, на сервер не кладемо;
- персональних даних у дашборді: агреговані числа, без прізвищ.

## Як запустити локально

Відкрити index.html у браузері. Форми при цьому не працюють —
вони потребують PHP; перевіряйте їх на сервері.

Зверніть увагу на колонки «Звідки» і «Власник». Саме їх найчастіше забувають. І саме вони перетворюють текст на робочий документ: через місяць одразу видно, кого питати про блок і на якому уроці він з'явився.

SECURITY.md — і чому це не формальність

Уявіть: хтось випадково помітив, що ваша форма приймає чужі дані. Людина хоче попередити. Куди їй писати?

Якщо відповіді на сайті немає, буде одне з двох. Або вона промовчить, і дірка лишиться відкритою. Або напише публічно в чат класу — з деталями, які прочитають усі, зокрема й ті, кому не варто.

SECURITY.md закриває саме цей розрив. Це не файл «щоб було». Великі проєкти тримають такий роками, а стандарт RFC 9116 описує його машиночитаний варіант — /.well-known/security.txt.

~/www/SECURITY.md — мінімальний, але справжній
# Як повідомити про вразливість

Дякуємо, що читаєте це. Якщо ви знайшли у нашому проєкті помилку,
що стосується безпеки, — напишіть нам, будь ласка, перш ніж розповідати
про неї публічно.

## Контакт

- пошта: team3-security@91.219.61.4
- або особисто: вчителю інформатики, кабінет 214

## Що написати

1. адресу сторінки або запиту;
2. що саме ви зробили (кроки, які можна повторити);
3. що отримали — код відповіді, шматок виводу, знімок екрана;
4. чим це небезпечно, на вашу думку.

## Що обіцяємо ми

- відповімо протягом 3 навчальних днів;
- скажемо, підтвердилася знахідка чи ні, і що виправили;
- подякуємо в цьому файлі, якщо ви не проти.

## Про що просимо вас

- не змінювати й не видаляти чужі дані;
- не влаштовувати навантаження на сервер;
- не публікувати деталі, доки ми не виправимо.

## Подяки

- (сюди потрапляють ті, хто нам допоміг)

Відповідальне розкриття — що це означає

Уяви, що ти помітив зламаний замок на сусідських дверях. Розумний порядок дій простий: спершу сказати сусідам, а не викласти фото дверей у чат будинку.

З дірками в сайтах домовилися так само. Той, хто знайшов, повідомляє приватно й дає час на виправлення. Той, чий сайт, не вдає, ніби нічого не було: виправляє і публічно дякує. Ця домовленість зветься відповідальним розкриттям.

Зобов'язання тут в обох сторін. У цьому вся суть: без другої половини перша перестає працювати.

Чому мовчати гірше, ніж повідомити Вразливість небезпечна рівно стільки часу, скільки вона відкрита. Хто промовчав «щоб не було проблем», той просто подовжив це вікно. Тому на уроці 16 у крос-аудиті цінується не лише кількість знайденого, а й час до виправлення.
І межа, яку не переходять «Знайшов дірку» не означає «можна брати дані». Доказом є код відповіді й один-два рядки виводу. Не викачаний файл із чужими телефонами. Правила аудиту про це ви підписуєте вже сьогодні, а працюють вони на уроці 16.
Питання

Учениця з паралельного класу помітила дірку у вашій формі: введене зберігається без екранування. Вона виклала в загальний чат школи скріншот із працюючим <script>: «дивіться, у них дірка». Що вона мала зробити інакше, якби у вас був SECURITY.md?

Крок 6

CSRF: чужий сайт надсилає запит від твого імені

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

Уяви гардероб у театрі. Ти віддав куртку й отримав номерок. Далі гардеробник не питає твого прізвища: він дивиться на номерок і видає куртку тому, хто цей номерок приніс.

У вебі так само. Коли ти входиш на сайт, сервер віддає браузеру маленький рядок на кшталт sid=8f3a…. Це і є номерок, а зветься він cookie сеансу. Далі браузер сам, без твоєї участі, додає цю cookie до кожного запиту на цей сайт.

Ключове слово — кожного. Браузер не питає, чи ти цього запиту хотів. Він дивиться лише на адресу отримувача. Запит іде на 91.219.61.4/s/team-3? Отже, cookie цього сайту додається. А хто цей запит почав — твоя сторінка чи чужа — браузера довгий час не цікавило взагалі.

Звідси й назва CSRF розшифровується як Cross-Site Request Forgery — «підробка міжсайтового запиту». Запит тут міжсайтовий: почався на одному сайті, пішов на інший. І він підроблений: сервер бачить звичайний запит від користувача, який увійшов, бо cookie справжня. А сам користувач цього запиту не робив.
Три сторони CSRF-атаки: жертва, чужий сайт і твій сервер Схема з трьох колонок. Ліворуч браузер жертви, у якому вже лежить cookie сеансу твого сайту. Посередині чужий сайт зі смішною картинкою і прихованою формою, що надсилається сама. Праворуч твій сервер. Унизу смуга з чотирма нумерованими стрілками: перша — жертва входить на твій сайт і отримує cookie; друга — переходить за посиланням на чужий сайт; третя — чужа сторінка сама надсилає POST на твій сервер; четверта — браузер автоматично додає до цього POST твою cookie. Сервер без захисту бачить залогіненого користувача і виконує дію. 1. Браузер жертви cookie сеансу sid=8f3a91c… домен 91.219.61.4/s/team-3 браузер додає цю cookie до КОЖНОГО запиту на 91.219.61.4/s/team-3 — хто б його не почав що бачить жертва: смішну картинку. І більше нічого 2. Чужий сайт funny-cats.example <img src="cat.jpg"> видима частина прихована частина: <form method="post" action="https:// 91.219.61.4/s/team-3 /api/post.php"> <input name="text"> </form> form.submit() спрацьовує само 3. Твій сервер що він бачить: POST /api/post.php Cookie: sid=8f3a91c… cookie справжня! висновок сервера: «це залогінений користувач — виконую» і в стрічці з'являється запис від імені жертви порядок подій у часі ① ти входиш — сервер ставить cookie ② клік по «смішній картинці» ③ прихована форма шле POST ④ і браузер САМ чіпляє до цього POST твою cookie Пароль ніхто не крав. Сервер не зламано. Зламано припущення «якщо cookie є — значить, користувач цього хотів».
Головне на схемі — стрілка ④. Її малює не зловмисник, а твій власний браузер. Він діє за правилами, які працюють від самої появи cookie. Тому CSRF не лагодиться на боці користувача. Лагодити доводиться на сервері.

Чого CSRF не робить

МіфЯк насправді
«Зловмисник украв мій пароль» ні: він жодного разу не бачив ні пароля, ні cookie — cookie надіслав браузер, а не сторінка
«Він прочитав мою переписку» зазвичай ні: відповідь твого сервера чужій сторінці не віддається (це блокує політика одного походження). CSRF — про дію, а не про читання
«У мене HTTPS, я захищений» ні: HTTPS шифрує канал. Запит атаки теж піде по HTTPS — охайно зашифрованим
«Це проблема старих сайтів» ні: це проблема будь-якої форми, що змінює дані і покладається лише на cookie
Питання

Однокласник надіслав тобі посилання на смішну картинку. Ти перейшов, посміявся, закрив. Через хвилину на твоєму сайті з'явився новий запис від твого імені. Пароля ти нікому не казав і на сайт відтоді не заходив. Як таке можливо?

Крок 7

Токен у формі: те, чого чужий сайт не може вгадати

Захист будується не на «звідки прийшов запит», а на «чи знає відправник секрет, який я показував тільки на своїй сторінці»

Логіка захисту коротка. Сервер віддає свою сторінку з формою і кладе в неї приховане поле з випадковим рядком. Той самий рядок він запам'ятовує в себе, у сеансі.

Коли приходить POST, сервер порівнює два рядки: той, що в полі, і той, що в сеансі. Збіглися — форма справді з нашої сторінки. Цей випадковий рядок і зветься токеном.

Чому це працює проти CSRF? Чужа сторінка може надіслати запит. Але прочитати вміст твоєї сторінки вона не може: браузер цього не дозволяє. Правило зветься політикою одного походження, same-origin policy. Тому й підставити правильний токен чужа сторінка не в змозі — вона його ніколи не бачила.

Той самий запит із токеном і без нього Дві панелі. Ліва: запит із чужого сайту без токена — сервер порівнює порожнє поле з рядком у сеансі, збігу немає, відповідь 403 Forbidden, запис не створено. Права: запит із твоєї власної сторінки — токен у полі збігається з токеном у сеансі, відповідь 200, запис створено. Запит із чужого сайту POST /api/post.php HTTP/2 Host: 91.219.61.4/s/team-3 Origin: https://funny-cats.example Cookie: sid=8f3a91c… ← браузер додав сам text=Купуйте+крипту csrf= (поля немає) сервер порівнює: у сеансі: 4c1e…f70a у запиті: — 403 Forbidden у стрічці нічого не з'явилося Той самий запит із твоєї сторінки POST /api/post.php HTTP/2 Host: 91.219.61.4/s/team-3 Origin: http://91.219.61.4/s/team-3/ Cookie: sid=8f3a91c… ← так само сам text=Завтра+репетиція+о+15:00 csrf=4c1e…f70a сервер порівнює: у сеансі: 4c1e…f70a у запиті: 4c1e…f70a 200 OK запис створено Cookie однакова в обох запитах. Різниця рівно одна — поле csrf, якого чужа сторінка не може прочитати.
Токен не робить cookie «менш автоматичною». Він додає до запиту те, що можна дізнатися тільки прочитавши твою сторінку. А читати її чужий сайт права не має.

Як це виглядає в коді

видача токена — на сторінці з формою
<?php
declare(strict_types=1);

// cookie сеансу: тільки по HTTPS, недосяжна для JS, не їде на чужі сайти
session_set_cookie_params([
    'secure'   => true,     // лише по HTTPS
    'httponly' => true,     // JS не прочитає document.cookie
    'samesite' => 'Lax',    // другий рівень захисту, див. нижче
]);
session_start();

// токен народжується один раз на сеанс
if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));   // 64 шістнадцяткові символи
}
?>
<form method="post" action="/api/form.php">
  <input type="hidden" name="csrf"
         value="<?= htmlspecialchars($_SESSION['csrf'], ENT_QUOTES, 'UTF-8') ?>">

  <label for="name">Ім'я</label>
  <input id="name" name="name" maxlength="80" required>

  <label for="msg">Повідомлення</label>
  <textarea id="msg" name="msg" maxlength="1000" required></textarea>

  <button type="submit">Надіслати</button>
</form>
перевірка токена — найперше в обробнику
<?php
declare(strict_types=1);
session_start();

// hash_equals порівнює рядки за сталий час — щоб токен не можна було
// підібрати посимвольно, вимірюючи час відповіді сервера
$sent = (string)($_POST['csrf'] ?? '');
$real = (string)($_SESSION['csrf'] ?? '');

if ($real === '' || !hash_equals($real, $sent)) {
    http_response_code(403);
    header('Content-Type: text/plain; charset=utf-8');
    exit('403 · запит не з нашої сторінки');
}

// далі — решта фільтрів: валідація, honeypot, ліміт, екранування
Помилка, яку роблять щороку Токен згенеровано, поле у формі є, у HTML усе красиво. А рядка hash_equals в обробнику немає. Сервер приймає будь-який POST, і форма захищена суто на вигляд. Перевіряють це не тим, що токен видно у вихідному коді сторінки. Перевіряють тим, що запит без нього повертає 403.

Другий рівень: атрибут SameSite

Сервер може попросити браузер про послугу: «не надсилай цю cookie, якщо запит почався на іншому сайті». Прохання записують у самій cookie, в атрибуті SameSite. Значень у нього три.

ЗначенняКоли cookie надсилаєтьсяНаслідок для CSRF
Noneзавжди, зокрема в міжсайтових запитах (обов'язково разом із Secure) атака проходить: cookie поїде
Laxу своїх запитах і при переході за звичайним посиланням; у міжсайтовому POST — ні типову форму-атаку зупиняє
Strictтільки у своїх запитах зупиняє й переходи за посиланням — незручно для входу

Здається, що Lax робить токен зайвим. Не робить, і причин три.

Перша: Lax не рятує від запиту з піддомена того самого сайту — для браузера це той самий сайт. Друга: він не рятує, якщо дія змінює дані через звичайне GET-посилання. Третя: браузери поводяться по-різному. Chrome і Edge вважають cookie без атрибута такою, що має Lax. Інші браузери — не обов'язково.

Правило двох рівнів Токен — головний захист. Він стоїть на вашому сервері й працює однаково всюди. SameSite=Lax — другий рівень. Він працює в браузері користувача, і ви ним не керуєте. Ставте обидва, а покладайтеся на перший.
Питання

Учень поставив cookie сеансу SameSite=Strict і прибрав CSRF-токен: «навіщо два захисти, якщо перший уже блокує». Автоперевірка на його сервері все одно показала червоний csrf. Що саме зробив чекер?

Крок 8

Симулятор CSRF: три кроки й один вимикач

Пройди атаку так, як її бачить жертва. Потім увімкни токен і повтори ту саму атаку — крок за кроком, на тих самих даних

Симулятор CSRF

Захист на твоєму сервері
Атрибут cookie сеансу
    HTTP-запит, який дійшов до твого сервера
    стрічка на 91.219.61.4/s/team-3/feed

      Значення cookie, час і текст записів — ілюстративні, вигадані для симулятора. Формати заголовків, коди відповідей і поведінка SameSite — справжні.

      Порядок, у якому це варто прогнати Спершу пройдіть усі три кроки з вимкненим токеном і SameSite=None. У стрічці з'явиться чужий запис. Потім натисніть «Спочатку» й увімкніть токен. Запит той самий, cookie та сама, а результат уже 403. Третім заходом лишіть токен вимкненим, а SameSite поставте Lax. Атака теж не пройде, але подивіться уважно, на якому кроці вона зупинилася.
      Питання

      У симуляторі з SameSite=Lax і без токена атака не спрацювала. Учень робить висновок: «токен не потрібен, досить правильної cookie». У чому слабке місце цього висновку?

      Крок 9

      Шість фільтрів форми: конвеєр, який ви збирали весь модуль

      Кожен фільтр ви вже десь бачили. Сьогодні вони вперше стають одним обробником, через який ідуть усі форми продукту

      Конвеєр із шести фільтрів, який проходить кожен POST Зліва входить запит із форми. Він проходить шість воріт по черзі: розмір тіла, CSRF-токен, honeypot, ліміт частоти, серверна валідація полів і екранування виводу. Кожні ворота мають свій код відмови: 413, 403, тихе 200, 429, 422 і — для екранування — не код, а безпечний вивід. Запит, що пройшов усі шість, потрапляє в журнал у теці data поза www. POST із форми ① Розмір тіла запиту більше 8 КБ — далі не пускаємо 413 ② CSRF-токен hash_equals із тим, що в сеансі 403 ③ Honeypot приховане поле заповнене — це бот тихе 200, без запису ④ Ліміт частоти більше 10 надсилань за хвилину з IP 429 ⑤ Серверна валідація полів є, потрібного типу, потрібної довжини 422 ⑥ Екранування при виводі htmlspecialchars перед показом не код, а безпечний вивід відмова — і запис у лог без даних форми жоден фільтр не замінює інший: токен — не проти спаму, honeypot — не проти XSS, валідація — не проти CSRF ~/data/ form.jsonl
      Порядок не випадковий. Спершу відсікається те, що дешево перевірити: розмір і токен. Потім те, що потребує читання полів. Так найдорожча робота дістається тільки запитам, які вже пройшли дешевий відсів.

      Чому саме ці шість, і що кожен не робить

      ФільтрВід чого рятуєВід чого не рятує
      Обмеження розмірувід запиту на 50 МБ, який з'їдає пам'ять і місце на дискувід маленького, але злого запиту
      CSRF-токенвід запиту, надісланого чужим сайтом від імені користувачавід спаму й від XSS: якщо людина сама заповнила форму, токен у неї правильний
      Honeypotвід простих ботів, які заповнюють усі поля підрядвід людини й від бота, написаного саме під вашу форму
      Ліміт частотивід 500 надсилань за хвилину: і спаму, і навантаженнявід одного акуратного шкідливого надсилання
      Серверна валідаціявід порожніх, надто довгих і неправильних за типом полів — навіть коли запит іде повз сторінку від правильного за формою, але шкідливого вмісту
      Екранування виводувід XSS: чужий <script> показується текстом, а не виконується ні від чого на вході — воно працює на виході
      Ключова думка всієї теми Шість фільтрів не дублюють одне одного. Вони закривають шість різних дір. Тому фраза «у нас є CSRF-токен, значить форма захищена» — неправда. Це те саме, що сказати: «у нас є замок, значить вікна можна не зачиняти».

      Окремо про третій фільтр

      У саду ставлять блюдце з солодкою водою: комахи злітаються туди й не лізуть у їжу на столі. Приманка робить свою роботу тим, що її помічають не всі.

      У формі роль приманки грає зайве поле. Людина його не бачить: воно сховане стилями. Бот бачить усі поля підряд і заповнює їх усі. Прийшов запит із заповненим цим полем — писала машина, і зберігати такий запит не треба. Таке поле-приманку звуть honeypot, тобто «горщик з медом».

      Обробник цілком

      ~/www/api/form.php — шість фільтрів у робочому порядку
      <?php
      declare(strict_types=1);
      session_start();
      header('Content-Type: text/plain; charset=utf-8');
      
      // ── ① розмір і метод ─────────────────────────────────────────
      if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
          http_response_code(405);
          exit('405 · тільки POST');
      }
      if ((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 8192) {
          http_response_code(413);
          exit('413 · запит завеликий');
      }
      
      // ── ② CSRF-токен ─────────────────────────────────────────────
      $real = (string)($_SESSION['csrf'] ?? '');
      $sent = (string)($_POST['csrf'] ?? '');
      if ($real === '' || !hash_equals($real, $sent)) {
          http_response_code(403);
          exit('403 · запит не з нашої сторінки');
      }
      
      // ── ③ honeypot: поле, приховане в CSS, людина його не бачить ─
      if (trim((string)($_POST['website'] ?? '')) !== '') {
          http_response_code(200);          // боту кажемо «все гаразд»
          exit('Дякуємо!');                 // але нічого не зберігаємо
      }
      
      // ── ④ ліміт частоти: 10 надсилань за 60 секунд з однієї адреси ─
      $ip   = (string)($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
      $slot = sys_get_temp_dir() . '/rl_' . hash('sha256', $ip) . '.txt';
      $hits = array_values(array_filter(
          array_map('intval', file_exists($slot) ? file($slot) : []),
          static fn(int $t): bool => $t > time() - 60
      ));
      if (count($hits) >= 10) {
          http_response_code(429);
          header('Retry-After: 60');
          exit('429 · забагато надсилань, спробуйте за хвилину');
      }
      $hits[] = time();
      file_put_contents($slot, implode("\n", $hits) . "\n", LOCK_EX);
      
      // ── ⑤ серверна валідація: клієнтської недостатньо ────────────
      $name = trim((string)($_POST['name'] ?? ''));
      $msg  = trim((string)($_POST['msg']  ?? ''));
      $errors = [];
      if (mb_strlen($name) < 2 || mb_strlen($name) > 80)    { $errors[] = 'name'; }
      if (mb_strlen($msg)  < 5 || mb_strlen($msg)  > 1000)  { $errors[] = 'msg';  }
      if ($errors !== []) {
          http_response_code(422);
          exit('422 · не пройшли поля: ' . implode(', ', $errors));
      }
      
      // ── зберігаємо СИРІ дані, поза www ───────────────────────────
      $row = ['ts' => date('c'), 'name' => $name, 'msg' => $msg];
      file_put_contents(
          getenv('HOME') . '/data/form.jsonl',
          json_encode($row, JSON_UNESCAPED_UNICODE) . "\n",
          FILE_APPEND | LOCK_EX
      );
      
      // ── ⑥ екранування — на ВИХОДІ, при показі ────────────────────
      echo 'Дякуємо, ' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '!';
      Чому зберігаємо сире, а екрануємо при виводі Спокуса «почистити один раз при збереженні» велика. Але вона веде до подвійного екранування: ім'я О'Коннор перетворюється на О&#039;Коннор прямо в журналі, і полагодити це вже нічим. Правило таке: у сховищі лежить те, що ввела людина. Перетворюють рівно в момент показу — і своїм способом для кожного місця показу: HTML, атрибут, JSON.
      Honeypot: поле ховають стилями, а не type="hidden" Поле type="hidden" бот теж бачить у коді й не заповнює. Робочий honeypot — звичайне текстове поле з autocomplete="off" і tabindex="-1", сховане правилом у таблиці стилів. І обов'язково aria-hidden="true": інакше екранний читач продиктує незрячому користувачеві пастку для ботів як звичайне поле.
      Питання

      Форма перевіряє довжину повідомлення через maxlength і JavaScript — усе працює, у браузері довше 1000 символів не введеш. Тестувальник надсилає з термінала curl тіло на 40 000 символів, і воно спокійно лягає в журнал. Що не так із логікою «валідація вже є»?

      Крок 10

      Форма по HTTP — помилка навіть у шкільному проєкті

      Не тому, що «так прийнято», а тому, що по дорозі від класу до сервера є щонайменше троє, хто бачить трафік

      Коли сторінка відкрита по http://, усе введене їде мережею відкритим текстом. Не «якось зашифроване за замовчуванням». Саме відкритим текстом, який читається як звичайний рядок.

      Побачити його може будь-хто на шляху. Сусід у тій самій Wi-Fi-мережі. Адміністратор шкільного роутера. Провайдер.

      Що бачить сторонній у мережі: форма по HTTP і та сама форма по HTTPS Угорі: браузер надсилає форму по HTTP, і той, хто слухає Wi-Fi, читає ім'я, телефон і cookie сеансу відкритим текстом. Унизу: та сама форма по HTTPS — той, хто слухає, бачить лише ім'я домену й обсяг зашифрованих даних. Форма по http:// — те, що видно сусідові в кав'ярні браузер учня натиснув «надіслати» POST /api/form.php HTTP/1.1 Cookie: sid=8f3a91c… name=Оля+Петренко&phone=+380…&msg=… усе це — звичайний текст у пакеті сервер між ними точка доступу, роутер, провайдер Та сама форма по https:// — те саме місце, той самий сусід браузер учня той самий клік 91.219.61.4/s/team-3 · TLS · 1 254 байти 17 03 03 04 8a 9c 1f 2e d4 … видно ЩО домен і СКІЛЬКИ даних — але не видно, що саме всередині сервер розшифрувати може тільки він HTTPS ховає вміст і адресу сторінки, але не ховає сам факт звернення до домену.
      HTTPS не робить сайт «безпечним». Він робить приватним канал. Дірку у формі HTTPS не закриває. CSRF-атака по HTTPS проходить так само добре, як по HTTP: вона теж буде охайно зашифрована.

      Три речі, які HTTP ламає одночасно

      1. Дані форми — ім'я, телефон, текст повідомлення читаються як звичайний рядок;
      2. Cookie сеансу — її теж видно, а хто має cookie, той увійшов як ви. Тому й потрібен атрибут Secure: із ним браузер не надішле cookie по HTTP взагалі;
      3. Сама сторінка — по HTTP її вміст можна не лише прочитати, а й підмінити дорогою: дописати скрипт, підмінити адресу в action форми. Форма виглядатиме вашою, а дані підуть кудись інде.
      Найгірший варіант — сторінка по HTTPS, форма по HTTP Замочок в адресному рядку є, а action форми починається з http://. Користувач бачить ознаку захисту, а дані їдуть відкрито. Сучасні браузери таке блокують або попереджають. Але покладатися на це не варто: перевіряйте action самі.

      Що робимо у продукті

      перевірка з термінала: HTTP має перенаправляти
      $ curl -s -o /dev/null -w '%{http_code} → %{redirect_url}\n' http://91.219.61.4/s/team-<N>/ 301 → http://91.219.61.4/s/team-<N>/ $ curl -s -o /dev/null -w '%{http_code}\n' http://91.219.61.4/s/team-<N>/ 200
      Питання

      Команда каже: «У нас же HTTPS і сертифікат у порядку. Навіщо ще CSRF-токен і перевірка полів на сервері?» Чим саме HTTPS тут не допомагає?

      Крок 11

      Чек-лист готовності: 15 пунктів, 5 із них блокують здачу

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

      Позначайте пункт тоді, коли команда виконана й вивід збігся з очікуваним. Галочки зберігаються в браузері й переживають перезавантаження сторінки.

      Окремо стоять пункти з позначкою блокує. Вони вирішують долю здачі. Скільки б відсотків не набралося, поки такий пункт червоний, продукт не приймуть.

      Три з п'яти блокуючих — це критерії саме цього уроку form_validate, form_escape і csrf автоперевірка дивиться вже сьогодні. Ще два — perms і no_media — наскрізні для всього модуля. Їх перевірять на уроці 16, разом із чужим аудитом. У чек-листі вони стоять уже сьогодні, щоб не рятувати їх за десять хвилин до захисту.
      Адреса в командах — приклад Скрізь у чек-листі стоїть 91.219.61.4/s/team-<N>. Підставте свою адресу. Токен у змінній T береться з живої сторінки форми. Тому команди з ним запрацюють лише тоді, коли форма справді віддає поле csrf.
      Питання

      Команда закрила 13 пунктів із 15 — 87 %. Червоними лишилися form_escape і пункт про доступність форми. Тімлід каже: «87 — це десь одинадцять балів, здаємо». Чому він помиляється?

      Крок 12

      Практика: зібрати, задокументувати, закрити форми

      Кроки 1—4 робить уся команда разом, далі — паралельно за ролями. Галочки зберігаються

      Одне правило, яке не має винятків Обробник форми не приймає жодного POST, доки не перевірив токен. Не «перевіряє, якщо токен передали», а відмовляє, якщо його немає. Різниця в одному слові коду: isset замість hash_equals перетворює захист на декорацію. Саме це найчастіше знаходять на крос-аудиті.
      Крок 13

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

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

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

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

      Крок 14

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

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

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

      Сім речень, які варто запам'ятати

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

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

      Підготуйте 3-хвилинне демо продукту за схемою «проблема → рішення → показ». Прорепетируйте його з секундоміром. Розподіліть репліки заздалегідь: хто говорить про проблему, хто веде показ, хто відповідає на питання про безпеку. Регламент на захисті жорсткий: 3 хвилини і 2 хвилини питань.

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

      Джерела

      Звідки взяті формулювання, коди й правила

      Усе, що на цій сторінці можна перевірити самому, має посилання. Те, що вигадано для прикладу, підписано просто в тексті

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