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

XSSCross-Site Scripting міжсайтовий скриптинг

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

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

Коли текст від людини стає частиною сторінки

XSS (Cross-Site Scripting) трапляється, коли застосунок вставляє в сторінку текст від людини так, що браузер читає його як . Скрипт тоді працює на вашому домені й у сесії того, хто відкрив сторінку: читає її вміст, надсилає запити від його імені, малює фальшиву форму входу. Cookie з прапорцем HttpOnly скрипт не прочитає, але йому це й не потрібно: до запитів з тієї самої вкладки браузер сам додасть cookie сесії.

СЛОВНИКРозмітка. HTML-теги, атрибути й JavaScript, які браузер виконує, на відміну від тексту, який він лише показує. XSS це момент, коли текст переходить у розмітку.

Буває три види. лежить на сервері: коментар, «Про себе», назва компанії, і бʼє кожного, хто відкриє сторінку. повертається в тій самій відповіді: пошуковий запит чи текст помилки з параметра адреси, і спрацьовує, коли жертва перейде за підготовленим посиланням. узагалі не проходить через PHP: JavaScript сторінки бере location.hash чи відповідь API і вставляє через innerHTML.

СЛОВНИКStored XSS. Збережена XSS: пейлоад записано в базу чи файл, і він спрацьовує в кожного, хто відкриє сторінку з цими даними. Найнебезпечніший вид, бо жертві не треба нікуди переходити.
СЛОВНИКReflected XSS. Відбита XSS: пейлоад приходить у запиті (параметр адреси, поле форми) і повертається в тій самій відповіді. Потребує, щоб жертва відкрила підготовлене посилання.
СЛОВНИКDOM-based XSS. XSS у JavaScript сторінки: скрипт бере дані з адреси, localStorage чи API і вставляє їх як HTML. Сервер може навіть не бачити пейлоаду, тому серверне екранування тут не допоможе.

Blade компілює {{ $x }} у echo e($x), а e() викликає htmlspecialchars з ENT_QUOTES, ENT_SUBSTITUTE і кодуванням UTF-8. Знаки < > & " і апостроф стають сутностями, тож тег показується буквами. Twig у Symfony екранує так само, поки ви не попросите |raw. Один виняток варто памʼятати: обʼєкт , як-от HtmlString, e() віддає без екранування, бо він сам заявляє, що вже є HTML.

СЛОВНИКHtmlable. Інтерфейс Illuminate\Contracts\Support\Htmlable з методом toHtml(). Його реалізують HtmlString, Js, слоти компонентів і обʼєкт View. e() віддає toHtml() як є, тому HtmlString з текстом користувача працює як {!! !!}.

Екранування HTML захищає вміст тегу й атрибут у лапках. Його замало в атрибуті без лапок, у href з javascript:, в обробнику onclick, у даних усередині <script> і там, де ви самі просите сирий HTML: {!! !!}, |raw, x-html, v-html, Markdown з налаштуваннями за замовчуванням. Для кожного з цих місць свій захист, вони нижче в пастках фреймворку.

дає другий рубіж. Заголовок каже браузеру, звідки можна брати скрипти, і з script-src 'self' без 'unsafe-inline' вставлений тег script чи обробник onerror просто не виконаються. CSP не замінює екранування: його послаблює один 'unsafe-inline', а помилка в політиці лишає дірку. Спершу правильний вивід, потім CSP.

СЛОВНИКContent-Security-Policy. Заголовок відповіді зі списком дозволених джерел для скриптів, стилів, зображень і фреймів. Браузер блокує все інше й може надсилати звіти про порушення.
02 · ЯК ДУМАЄ АТАКУВАЛЬНИК

Три кроки від коментаря до чужої сесії

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

Коментар і те, як його побачить інша людина

ПОЛЕ КОМЕНТАРЯсимуляція
Як побачить інша людина
Олена К.щойно
⚡ тут спрацював би скрипт

Без екранування браузер сприймає коментар як HTML і код у ньому.

04 · ВРАЗЛИВИЙ І ЗАХИЩЕНИЙ КОД

Одна зміна у виводі

PHP 8.5
1<?php
2
3$comments = [
4 'Чудова стаття, дякую!',
5 '<script>alert(1)</script>',
6 '<img src=x onerror=alert(1)>',
7];
8
9// Було: текст коментаря йде в HTML як є.
10foreach ($comments as $body) {
11 echo '<p>', $body, "</p>\n";
12}
13
14// htmlspecialchars не знає, що таке схема URL.
15$site = 'javascript:alert(1)';
16echo '<a href="', htmlspecialchars($site), '">сайт</a>', "\n";
1<?php
2
31// Як e() у Laravel: Blade компілює {{ $x }} у echo e($x).
4function e(string $value): string
5{
6 return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
7}
8
92// Схему URL перевіряємо окремо: e() її не чіпає.
10function safeUrl(string $url): string
11{
12 $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
13
14 return in_array($scheme, ['http', 'https'], true) ? $url : '#';
15}
16
17$comments = [
18 'Чудова стаття, дякую!',
19 '<script>alert(1)</script>',
20 '<img src=x onerror=alert(1)>',
21];
22
233foreach ($comments as $body) {
24 echo '<p>', e($body), "</p>\n";
25}
26
274$site = 'javascript:alert(1)';
28echo "\n", 'e() лишає схему як є: ', e($site), "\n";
29echo 'Після перевірки схеми: <a href="', e(safeUrl($site)), '">сайт</a>', "\n";
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–7

    Те саме, що робить e() у Laravel: ENT_QUOTES екранує обидві лапки, ENT_SUBSTITUTE замінює зіпсовані байти UTF-8, а не віддає порожній рядок.

  2. 2 рядки 9–15

    Екранування HTML не робить URL безпечним. Дозволяємо лише http і https, решту замінюємо на #.

  3. 3 рядки 23–25

    Кожен коментар іде через e(). У виводі теги стали сутностями &lt; і &gt;, браузер покаже їх буквами.

  4. 4 рядки 27–29

    javascript:alert(1) пройшов крізь e() без змін, і тільки перевірка схеми прибрала його з href.

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

Laravel і Symfony екранують самі, крім цих місць

LARAVEL · BLADE
{!! $comment->body !!}

Чому це дірка: Подвійний знак оклику вимикає екранування. {{ }} екранує, {!! !!} друкує рядок як HTML. Лишайте його для HTML, який ви згенерували самі або очистили.

БУДЬ-ЯКИЙ ШАБЛОН · HREF
<a href="{{ $user->site }}">сайт</a>

Чому це дірка: Екранування HTML не зупиняє javascript:alert(1) у href чи src. Перевіряйте схему URL, коли зберігаєте його, як ProjectLink на цьому сайті.

АТРИБУТ БЕЗ ЛАПОК
<input name="q" value={{ $q }}>

Чому це дірка: htmlspecialchars не чіпає пробіл і знак рівності. Запит «x autofocus onfocus=alert(1)» додасть тегу нові атрибути. Значення атрибута завжди беріть у лапки.

LARAVEL · ОБРОБНИК ПОДІЇ
<div onclick="show('{{ $nick }}')">

Чому це дірка: Браузер розкодовує &#039; назад в апостроф ще до запуску JavaScript, тож нік з апострофом вирветься з рядка. Передавайте дані через data-атрибут або Js::from.

LARAVEL · @JSON
<div data-user='@json($user, JSON_UNESCAPED_UNICODE)'>

Чому це дірка: @json і Js::from переводять лапки й < > у \u-послідовності, тому їхній вивід безпечний і в <script>, і в атрибуті. Js::from додає ваші прапорці до типових, а в @json вони типові замінюють: тут зник JSON_HEX_APOS, і апостроф з імені закриє атрибут. Додавайте прапорці до JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT, а не замість них.

LARAVEL · HTMLSTRING
{{ new HtmlString($user->bio) }}

Чому це дірка: e() не екранує Htmlable: HtmlString сама каже, що вона вже HTML. Загорнути в неї текст користувача означає те саме, що {!! !!}.

ALPINE · VUE
<div x-html="comment.body"></div>

Чому це дірка: x-html і v-html вставляють рядок як HTML у браузері, повз Blade. Для тексту користувача беріть x-text, а у Vue звичайні {{ }}.

MARKDOWN
{!! Str::markdown($post->body) !!}

Чому це дірка: Str::markdown з типовими налаштуваннями знешкоджує лише кілька тегів на кшталт script, а img з onerror і посилання javascript: пропускає. Для тексту від людей передайте ['html_input' => 'strip', 'allow_unsafe_links' => false].

SYMFONY · TWIG
{{ comment.body|raw }}

Чому це дірка: Фільтр |raw вимикає автоекранування Twig. Без нього Twig екранує сам.

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

Де тут XSS

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

resources/views/profile.blade.php натисніть на підозрілі рядки
Тут усе гаразд: {{ }} екранує вивід у звичайному HTML.
Пропущено. Біографію друкують через {!! !!}, тож будь-який HTML у ній виконається. Потрібне {{ }}, а для форматування очищений Markdown.
Пропущено. У href екранування HTML не зупиняє javascript:alert(1). Перевіряйте схему URL.
Тут усе гаразд: {{ }} екранує вивід у звичайному HTML.
Тут усе гаразд: src у лапках, {{ }} екранує, а id це число з бази.
Пропущено. В атрибуті onclick браузер спершу розкодовує &#039; назад в апостроф, а потім запускає JavaScript. Нік з апострофом вирветься з рядка. Передавайте дані через data-атрибут або Js::from.
Пропущено. Reflected XSS. Атрибут без лапок, а пробіл і = htmlspecialchars не екранує: ?q=x autofocus onfocus=alert(1) додасть обробник. Візьміть value у лапки.
Тут усе гаразд: {{ }} екранує вивід у звичайному HTML.
07 · ЧЕКЛІСТ ДЛЯ PR

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

## Захист від XSS
  • Увесь вивід даних користувача йде через {{ }} або автоекранування Twig, а {!! !!} і |raw лише для HTML, який ми згенерували самі або очистили.
  • Значення атрибутів у лапках. Дані для JavaScript передаються через @json або Js::from, і власні прапорці додаються до типових, а не замість них.
  • URL від користувача в href і src перевірено на дозволену схему: http, https, mailto.
  • Markdown і багатий текст від людей очищено: html_input strip і allow_unsafe_links false або HTML Purifier, а не регулярка.
  • У JavaScript текст вставляється через textContent, x-text чи v-text, а не innerHTML, x-html чи v-html.
  • Feature-тест перевіряє, що <script> з поля показується на сторінці як &lt;script&gt;.
08 · ЯК У PHP UKRAINE

Як цей сайт показує «Про себе»

«Про себе» розробник пише у звичайному полі, без HTML-редактора. Перед виводом TextBlocks ділить текст на блоки: рядок з ## стає заголовком, рядок з дефісом пунктом списку, решта абзацами. Парсер повертає лише рядки, жодного HTML, а шаблон resources/views/profile/show.blade.php друкує кожен блок через {{ }}. Тому <script> у профілі зʼявиться на сторінці буквами, а форматування все одно є.

php TextBlocks.php фрагмент, скорочено
1foreach (preg_split('/\r?\n/', $text) ?: [] as $line) {
2 $line = trim($line);
3 $heading = preg_match('/^#{1,3}\s+(.+)$/u', $line, $h) === 1 ? $h[1] : null;← ## заголовок
4 $item = preg_match('/^[-*•]\s+(.+)$/u', $line, $i) === 1 ? $i[1] : null;← - пункт
5
6 if ($heading !== null) {
7 $blocks[] = ['kind' => 'head', 'text' => $heading, 'items' => []];
8 } elseif ($item !== null) {
9 $items[] = $item;
10 } elseif ($line !== '') {
11 $blocks[] = ['kind' => 'text', 'text' => $line, 'items' => []];← лише текст, без HTML
12 }
13}
resources/views/profile/show.blade.phpКожен блок друкується через {{ $block['text'] }} і {{ $item }}, жодного {!! !!}.
src/Content/Infrastructure/Markdown/CommonMarkRenderer.phpСтатті, які пишуть читачі, рендеряться з html_input: strip і allow_unsafe_links: false, тож сирий HTML вирізається, а javascript: не стає посиланням.
src/Projects/Domain/ProjectLink.phpПосилання проєкту приймається лише зі схемою http або https і справжнім доменом, тож javascript: у href картки не потрапить.
09 · СЛОВНИК
Розмітка
HTML-теги, атрибути й JavaScript, які браузер виконує, на відміну від тексту, який він лише показує. XSS це момент, коли текст переходить у розмітку.
Stored XSS
Збережена XSS: пейлоад записано в базу чи файл, і він спрацьовує в кожного, хто відкриє сторінку з цими даними. Найнебезпечніший вид, бо жертві не треба нікуди переходити.
Reflected XSS
Відбита XSS: пейлоад приходить у запиті (параметр адреси, поле форми) і повертається в тій самій відповіді. Потребує, щоб жертва відкрила підготовлене посилання.
DOM-based XSS
XSS у JavaScript сторінки: скрипт бере дані з адреси, localStorage чи API і вставляє їх як HTML. Сервер може навіть не бачити пейлоаду, тому серверне екранування тут не допоможе.
Htmlable
Інтерфейс Illuminate\Contracts\Support\Htmlable з методом toHtml(). Його реалізують HtmlString, Js, слоти компонентів і обʼєкт View. e() віддає toHtml() як є, тому HtmlString з текстом користувача працює як {!! !!}.
Content-Security-Policy
Заголовок відповіді зі списком дозволених джерел для скриптів, стилів, зображень і фреймів. Браузер блокує все інше й може надсилати звіти про порушення.
10 · ПЕРЕВІР СЕБЕ

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

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

Stored зберігається на сервері (коментар у базі) і бʼє кожного, хто відкриє сторінку. Reflected повертається з того самого запиту, як-от пошуковий запит у відповіді, і потребує, щоб жертва перейшла за підготовленим посиланням. DOM-based третій вид: його вставляє JavaScript сторінки, і сервер пейлоаду може взагалі не бачити.

e() перетворює лише спецсимволи HTML: < > & і лапки. Рядок javascript:alert(1) їх не має, тож іде в href без змін, і клік виконає скрипт. Екранування захищає синтаксис HTML, а не зміст URL. Потрібна перевірка схеми (http, https) при збереженні або виводі.

Екранування можна випадково вимкнути: {!! !!}, |raw, HtmlString, x-html, вивід в атрибут чи JavaScript. CSP це другий рубіж: навіть якщо пейлоад потрапив на сторінку, браузер не виконає інлайн-скрипт і не завантажить код зі стороннього домену. Але CSP з unsafe-inline майже нічого не дає, тому спершу правильний вивід.

Очистити за білим списком: HTML Purifier або symfony/html-sanitizer лишають дозволені теги й атрибути та прибирають решту, включно з on* і javascript:. Регулярки для цього не годяться. Очищати краще при виводі або зберігати очищену версію поруч з оригіналом, щоб змінити правила без міграції. Якщо досить форматування, простіше взяти Markdown з html_input strip. І все це під CSP.