Коли текст від людини стає частиною сторінки
XSS (Cross-Site Scripting) трапляється, коли застосунок вставляє в сторінку текст від людини так, що браузер читає його як . Скрипт тоді працює на вашому домені й у сесії того, хто відкрив сторінку: читає її вміст, надсилає запити від його імені, малює фальшиву форму входу. Cookie з прапорцем HttpOnly скрипт не прочитає, але йому це й не потрібно: до запитів з тієї самої вкладки браузер сам додасть cookie сесії.
Буває три види. лежить на сервері: коментар, «Про себе», назва компанії, і бʼє кожного, хто відкриє сторінку. повертається в тій самій відповіді: пошуковий запит чи текст помилки з параметра адреси, і спрацьовує, коли жертва перейде за підготовленим посиланням. узагалі не проходить через PHP: JavaScript сторінки бере location.hash чи відповідь API і вставляє через innerHTML.
Blade компілює {{ $x }} у echo e($x), а e() викликає htmlspecialchars з ENT_QUOTES, ENT_SUBSTITUTE і кодуванням UTF-8. Знаки < > & " і апостроф стають сутностями, тож тег показується буквами. Twig у Symfony екранує так само, поки ви не попросите |raw. Один виняток варто памʼятати: обʼєкт , як-от HtmlString, e() віддає без екранування, бо він сам заявляє, що вже є HTML.
Екранування HTML захищає вміст тегу й атрибут у лапках. Його замало в атрибуті без лапок, у href з javascript:, в обробнику onclick, у даних усередині <script> і там, де ви самі просите сирий HTML: {!! !!}, |raw, x-html, v-html, Markdown з налаштуваннями за замовчуванням. Для кожного з цих місць свій захист, вони нижче в пастках фреймворку.
дає другий рубіж. Заголовок каже браузеру, звідки можна брати скрипти, і з script-src 'self' без 'unsafe-inline' вставлений тег script чи обробник onerror просто не виконаються. CSP не замінює екранування: його послаблює один 'unsafe-inline', а помилка в політиці лишає дірку. Спершу правильний вивід, потім CSP.
Три кроки від коментаря до чужої сесії
- Атакувальник створює Коментар.
- Коментар використовує База (зберігся).
- Дані йдуть від База до Інший користувач.
- Інший користувач надсилає подію дані жертви, її отримує evil.example.
Коментар і те, як його побачить інша людина
Без екранування браузер сприймає коментар як HTML і код у ньому.
Одна зміна у виводі
-
1
рядки 3–7
Те саме, що робить e() у Laravel: ENT_QUOTES екранує обидві лапки, ENT_SUBSTITUTE замінює зіпсовані байти UTF-8, а не віддає порожній рядок.
-
2
рядки 9–15
Екранування HTML не робить URL безпечним. Дозволяємо лише http і https, решту замінюємо на #.
-
3
рядки 23–25
Кожен коментар іде через e(). У виводі теги стали сутностями < і >, браузер покаже їх буквами.
-
4
рядки 27–29
javascript:alert(1) пройшов крізь e() без змін, і тільки перевірка схеми прибрала його з href.
- Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
Laravel і Symfony екранують самі, крім цих місць
{!! $comment->body !!}
Чому це дірка: Подвійний знак оклику вимикає екранування. {{ }} екранує, {!! !!} друкує рядок як HTML. Лишайте його для HTML, який ви згенерували самі або очистили.
<a href="{{ $user->site }}">сайт</a>
Чому це дірка: Екранування HTML не зупиняє javascript:alert(1) у href чи src. Перевіряйте схему URL, коли зберігаєте його, як ProjectLink на цьому сайті.
<input name="q" value={{ $q }}>
Чому це дірка: htmlspecialchars не чіпає пробіл і знак рівності. Запит «x autofocus onfocus=alert(1)» додасть тегу нові атрибути. Значення атрибута завжди беріть у лапки.
<div onclick="show('{{ $nick }}')">
Чому це дірка: Браузер розкодовує ' назад в апостроф ще до запуску JavaScript, тож нік з апострофом вирветься з рядка. Передавайте дані через data-атрибут або Js::from.
<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, а не замість них.
{{ new HtmlString($user->bio) }}
Чому це дірка: e() не екранує Htmlable: HtmlString сама каже, що вона вже HTML. Загорнути в неї текст користувача означає те саме, що {!! !!}.
<div x-html="comment.body"></div>
Чому це дірка: x-html і v-html вставляють рядок як HTML у браузері, повз Blade. Для тексту користувача беріть x-text, а у Vue звичайні {{ }}.
{!! Str::markdown($post->body) !!}
Чому це дірка: Str::markdown з типовими налаштуваннями знешкоджує лише кілька тегів на кшталт script, а img з onerror і посилання javascript: пропускає. Для тексту від людей передайте ['html_input' => 'strip', 'allow_unsafe_links' => false].
{{ comment.body|raw }}
Чому це дірка: Фільтр |raw вимикає автоекранування Twig. Без нього Twig екранує сам.
Де тут 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> з поля показується на сторінці як <script>.
Як цей сайт показує «Про себе»
«Про себе» розробник пише у звичайному полі, без HTML-редактора. Перед виводом TextBlocks ділить текст на блоки: рядок з ## стає заголовком, рядок з дефісом пунктом списку, решта абзацами. Парсер повертає лише рядки, жодного HTML, а шаблон resources/views/profile/show.blade.php друкує кожен блок через {{ }}. Тому <script> у профілі зʼявиться на сторінці буквами, а форматування все одно є.
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 картки не потрапить.- Розмітка
- 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
- Заголовок відповіді зі списком дозволених джерел для скриптів, стилів, зображень і фреймів. Браузер блокує все інше й може надсилати звіти про порушення.
Питання зі співбесід
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.