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