Коли чужий сайт натискає кнопку за вас
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 цей захист обходить: скрипт на вашій сторінці прочитає токен так само, як ваш код.
У Laravel 13 перевірку робить middleware PreventRequestForgery у групі web. Старі імена VerifyCsrfToken і ValidateCsrfToken лишилися як deprecated-нащадки. GET, HEAD і OPTIONS він пропускає без перевірки. Далі дивиться на заголовок : same-origin означає запит з вашої ж сторінки, і такий запит проходить без токена. Якщо заголовка немає або він інший, порівнює токен. Налаштовується в bootstrap/app.php: preventRequestForgery(except: [...]), а прапорці originOnly і allowSameSite керують перевіркою за заголовком.
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 не захистить. Токен потрібен і далі.
Не кожному маршруту потрібен токен. API, яке автентифікує токеном у заголовку Authorization, CSRF не боїться: браузер сам цей заголовок не додає, і підробленому запиту нічим підписатися. Тому routes/api.php живе без групи web. Але якщо SPA входить через cookie сесії, як у Sanctum, перевірка знову потрібна. Вебхуки виключають з CSRF, бо в стороннього сервісу немає вашого токена, і замість нього перевіряють підпис.
Три кроки від чужої сторінки до зміненої пошти
- Дані йдуть від Ви, увійшли до evil.example.
- evil.example створює Прихована форма.
- Прихована форма використовує Ваш сайт (POST + cookie).
- Ваш сайт надсилає подію без токена, її отримує Нова пошта.
Перевірка, яку робить Laravel
-
1
рядки 3–4
Токен генерує сервер з криптографічно стійкого random_bytes і тримає в сесії. У cookie він не потрібен: його приносить форма чи заголовок.
-
2
рядки 9–11
Читаючі методи middleware не перевіряє взагалі. Тому дія, яка щось змінює, ніколи не має бути GET: останній рядок виводу це показує.
-
3
рядки 13–15
Новинка Laravel 13: браузер сам ставить Sec-Fetch-Site, і запит з вашої ж сторінки проходить без токена. Підробити заголовок зі сторінки не можна.
-
4
рядки 17–19
Токен з поля _token або заголовка X-CSRF-TOKEN порівнюється через hash_equals, за сталий час, щоб не підказати збіг по таймінгу.
-
5
рядки 31–33
Чужа форма отримує 419 і з токеном, і без нього. А GET з чужого посилання проходить, бо це читаючий метод.
- Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
Laravel перевіряє сам, крім цих місць
Route::get('/account/delete', [AccountController::class, 'destroy']);
Чому це дірка: PreventRequestForgery не дивиться на GET, HEAD і OPTIONS, а cookie з SameSite=Lax браузер додає при переході за посиланням. Дія, що змінює стан, завжди POST, PUT, PATCH чи DELETE.
Route::match(['get', 'post'], '/team/invite', [TeamController::class, 'invite']);
Чому це дірка: Той самий контролер відповідає на GET, і цей шлях проходить без токена. Якщо дія змінює дані, лишайте лише POST.
$middleware->preventRequestForgery(except: ['account/*']);
Чому це дірка: Шаблон з * вимикає перевірку для всіх адрес під ним, і для тих, що зʼявляться пізніше. Винятки пишіть точними адресами й лише для запитів без сесії.
Route::post('/webhooks/stripe', StripeWebhookController::class) ->withoutMiddleware(PreventRequestForgery::class);
Чому це дірка: Вимкнути CSRF для вебхука правильно, у Stripe немає вашого токена. Але тепер справжність запиту тримається лише на підписі: без перевірки заголовка Stripe-Signature будь-хто надішле «успішну оплату».
fetch('/profile', { method: 'POST', body })
Чому це дірка: Сучасний браузер додасть Sec-Fetch-Site: same-origin, і Laravel 13 пропустить запит без токена. Старий браузер чи вебвʼю цього заголовка не шле й отримає 419. Додавайте X-CSRF-TOKEN з <meta name="csrf-token">.
Route::middleware('auth:sanctum')->post('/api/orders', [OrderController::class, 'store']);
Чому це дірка: З токеном у заголовку Authorization CSRF не страшний. Але SPA на Sanctum входить через cookie сесії, і тоді перевірка потрібна: спершу GET /sanctum/csrf-cookie, далі X-XSRF-TOKEN у кожному запиті.
'same_site' => 'none',
Чому це дірка: None повертає cookie в кожен міжсайтовий запит, і захист знову тримається лише на токені. Це для віджетів в iframe на чужих сайтах. Звичайному застосунку лишайте lax.
$this->post('/profile/email', ['email' => $new])->assertRedirect();
Чому це дірка: Під час тестів PreventRequestForgery пропускає все, тож зелений тест не доводить, що форма має @csrf. Перевірте саму форму: assertSee('name="_token"', false).
<form method="post" action="{{ path('post_delete', {id: post.id}) }}">
Чому це дірка: Symfony Form додає токен сам, а рукописну форму, як кнопку «Видалити», ні. Додайте csrf_token('delete' ~ post.id) у шаблон і isCsrfTokenValid у контролері.
Де тут 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-захист.
Де цей сайт налаштовує CSRF
Окремого middleware для CSRF у проєкті немає: PreventRequestForgery стоїть у групі web з коробки, а bootstrap/app.php лише перелічує винятки. Їх два, обидва точними адресами. Форма підписки на сторінці «скоро» рендериться до старту сесії, тож токена в ній немає, і її бережуть honeypot і throttle:5,1. events приймає маячок переглядів сторінок з throttle:60,1. validateCsrfTokens у Laravel 13 позначено deprecated: він викликає preventRequestForgery(except: ...), поведінка та сама.
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.- 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 суворіший: схема, домен і порт мають збігатися повністю.
Питання зі співбесід
Бо підроблений запит і є запитом залогіненої людини. Браузер сам додає 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. Так можна, якщо всі клієнти сучасні браузери. Для старих браузерів, вебвʼю й клієнтів, що не шлють заголовок, лишаю типовий режим з токеном як запасним шляхом.