<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
БЕЗПЕКА · JUNIOR ЧАСТО ПИТАЮТЬ

Що таке XSS і як Blade від нього захищає, а де ні?

XSS трапляється, коли дані користувача потрапляють у HTML-сторінку як розмітка або код і браузер їх виконує від імені того, хто сторінку відкрив. Лікується екрануванням на виводі під конкретний контекст: `{{ }}` у Blade викликає `htmlspecialchars()` з `ENT_QUOTES`, `{!! !!}` не екранує нічого, в атрибут `href` треба пускати лише `http(s)`-посилання, а в `<script>` дані передають через `Js::from()` або `@json`. CSP додає другий шар, якщо перший десь протік.

Користувач написав у коментарі `<script>`, і в інших людей вискочив `alert`. Що сталося і де виправляти?
Blade же екранує `{{ }}`. Звідки тоді XSS у посиланні на сайт користувача в профілі?
Чому не можна просто прогнати весь ввід через `strip_tags()` при збереженні?
Як передати PHP-масив у JavaScript на сторінці і не відкрити XSS?
XSS Blade htmlspecialchars екранування CSP Js::from

Браузер не відрізняє розмітку, яку написав розробник, від розмітки, яка прийшла з бази. Якщо в коментар потрапив <img src=x onerror=...>, а шаблон вивів його як HTML, скрипт виконається в кожного, хто відкриє сторінку, з його сесією і правами. Це stored XSS. Reflected працює так само, лише рядок приходить у параметрі запиту і повертається у відповідь, а DOM XSS обходиться без сервера: JavaScript сам пише дані в innerHTML. В OWASP Top 10:2025 XSS входить до категорії A05 Injection.

Захист будують на виводі, а не на вході, бо лише на виводі відомо, куди потрапить рядок. {{ $x }} у Blade компілюється в e($x), а той викликає htmlspecialchars($x, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'): <, >, & і обидва види лапок стають сутностями, тож текст лишається текстом і в тілі сторінки, і в атрибуті в лапках. {!! $x !!} виводить рядок без змін, і кожен такий рядок у шаблоні варто читати як «тут я гарантую, що HTML безпечний». Гарантувати його можна, лише якщо ви згенерували HTML самі або пропустили через санітайзер з переліком дозволених тегів.

Далі починаються контексти, де {{ }} недостатньо. Атрибут без лапок (class={{ $x }}) ламається пробілом, якого htmlspecialchars() не чіпає. Посилання href="{{ $url }}" з javascript:alert(1) проходить екранування без змін, бо в ньому немає жодного спецсимволу, тому схему перевіряють окремо, найпростіше правилом url:http,https при збереженні. У <script> діють правила JavaScript, а не HTML: сутності там не декодуються, зате зворотний слеш і </script> мають значення. Дані в скрипт передають як JSON через Js::from() або @json, обидва ставлять JSON_HEX_TAG, JSON_HEX_APOS, JSON_HEX_QUOT і JSON_HEX_AMP, тож </script> у значенні не закриє тег. На фронтенді те саме правило: textContent і x-text для даних, innerHTML і x-html лише для перевіреної розмітки.

Другий шар обмежує шкоду, коли перший десь протік. Content-Security-Policy з script-src 'nonce-...' не дає виконатись впровадженому <script> чи onerror=, а Vite::useCspNonce() проставляє nonce тегам, які генерує @vite. Cookie сесії з HttpOnly скрипт не прочитає, але це не робить XSS безпечним: скрипт і так працює всередині сесії користувача і може змінити email, створити токен API чи відправити форму. Тому CSP і HttpOnly описують як страховку, а в основі відповіді лишається екранування під контекст.

{{-- Текст: Blade викликає e(), тобто htmlspecialchars з ENT_QUOTES. --}}
<p>{{ $comment->body }}</p>

{{-- Сирий вивід: лише для HTML, який ви згенерували або очистили самі. --}}
<div>{!! $cleanHtml !!}</div>

{{-- Атрибут: завжди в лапках. Без лапок пробіл у значенні відкриває новий атрибут. --}}
<div class="{{ $theme }}">

{{-- URL: javascript:alert(1) пройде крізь e() без змін, тому схему
     перевіряють при збереженні правилом 'website' => ['url:http,https']. --}}
<a href="{{ $profile->website }}" rel="nofollow ugc">Сайт</a>

{{-- JavaScript: Js::from кодує JSON з JSON_HEX_TAG, тому </script> не закриє тег. --}}
<script>
    const profile = {{ Js::from(['name' => $profile->name]) }};
</script>

{{-- Alpine: x-text вставляє текст, x-html вставляє розмітку. --}}
<span x-text="profile.name"></span>
Що екранують на виводі, а не на вході, і під контекст: HTML-текст, атрибут, URL і JavaScript мають різні правила.
Що `{{ $x }}` компілюється в `e($x)`, тобто `htmlspecialchars($x, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')`, а `{!! !!}` виводить рядок як є і допустимий лише для HTML, який ви згенерували або очистили самі.
Що екранування не рятує від `javascript:`-посилання в `href`: у ньому немає жодного символу, який `htmlspecialchars()` змінив би, тому схему перевіряють окремо.
Що є stored, reflected і DOM XSS, і що останній живе цілком у JavaScript (`innerHTML`, `x-html`), тому Blade його не бачить.
Що CSP з nonce не замінює екранування, а обмежує шкоду, коли воно десь протекло.
Чистити ввід `strip_tags()` при збереженні й вважати задачу закритою: ламаються легітимні дані (`a < b`), а атрибути, `javascript:`-посилання і контекст `<script>` лишаються відкритими.
Виводити HTML з редактора чи Markdown через `{!! !!}` без санітайзера, бо «це ж наш редактор»: запит можна відправити й без редактора.
Писати атрибут без лапок: `<div class={{ $theme }}>`. Пробіл у значенні відкриває новий атрибут, і `htmlspecialchars()` тут не допоможе, бо пробіл він не чіпає.
Вставляти дані в скрипт як `var name = '{{ $name }}'`: HTML-сутності всередині `<script>` не декодуються, рядок псується, а зворотний слеш у значенні ламає лапки. Правильно `Js::from($name)`.
Вважати, що cookie з `HttpOnly` робить XSS нешкідливим: скрипт не прочитає cookie, але виконає будь-який запит від імені користувача прямо з його браузера.
ПОРАДА

Скажіть одну фразу: «екранування залежить від того, куди виводимо». І одразу наведіть три приклади, де `{{ }}` недостатньо: `href` з `javascript:`, атрибут без лапок і змінна в `<script>`. Це показує, що ви розумієте механізм, а не памʼятаєте правило «Blade екранує».

оновлено 8 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

`e()` викликає `htmlspecialchars()` з `ENT_QUOTES`, тому екрануються обидва види лапок, а `{!! !!}` компілюється в `echo` без обробки. Рядок `javascript:alert(1)` не містить жодного з цих символів і проходить крізь екранування без змін. `strip_tags()` не знає про контекст атрибута чи скрипта і не чіпає `javascript:`-посилання. `HttpOnly` ховає cookie від `document.cookie`, але скрипт усе одно робить запити від імені користувача.