<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
БЕЗПЕКА · КРИТИЧНІСТЬ: СЕРЕДНЯ OWASP 2025 A01 · Broken Access Control

CSRFCross-Site Request Forgery

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

11 хв читання Junior → Senior
01 · ЩО ЦЕ

Коли чужий сайт натискає кнопку за вас

CSRF (Cross-Site Request Forgery) працює через те, що браузер додає cookie до запиту на ваш сайт незалежно від того, яка сторінка цей запит почала. Людина увійшла в кабінет і відкрила evil.example. Там прихована форма сама відправляє POST на /profile/email, браузер підписує його cookie сесії, і сервер бачить звичайний запит від залогіненого користувача. Відповіді атакувальник не бачить, йому досить наслідку: змінена пошта, переказ, видалений акаунт.

Захист дає . Це випадковий рядок у сесії, який кожна форма надсилає назад у прихованому полі _token, а сервер порівнює через hash_equals. evil.example не може прочитати вашу сторінку через same-origin policy, тож не знає токена. У Blade його ставить @csrf. Для JavaScript є <meta name="csrf-token"> і заголовок X-CSRF-TOKEN або cookie XSRF-TOKEN, яку Laravel ставить сам, а axios повертає в заголовку X-XSRF-TOKEN. Токен живе всю сесію, а не одну форму. XSS цей захист обходить: скрипт на вашій сторінці прочитає токен так само, як ваш код.

СЛОВНИКCSRF-токен. Випадковий рядок, привʼязаний до сесії. Форма повертає його в полі _token чи заголовку, сервер порівнює із сесією. Чужа сторінка його не знає, бо не може прочитати вашу.

У Laravel 13 перевірку робить middleware PreventRequestForgery у групі web. Старі імена VerifyCsrfToken і ValidateCsrfToken лишилися як deprecated-нащадки. GET, HEAD і OPTIONS він пропускає без перевірки. Далі дивиться на заголовок : same-origin означає запит з вашої ж сторінки, і такий запит проходить без токена. Якщо заголовка немає або він інший, порівнює токен. Налаштовується в bootstrap/app.php: preventRequestForgery(except: [...]), а прапорці originOnly і allowSameSite керують перевіркою за заголовком.

СЛОВНИКSec-Fetch-Site. Заголовок, який браузер додає сам: same-origin, same-site, cross-site або none. Скрипт сторінки не може його підробити, тому сервер дізнається, звідки прийшов запит.

Livewire бере токен з @livewireScriptConfig або атрибута data-csrf свого скрипту й надсилає X-CSRF-TOKEN з кожним оновленням компонента. Маршрут оновлення стоїть у групі web, тож перевіряє його той самий middleware. Коли сесія закінчилася, сервер відповідає 419, і Livewire пропонує перезавантажити сторінку.

Другий рубіж дає атрибут cookie . Laravel за замовчуванням ставить сесії lax: браузер не надсилає cookie у міжсайтових POST з форм, fetch чи iframe, але надсилає, коли людина переходить за посиланням. Тому GET, який щось змінює, лишається вразливим і з Lax. Крім того, ширше за same-origin: blog.example.com і app.example.com один сайт, і від сусіднього піддомену SameSite не захистить. Токен потрібен і далі.

СЛОВНИКSameSite. Атрибут cookie: Strict, Lax або None. Каже браузеру, чи додавати cookie до запитів, які почав інший сайт. Lax пропускає лише перехід за посиланням, None пропускає все й вимагає Secure.
СЛОВНИКSame-site. Той самий зареєстрований домен, піддомени не важливі: a.example.com і b.example.com один сайт. Same-origin суворіший: схема, домен і порт мають збігатися повністю.

Не кожному маршруту потрібен токен. API, яке автентифікує токеном у заголовку Authorization, CSRF не боїться: браузер сам цей заголовок не додає, і підробленому запиту нічим підписатися. Тому routes/api.php живе без групи web. Але якщо SPA входить через cookie сесії, як у Sanctum, перевірка знову потрібна. Вебхуки виключають з CSRF, бо в стороннього сервісу немає вашого токена, і замість нього перевіряють підпис.

02 · ЯК ДУМАЄ АТАКУВАЛЬНИК

Три кроки від чужої сторінки до зміненої пошти

ДІАГРАМА · УСЯ СХЕМА
Кроки: 3
відкрили посиланняавтосабмітPOST + cookieбез токенаВи, увійшли{ }evil.example:Прихована формаВаш сайт:Нова пошта
Ви, увійшли{ }evil.example:Прихована формаВаш сайт:Нова пошта
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Клієнт Ключовий учасник Інфраструктура
  • Дані йдуть від Ви, увійшли до evil.example.
  • evil.example створює Прихована форма.
  • Прихована форма використовує Ваш сайт (POST + cookie).
  • Ваш сайт надсилає подію без токена, її отримує Нова пошта.
03 · ВРАЗЛИВИЙ І ЗАХИЩЕНИЙ КОД

Перевірка, яку робить Laravel

PHP 8.5
1<?php
2
3session_start();
4
5// Було: досить cookie сесії, а її браузер додає сам, хоч би звідки прийшла форма.
6if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_SESSION['user_id'])) {
7 changeEmail($_SESSION['user_id'], $_POST['email']);
8}
9
10// А видалення ще й через GET: вистачить посилання в листі чи на форумі.
11if (isset($_GET['delete'], $_SESSION['user_id'])) {
12 deleteAccount($_SESSION['user_id']);
13}
1<?php
2
31// Токен живе в сесії. Чужий сайт не може прочитати вашу сторінку, тож не знає його.
4$session = ['_token' => bin2hex(random_bytes(20))];
5
6// Перевірка PreventRequestForgery з Laravel 13, спрощено.
7function allows(array $request, array $session): bool
8{
92 if (in_array($request['method'], ['GET', 'HEAD', 'OPTIONS'], true)) {
10 return true;
11 }
12
133 if (($request['headers']['Sec-Fetch-Site'] ?? null) === 'same-origin') {
14 return true;
15 }
16
174 $token = $request['input']['_token'] ?? $request['headers']['X-CSRF-TOKEN'] ?? null;
18
19 return is_string($token) && hash_equals($session['_token'], $token);
20}
21
22$requests = [
23 'форма з @csrf' => ['method' => 'POST', 'headers' => [], 'input' => ['_token' => $session['_token']]],
24 'Livewire, заголовок X-CSRF-TOKEN' => ['method' => 'POST', 'headers' => ['X-CSRF-TOKEN' => $session['_token']], 'input' => []],
25 'fetch зі своєї сторінки' => ['method' => 'POST', 'headers' => ['Sec-Fetch-Site' => 'same-origin'], 'input' => []],
26 'форма на evil.example' => ['method' => 'POST', 'headers' => ['Sec-Fetch-Site' => 'cross-site'], 'input' => []],
27 'evil.example з вигаданим токеном' => ['method' => 'POST', 'headers' => ['Sec-Fetch-Site' => 'cross-site'], 'input' => ['_token' => 'abc']],
28 'GET /account/delete з чужого посилання' => ['method' => 'GET', 'headers' => ['Sec-Fetch-Site' => 'cross-site'], 'input' => []],
29];
30
315foreach ($requests as $name => $request) {
32 echo $name, ': ', allows($request, $session) ? 'пропущено' : '419 Page Expired', "\n";
33}
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–4

    Токен генерує сервер з криптографічно стійкого random_bytes і тримає в сесії. У cookie він не потрібен: його приносить форма чи заголовок.

  2. 2 рядки 9–11

    Читаючі методи middleware не перевіряє взагалі. Тому дія, яка щось змінює, ніколи не має бути GET: останній рядок виводу це показує.

  3. 3 рядки 13–15

    Новинка Laravel 13: браузер сам ставить Sec-Fetch-Site, і запит з вашої ж сторінки проходить без токена. Підробити заголовок зі сторінки не можна.

  4. 4 рядки 17–19

    Токен з поля _token або заголовка X-CSRF-TOKEN порівнюється через hash_equals, за сталий час, щоб не підказати збіг по таймінгу.

  5. 5 рядки 31–33

    Чужа форма отримує 419 і з токеном, і без нього. А GET з чужого посилання проходить, бо це читаючий метод.

  6. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · ПАСТКИ ФРЕЙМВОРКУ

Laravel перевіряє сам, крім цих місць

LARAVEL · GET
Route::get('/account/delete', [AccountController::class, 'destroy']);

Чому це дірка: PreventRequestForgery не дивиться на GET, HEAD і OPTIONS, а cookie з SameSite=Lax браузер додає при переході за посиланням. Дія, що змінює стан, завжди POST, PUT, PATCH чи DELETE.

LARAVEL · MATCH
Route::match(['get', 'post'], '/team/invite', [TeamController::class, 'invite']);

Чому це дірка: Той самий контролер відповідає на GET, і цей шлях проходить без токена. Якщо дія змінює дані, лишайте лише POST.

LARAVEL · EXCEPT
$middleware->preventRequestForgery(except: ['account/*']);

Чому це дірка: Шаблон з * вимикає перевірку для всіх адрес під ним, і для тих, що зʼявляться пізніше. Винятки пишіть точними адресами й лише для запитів без сесії.

LARAVEL · ВЕБХУК
Route::post('/webhooks/stripe', StripeWebhookController::class)
    ->withoutMiddleware(PreventRequestForgery::class);

Чому це дірка: Вимкнути CSRF для вебхука правильно, у Stripe немає вашого токена. Але тепер справжність запиту тримається лише на підписі: без перевірки заголовка Stripe-Signature будь-хто надішле «успішну оплату».

JAVASCRIPT · FETCH
fetch('/profile', { method: 'POST', body })

Чому це дірка: Сучасний браузер додасть Sec-Fetch-Site: same-origin, і Laravel 13 пропустить запит без токена. Старий браузер чи вебвʼю цього заголовка не шле й отримає 419. Додавайте X-CSRF-TOKEN з <meta name="csrf-token">.

SANCTUM · SPA
Route::middleware('auth:sanctum')->post('/api/orders', [OrderController::class, 'store']);

Чому це дірка: З токеном у заголовку Authorization CSRF не страшний. Але SPA на Sanctum входить через cookie сесії, і тоді перевірка потрібна: спершу GET /sanctum/csrf-cookie, далі X-XSRF-TOKEN у кожному запиті.

СЕСІЯ · SAMESITE
'same_site' => 'none',

Чому це дірка: None повертає cookie в кожен міжсайтовий запит, і захист знову тримається лише на токені. Це для віджетів в iframe на чужих сайтах. Звичайному застосунку лишайте lax.

LARAVEL · ТЕСТИ
$this->post('/profile/email', ['email' => $new])->assertRedirect();

Чому це дірка: Під час тестів PreventRequestForgery пропускає все, тож зелений тест не доводить, що форма має @csrf. Перевірте саму форму: assertSee('name="_token"', false).

SYMFONY · TWIG
<form method="post" action="{{ path('post_delete', {id: post.id}) }}">

Чому це дірка: Symfony Form додає токен сам, а рукописну форму, як кнопку «Видалити», ні. Додайте csrf_token('delete' ~ post.id) у шаблон і isCsrfTokenValid у контролері.

05 · ЗНАЙДИ ПРОБЛЕМУ

Де тут CSRF

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

routes/web.php натисніть на підозрілі рядки
Тут усе гаразд: цей рядок нічого не змінює або лише відкриває групу.
Тут усе гаразд: POST у групі web, PreventRequestForgery перевірить токен.
Пропущено. Так, тут CSRF. Видалення акаунта через GET: middleware його не перевіряє, а Lax-cookie браузер додасть при переході за посиланням. Потрібен Route::delete і форма з @csrf.
Тут усе гаразд: цей рядок нічого не змінює або лише відкриває групу.
Пропущено. Теж вразливо. Маршрут відповідає й на GET, і запрошення можна надіслати посиланням з чужого сайту без токена. Лишіть тільки post.
Сам маршрут POST під auth нормальний. Проблема на наступному рядку.
Пропущено. І тут. Звичайний маршрут з сесією без перевірки CSRF: будь-яка сторінка змінить аватар залогіненої людини. Токен для AJAX передається в X-CSRF-TOKEN, вимикати перевірку не треба.
Тут усе гаразд: вихід через POST, а не посиланням.
Тут усе гаразд: цей рядок нічого не змінює або лише відкриває групу.
Тут усе гаразд: цей рядок нічого не змінює або лише відкриває групу.
Для вебхука виняток доречний: у Stripe немає вашого токена. Головне, щоб контролер перевіряв підпис Stripe-Signature.
Пропущено. GET змінює стан, і id у адресі вгадується: будь-хто відпише будь-кого одним посиланням. Потрібна підписана адреса з middleware signed, а саму відписку краще робити POST з цієї сторінки.
06 · ЧЕКЛІСТ ДЛЯ PR

Вставте у свій шаблон пул-реквесту

## Захист від CSRF
  • Кожна дія, що змінює стан, приймає лише POST, PUT, PATCH чи DELETE. GET тільки читає.
  • Кожна форма в групі web має @csrf, а запити з JavaScript несуть X-CSRF-TOKEN або X-XSRF-TOKEN.
  • Винятки з PreventRequestForgery записані точними адресами, без *, і кожен має іншу перевірку: підпис вебхука, throttle.
  • Cookie сесії має SameSite lax або strict, Secure і HttpOnly.
  • API з токеном в Authorization не читає cookie сесії, а SPA на cookie проходить перевірку CSRF.
  • Feature-тест перевіряє, що форма містить поле _token: у тестах сам middleware перевірку пропускає.
  • На сторінках немає XSS: скрипт на вашому домені читає токен і обходить увесь CSRF-захист.
07 · ЯК У PHP UKRAINE

Де цей сайт налаштовує CSRF

Окремого middleware для CSRF у проєкті немає: PreventRequestForgery стоїть у групі web з коробки, а bootstrap/app.php лише перелічує винятки. Їх два, обидва точними адресами. Форма підписки на сторінці «скоро» рендериться до старту сесії, тож токена в ній немає, і її бережуть honeypot і throttle:5,1. events приймає маячок переглядів сторінок з throttle:60,1. validateCsrfTokens у Laravel 13 позначено deprecated: він викликає preventRequestForgery(except: ...), поведінка та сама.

php app.php фрагмент, скорочено
1->withMiddleware(function (Middleware $middleware): void {
2 // The holding page renders before the session starts, so its form has no CSRF token; a honeypot and throttling guard it instead.
3 $middleware->validateCsrfTokens(except: ['launch/subscribe', 'events']);← лише два точні винятки
4 $middleware->append(SecurityHeaders::class);
5 $middleware->web(append: [AuthenticateSession::class, AddLinkHeadersForPreloads::class]);
resources/views/components/layouts/app.blade.phpКожна сторінка має <meta name="csrf-token">, а @livewireScriptConfig передає токен Livewire, який шле його в X-CSRF-TOKEN.
resources/js/tools/server.jsІнструменти, що рахують на сервері, беруть токен з meta і надсилають у заголовку X-CSRF-TOKEN.
resources/views/components/layouts/dashboard.blade.phpВихід з кабінету працює формою POST з @csrf, а не посиланням.
config/session.phpCookie сесії за замовчуванням має SameSite lax і HttpOnly.
08 · СЛОВНИК
CSRF-токен
Випадковий рядок, привʼязаний до сесії. Форма повертає його в полі _token чи заголовку, сервер порівнює із сесією. Чужа сторінка його не знає, бо не може прочитати вашу.
Sec-Fetch-Site
Заголовок, який браузер додає сам: same-origin, same-site, cross-site або none. Скрипт сторінки не може його підробити, тому сервер дізнається, звідки прийшов запит.
SameSite
Атрибут cookie: Strict, Lax або None. Каже браузеру, чи додавати cookie до запитів, які почав інший сайт. Lax пропускає лише перехід за посиланням, None пропускає все й вимагає Secure.
Same-site
Той самий зареєстрований домен, піддомени не важливі: a.example.com і b.example.com один сайт. Same-origin суворіший: схема, домен і порт мають збігатися повністю.
09 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з безпеки →

Бо підроблений запит і є запитом залогіненої людини. Браузер сам додає cookie сесії до запиту на ваш сайт, навіть якщо форму відправила чужа сторінка. Сервер має переконатися, що запит почала ваша сторінка: токен, який знає лише вона, або заголовок Sec-Fetch-Site: same-origin.

Lax пропускає cookie при переході за посиланням, тож GET, що змінює стан, лишається вразливим. Same-site охоплює всі піддомени: сторінка на blog.example.com для app.example.com той самий сайт. Старі браузери й вебвʼю SameSite можуть не знати. Токен закриває всі ці випадки, а SameSite лишається другим рубежем.

Ті, де немає автентифікації через cookie: вебхуки сторонніх сервісів (з перевіркою підпису замість токена), публічні маячки без сесії, API з токеном у заголовку Authorization, яке й так живе поза групою web. Виняток пишуть точною адресою, бо шаблон з * зачепить і майбутні маршрути. SPA, що входить через cookie, перевірка потрібна.

Middleware перейменовано на PreventRequestForgery, а VerifyCsrfToken і ValidateCsrfToken лишилися deprecated-нащадками. Перед токеном він дивиться на Sec-Fetch-Site: same-origin пропускає одразу, same-site лише з allowSameSite. З originOnly: true токен узагалі не потрібен і XSRF-TOKEN не ставиться, а запит без потрібного заголовка отримує OriginMismatchException. Так можна, якщо всі клієнти сучасні браузери. Для старих браузерів, вебвʼю й клієнтів, що не шлють заголовок, лишаю типовий режим з токеном як запасним шляхом.