XSS: міжсайтовий скриптинг
Зміст
Типова перевірка на XSS у виконанні джуна виглядає так: вставити <script>alert(1)</script> у поле пошуку й подивитися, чи вискочить вікно. Алерту немає — «чисто», закриваємо. Насправді перевірено один тег в одному контексті одного типу доставки: пейлоад міг не спрацювати, бо значення поїхало в атрибут, а не в тіло HTML; бо виконання видно не тут, а в адмінці; бо тіло відповіді не змінюється й весь сюжет відбувається в DOM.
Механізм і базове означення XSS живуть у главі HTTPS, TLS і безпека — тут вони не переказуються. Ця глава — канон сайту за тим, чого там немає: типи доставки, й кодування, небезпечні DOM-сінки, і пейлоади. як категорія — глава Інʼєкції: SQL, NoSQL, command, і report-режим — глава HTTP-заголовки безпеки, CSP і клікджекінг.
Чому XSS розбирають серед інʼєкцій
Власної категорії в :2025 у XSS немає — він усередині A05 Injection разом із , і профілі двох різні: XSS — «висока частота / низький вплив», понад 30 тисяч ; SQL-інʼєкція — «низька частота / високий вплив», понад 14 тисяч CVE. Формальна підстава сусідства — дерево : CWE-79 є дочірньою слабкістю класу «Injection» (CWE-74), а живучість пояснюється тим, що уникнення XSS вимагає від розробника великої дисципліни. Спосіб виявлення джерело називає прямо: ревʼю коду разом з автоматизованим тестуванням (включно з ) усіх параметрів, заголовків, URL, cookie, JSON, SOAP і XML-вводів — тобто за входами застосунку, а не за видимими полями форми.
Збережений і відбитий: різниця в доставці, не в наслідках
(Stored XSS) — два кроки, рознесені в часі: ввід осідає на сервері (база, форум, лог відвідувань, поле коментаря), а потім жертва вичитує ці дані без того, щоб їх зробили безпечними для рендерингу. Синоніми рівноправні: Stored = Persistent = Type 2, а «HTML Injection» у словнику CWE — не окрема слабкість, а синонім збереженого XSS. Тип тяжчий через аудиторію: заражений вміст віддається всім, хто відкриває сторінку, і щоразу, — тож і місце інʼєкції обирають не навмання: оптимальне те, що бачать або багато користувачів, або ті, хто особливо цікавий . Лог відвідувань тут не випадковий: його заповнює атакувальник, а читає адміністратор.
(Reflected XSS) тримається на двох запереченнях: ввід негайно повертається у відповіді — у повідомленні про помилку, результаті пошуку, будь-якій відповіді з частиною запиту, — і при цьому його не зробили безпечним для рендерингу і не зберегли. Синоніми: Non-Persistent = Type 1; вкладається в один цикл «запит-відповідь». Доставку робить окремий канал — лист або інший сайт: жертва клацає посилання, код їде на вразливий сайт, той відбиває його в браузер, і браузер виконує код, бо той прийшов від «довіреного» сервера. Формально небезпечний вміст подає сама жертва, а каналом доставки буває й CSRF (CSRF і SameSite).
Наслідок від типу не залежить — різниця лише в тому, як пейлоад приходить на сервер. Тому «це лише reflected» не є підставою знизити пріоритет, а «у нас сайт лише для читання» — підставою вважати його невразливим. Тип ще й не назавжди: забруднення параметрів HTTP (HTTP Parameter Pollution) здатне підняти дефект із відбитого до збереженого, а після збереження пейлоада WAF атаці вже не завадить. — форма персистентного: пейлоад зберігається на сервері й повертається жертві вже з бекенд-застосунку; джерело обмежується констатацією, що підтвердити його в реальному сценарії важко, і процедури не дає.
Схема показує лише шлях пейлоада — і саме тут ховається найпоширеніша помилка. Типів не три в один ряд: Stored/Reflected — вісь доставки, Server/Client — вісь місця дефекту, разом це матриця 2×2. названо випадок, коли недовірені дані потрапляють у згенеровану сервером відповідь, а джерелом буває і запит, і сховище: існує і відбитий серверний, і збережений серверний, і так само збережений та відбитий DOM-based.
DOM-based XSS: у відповіді сервера його немає
(у частині текстів — type-0) — атака, у якій пейлоад виконується внаслідок зміни DOM-оточення, яким користується власний клієнтський скрипт сторінки, так що цей скрипт починає працювати «неочікуваним» чином. Тип молодший за інші: у 2005 році його визначив Amit Klein.
Прикмета, яка перевертає методику: сама сторінка — тобто HTTP-відповідь — не змінюється, а клієнтський код сторінки виконується інакше, тож «пошукати пейлоад у тілі відповіді» цей тип не ловить у принципі. Місце інʼєкції теж інше: у відбитому й збереженому атаку впроваджують під час серверної обробки, тут — під час виконання, прямо в клієнті; критерій Type 0 у CWE записано так само. Відповідальність від цього не переїжджає на браузер: увесь код походить із сервера, тож робити його безпечним зобовʼязаний власник застосунку. Найпростіший приклад — значення параметра URL, яке скрипт присвоює властивості innerHTML.
У таксономії це підмножина , де джерело даних лежить у DOM; сам клієнтський XSS означено через сінк — недовірені дані оновлюють DOM небезпечним JS-викликом. Закрити тип важче: велика , брак стандартизації між браузерами, понад 60 різних сінків, а джерелом може бути будь-що під контролем атакувальника — параметр URL, канал postMessage. Серверні фільтри проти нього не працюють за побудовою, WAF проти DOM-based прямо не рекомендований, а мінімальний приклад зі шпаргалки обходів — недовірений GET-параметр напряму в eval.
Контексти виводу: правильного екранування «взагалі» не буває
Мета (output encoding) — перетворити недовірений ввід у форму, де він показується як дані й не виконується як код: небезпечні символи екрануються, щоб їх трактували як текст. Саме це, а не , названо найефективнішим рішенням проти XSS: валідація лише додає шар і зробити її правильно значно складніше, а найпростішим і найсильнішим у більшості випадків захистом від серверного XSS названо поіменно контекстно-залежне кодування виводу на сервері. Захищати треба всі змінні застосунку, а не «підозрілі» поля.
«Ми все екрануємо» нічого не означає, бо методів багато: браузер по-різному розбирає HTML, JS, URL і CSS, і неправильний метод або вносить слабкість, або ламає функціональність. Різні частини одного документа вимагають різного кодування, а HTML entity encoding доречне лише для тіла HTML.
| Контекст вставки | Що робити |
|---|---|
| Тіло HTML, між тегами | HTML entity encoding |
| Значення HTML-атрибута | HTML attribute encoding; безпечні лише атрибути з окремого переліку |
| Вбудований JavaScript | єдине «безпечне» місце — рядкове значення в лапках, символи у форматі \xHH |
| CSS | значення підставляють лише у значення CSS-властивості |
| URL | усі символи у форматі %HH, атрибути обовʼязково в лапках |
Три уточнення до таблиці. Лапки — частина захисту: вони ускладнюють зміну контексту й звужують набір символів, які треба кодувати. Атрибути-обробники подій виключені: ті, що приймають JavaScript (onClick), небезпечні з недовіреним значенням у принципі, і MDN додає сюди ж onblur та атрибут src елемента iframe. А вкладені контексти кодують по черзі: URL усередині атрибута спершу URL-кодують, потім застосовують HTML attribute encoding.
Є й позиції, де кодування не рятує взагалі, — джерело зве їх й вимагає не ставити туди змінних: у контексті виконання закодований рядок виконається попри кодування, а вставка всередину <script> чи <style> майже завжди небезпечна. Коли користувач має право писати HTML, кодування зламає функціональність — тоді застосовують санітизацію: вирізати небезпечний HTML і повернути безпечний, прибравши теги script і вбудовані обробники подій. Санітайзер — жива залежність: обходи знаходять регулярно, тож патчити його треба регулярно.
Кодуванням виводу не є глобальний фільтр на вході (він не знає контексту, а HTML- і JS-кодування не взаємозамінні), сліпе HTML-кодування «на все» (при типі вмісту text/xhtml може не спрацювати) і JSON.stringify(); ще й саме кодування злітає, коли значення читають через атрибут value DOM-елемента. Найдешевша точка входу для тестувальника — місця, де автоекранування шаблонізатора вимкнене штатно: фільтр safe у Django, dangerouslySetInnerHTML у React.
Сінки: куди саме приїжджає значення
(injection sink) — потужна функція вебплатформи, яку можна викликати лише з довіреним, перевіреним або належно санітизованим значенням; виклик із контрольованим атакувальником вводом уже вважається , а — той, у якому недовірене значення дає DOM XSS. Чому такий сінк не видно з коду: рядки не несуть інформації про , тож застосунки регулярно передають туди контрольовані атакувальником значення, не усвідомлюючи цього. Ревʼю «подивитися на виклик innerHTML» відповіді не дає — тому платформний захист і будується на типах.
Сінків понад 60, і вся група має машинну назву 'script'. До неї належать функції, що парсять і вставляють HTML: сетери Element.innerHTML, ShadowRoot.innerHTML, Element.outerHTML і Document.write. Поіменно небезпечні також:
setAttribute(name, value)— неявно приводить рядок до типу DOM-атрибута, тож безпечний лише для обмеженого переліку атрибутів; небезпечні всі, що є контекстом виконання команд, як-отonclickчиonblur.- Методи, що приймають код рядком —
setTimeout,setInterval,new Function. - Неявний
eval— методів, які приховано обчислюють передані дані, багато. Для розбору JSON берутьJSON.parse(): він відкидає все, що не є валідним JSON.
визначено критерієм «трактує значення як текст і ніколи його не виконує». Базовий безпечний спосіб покласти недовірені дані в DOM — textContent; рефакторинг зводиться до заміни небезпечних сінків на textContent або value, а найсильнішим захистом від клієнтського XSS джерело називає використання безпечних JavaScript API. Фікс тут — змінити сінк, а не докрутити екранування.
// небезпечно: значення з URL іде в сінк, який парсить HTML
const name = new URLSearchParams(location.search).get('name') ?? '';
welcome.innerHTML = `Вітаємо, ${name}`;
// безпечно: сінк трактує значення як текст і не виконує його
welcome.textContent = `Вітаємо, ${name}`;
Окремо про innerText, який часто радять як заміну: шпаргалка із запобігання DOM-based XSS застерігає, що залежно від тега код усе-таки може виконатися, а шпаргалка із запобігання XSS у переліку безпечних сінків його не називає взагалі. Для eval заміни немає: недовірений ввід у ньому — завжди погана ідея, і замість спроб санітизувати його просто не роблять.
Для перевірки це означає, що інвентаризація сінків — скінченна робота: перелік відомий наперед, а сам виклик дефектом не є, бо питання в походженні значення.
Trusted Types: типи замість рядків
Trusted Types — API, який замикає потужні веб-API на приймання лише непідробних типізованих значень замість рядків. Він цілить рівно в ту сліпу пляму, через яку живе DOM XSS: створити значення можна тільки через визначені застосунком незмінні політики, тож поверхня зводиться до малих ізольованих ділянок коду. Мета — мінімізувати ймовірність DOM XSS; заявлена не-мета — інʼєкції в розмітку, згенеровану сервером: для них рекомендують шаблонізатори і script-src. Слово «trusted» означає лише впевненість автора, що значення можна безпечно передати в сінк: обгортка нічого не санітизує, зате підробити її не можна.
Примус вмикається через CSP: require-trusted-types-for задає групи сінків, для яких типи обовʼязкові, trusted-types керує створенням політик. Робочий заголовок — Content-Security-Policy: require-trusted-types-for 'script': у браузерах на Chromium він змушує DOM XSS-сінки відхиляти звичайні рядки. Розкочування має штатну репетицію: спершу Content-Security-Policy-Report-Only, збір порушень — і лише потім примус.
Головна пастка міграції — політика з іменем default: браузер викликає її неявно щоразу, коли в сінк передали звичайний рядок, і якщо її немає або вона повертає null чи undefined, це дає порушення CSP — у режимі примусу кидається помилка, у report-only початкове значення просто йде далі. І навпаки: лакса no-op зводить нанівець усю користь механізму — ризик не зникає, а переїжджає в саму політику. Плюс доброї крос-браузерної підтримки API ще не має.
Звідси три наслідки для перевірки: наявність заголовка не означає «увімкнено», бо default у списку рядки не відхиляє; зелений результат у report-only нічого не доводить; а звіт про порушення несе обрізаний пейлоад — 40 символів разом з іменем сінка.
CSP: другий рубіж, і саме другий
CSP свідомо не задумана як перша лінія захисту від інʼєкції вмісту — специфікація описує її як . Те саме з боку XSS формулює OWASP: це список дозволеного, помилитися в реалізації якого легко, тож основним механізмом захисту він бути не має; первинними названо захисти фреймворку, кодування виводу й . Якщо політику будують саме проти XSS, рекомендований підхід — на nonce або хешах, які кажуть браузеру, які скрипти він має побачити в документі; дзеркально 'unsafe-inline' і схему data: як дозволені джерела вказувати не слід.
Межі сусідніх шарів варто тримати в голові: атрибути cookie обмежують шкоду, але не спиняють виконання шкідливого вмісту й не усувають причини, а WAF ненадійний і причини теж не усуває — єдиного прийому не існує, потрібна комбінація. Склад директив і report-режим — глава HTTP-заголовки безпеки, CSP і клікджекінг.
Пейлоади: що вони доводять і чого не доводять
Канонічний набір — це посібник із тестування, а не інструкція «як написати фільтр»: шпаргалка демонструє, що фільтрація вводу є неповним захистом від XSS, і саме для цього дає серію атак, здатних обійти певні захисні фільтри. Знайдений обхід — не задача «полагодити фільтр»: лікують кодування виводу й санітизація. Набір починається з baseline — звичайної JS-інʼєкції, — а далі йде один зонд на кілька контекстів, , що виконується одразу в HTML, рядках скрипта, JavaScript і URL:
javascript:/*--></title></style></textarea></script></xmp> <svg/onload='+/"`/+/onmouseover=1/+/[*/[]/+alert(42);//'>
Пейлоад не зводиться до тега script: зонд може триматися лише на обробнику події, окремий вектор дає поблажливий всередині тега IMG, спрацює й <body onload=alert('test1')> та атрибути на кшталт onmouseover і onerror. Шкідливий вміст часто має форму JavaScript, але може бути HTML, Flash чи будь-яким іншим кодом, який браузер уміє виконати. Класика в розборі MDN: створюється елемент img зі значенням src, яке дає помилку, і саме тому виконується обробник onerror.
Тому один пейлоад нічого не доводить: вставка JavaScript в обробник події застосовна до будь-якого типу тега — Form, Iframe, , Embed — з підстановкою будь-якої релевантної події, що дає дуже багато варіацій тієї самої інʼєкції; та й контекст, у який потрапило значення, розпізнати не завжди легко. Падають фільтри на припущенні, що тег розбивається пробілом; на поблажливості парсера (Gecko дозволяє між обробником події та знаком рівності будь-який символ, крім літер, цифр і символів обгортання); на наївному пошуку парних кутових дужок; на частковому кодуванні — параметр HTML-кодували, але не URL-кодували, і це відкриває забруднення параметрів.
Де дивитися на результат, визначає тип: відбитий видно в тій самій відповіді, збережений — там, куди дані читаються, і другим користувачем, DOM-based — тільки в DOM. І правило : обхід в одному місці — привід шукати далі, бо якщо вразлива одна частина сайту, велика ймовірність, що є й інші проблеми.
Типові помилки
«Алерту немає — поле безпечне». Виглядає як пройдена перевірка, а насправді перевірено один тег в одному контексті: значення могло поїхати в атрибут, у рядок скрипта чи в URL, а варіацій тієї самої інʼєкції за тегами й подіями дуже багато.
«Це лише reflected». Виглядає як тріаж, а насправді наслідок однаковий незалежно від типу — тип описує спосіб доставки, а не шкоду. Те саме з «у нас сайт лише для читання»: доставку робить посилання, а не форма.
«У відповіді пейлоада немає — значить чисто». Виглядає як доказ, а насправді при DOM-based тіло HTTP-відповіді не змінюється взагалі, і пошук по відповіді цей тип не ловить у принципі.
«Ми екранували HTML скрізь». Виглядає як суцільний захист, а насправді HTML entity encoding доречне лише для тіла HTML, різні частини документа вимагають різного кодування, а всередині script і style вставка небезпечна навіть закодована.
«Trusted Types увімкнено, заголовок є». Виглядає як , а насправді default-політика в списку пропускає рядки крізь себе, а в report-only початкове значення взагалі йде далі.
Підсумок
- Тип XSS описує доставку, а не наслідок. Збережений, відбитий і DOM-based відрізняються тим, як пейлоад дістається жертви; шкода однакова, і «лише reflected» аргументом у тріажі не є.
- Типів не три в один ряд. Stored/Reflected — вісь доставки, Server/Client — вісь місця дефекту; разом це матриця 2×2, а DOM-based — підмножина клієнтського XSS.
- Головний захист — контекстно-залежне кодування виводу, і це не одна дія. Тіло HTML, атрибут, вбудований JS, CSS і URL кодуються по-різному, а є ще й контексти, де кодування не рятує взагалі.
- Клієнтську частину лікує сінк, а не фільтр. Виклик сам собою не дефект — питання в походженні значення; правильний фікс — замінити небезпечний сінк на
textContent, і саме на цю сліпу пляму цілять Trusted Types. - CSP і WAF — другий рубіж. Політика ускладнює експлуатацію, але не усуває причини; а пейлоади існують, щоб показати неповноту фільтрації, а не щоб поповнювати .
Можливі питання
«Які бувають типи XSS і чим вони відрізняються?» Слабка відповідь перелічує три слова; сильна називає критерій кожного — осідання даних на сервері, повернення в тій самій відповіді, зміна DOM без зміни відповіді — і додає, що наслідок від типу не залежить.
«Чому DOM-based шукають інакше?» Перевіряють розуміння механізму, а не назви. Ключове: HTTP-відповідь не змінюється, тому пошук пейлоада у відповіді безсилий, а джерело значення шукають у самому DOM — параметр URL, postMessage.
«Як правильно захиститися від XSS?» Дивляться, чи не почнете ви з фільтрації вводу. Очікують кодування виводу з урахуванням контексту, санітизацію там, де користувач має право писати HTML, захисти фреймворку — і лише потім CSP як другий рубіж.
«Чому innerHTML небезпечний, а textContent ні?» Питання про сінки. Сильна відповідь дає критерій безпечного сінка — трактує значення як текст і ніколи не виконує — і додає, що рядок не несе інформації про походження, тож самого виклику для вердикту мало.
«Чи достатньо CSP, щоб закрити XSS?» Питання-пастка. Відповідь: ні — специфікація прямо називає CSP глибиною захисту, а не першою лінією; а Trusted Types, які вмикаються тим самим заголовком, закривають лише DOM-сінки й серверних типів не стосуються.
Джерела
Чому XSS розбирають серед інʼєкцій
- OWASP Top 10:2025 — Injection — XSS усередині A05, профілі ризику XSS і SQL-інʼєкції з кількістю CVE, виявлення через ревʼю коду плюс автоматизоване тестування всіх точок вводу.
- CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') — CWE-79 як дочірня слабкість класу Injection (CWE-74) і причина поширеності дефекту.
Збережений і відбитий: різниця в доставці, не в наслідках
- OWASP — Types of Cross-Site Scripting — означення збереженого й відбитого типів, матриця 2×2, серверний XSS і накладання осей.
- CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') — критерії Type 1 і Type 2, «HTML Injection» як синонім збереженого, вибір місця інʼєкції, однаковість наслідку.
- OWASP — Cross Site Scripting (XSS) — Persistent і Non-Persistent, один цикл «запит-відповідь», доставка листом чи з іншого сайту, застереження про сайт лише для читання, blind XSS і його важкість підтвердження.
- MDN — Cross-site scripting (XSS) — чому збережений особливо тяжкий: вміст віддається всім користувачам і щоразу.
- OWASP Cheat Sheet — XSS Filter Evasion — підняття дефекту з відбитого до збереженого через забруднення параметрів і безсилля WAF після збереження пейлоада.
DOM-based XSS: у відповіді сервера його немає
- OWASP — Types of Cross-Site Scripting — означення type-0, незмінність HTTP-відповіді, DOM-based як підмножина клієнтського XSS і означення клієнтського XSS через небезпечний JS-виклик.
- OWASP — Cross Site Scripting (XSS) — авторство типу: Amit Klein, 2005.
- CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') — критерій Type 0: інʼєкцію виконує клієнтський застосунок через маніпуляцію DOM.
- OWASP Cheat Sheet — DOM based XSS Prevention — інʼєкція під час виконання в клієнті, складність помʼякшення, відповідальність власника застосунку.
- OWASP Cheat Sheet — Cross Site Scripting Prevention — неефективність серверного фільтра і WAF саме проти DOM-based.
- W3C — Trusted Types — понад 60 сінків інʼєкції та джерела під контролем атакувальника: параметр URL, канал
postMessage. - MDN — Cross-site scripting (XSS) — параметр URL, присвоєний властивості
innerHTML. - OWASP Cheat Sheet — XSS Filter Evasion — мінімальний DOM-приклад: недовірений GET-параметр напряму в
eval.
Контексти виводу: правильного екранування «взагалі» не буває
- OWASP Cheat Sheet — Cross Site Scripting Prevention — мета кодування виводу, правила по контекстах, роль лапок, небезпечні контексти, санітизація й вимога патчити санітайзер, глобальний фільтр як хибний захист.
- OWASP Cheat Sheet — DOM based XSS Prevention — безсилля JS-кодування в контексті виконання, межа сліпого HTML-кодування,
JSON.stringify()і втрата кодування при читанні з DOM. - CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') — кодування виводу як найефективніше рішення, різні кодування в межах одного документа, HTML entity encoding лише для тіла.
- MDN — Cross-site scripting (XSS) — механізм екранування, небезпечні атрибути, санітизація і штатне вимкнення автоекранування в шаблонізаторах.
- OWASP — Types of Cross-Site Scripting — контекстно-залежне кодування виводу на сервері як найсильніший захист і складність правильної валідації вводу.
Сінки: куди саме приїжджає значення
- W3C — Trusted Types — означення сінка інʼєкції та DOM XSS-сінка, відсутність інформації про походження в рядках, понад 60 сінків, група
'script', функції парсингу HTML. - OWASP Cheat Sheet — DOM based XSS Prevention —
setAttributeі небезпечні атрибути, методи з кодом-рядком, неявнийevalпротиJSON.parse(),textContentяк базовий безпечний спосіб і застереження проinnerText. - OWASP Cheat Sheet — Cross Site Scripting Prevention — критерій безпечного сінка й перелік
textContent/value. - OWASP — Types of Cross-Site Scripting — безпечні JavaScript API як найпростіший і найсильніший захист від клієнтського XSS.
- MDN — Cross-site scripting (XSS) — присвоєння значення властивості
innerHTMLяк типовий небезпечний сінк.
Trusted Types: типи замість рядків
- W3C — Trusted Types — суть API, політики й
default-політика, примус черезrequire-trusted-types-forіtrusted-types, режими enforcing і report-only, обрізаний звіт, модель і межі. - OWASP Cheat Sheet — Cross Site Scripting Prevention — заголовок вмикання в Chromium і відхилення звичайних рядків DOM XSS-сінками.
- MDN — Cross-site scripting (XSS) — стан крос-браузерної підтримки Trusted Types.
CSP: другий рубіж, і саме другий
- W3C — Content Security Policy Level 3 — CSP як defense-in-depth, а не перша лінія захисту; застереження проти
'unsafe-inline'і схемиdata:. - OWASP Cheat Sheet — Cross Site Scripting Prevention — місце CSP у порядку захистів, первинна трійка, межі атрибутів cookie й WAF, теза про комбінацію технік.
- MDN — Cross-site scripting (XSS) — строгий CSP на nonce або хеші як рекомендований підхід саме проти XSS.
Пейлоади: що вони доводять і чого не доводять
- OWASP Cheat Sheet — XSS Filter Evasion — жанр набору й теза про неповноту фільтрації, baseline і поліглот-пейлоад, обробники подій і тег
IMG, варіації за тегами й подіями, перелік прийомів обходу. - OWASP — Cross Site Scripting (XSS) —
<body onload=…>,onmouseoverіonerror, форми шкідливого вмісту поза JavaScript, правило «одна вразлива частина — шукай інші». - MDN — Cross-site scripting (XSS) — покроковий розбір вектора через
imgі обробникonerror. - OWASP Cheat Sheet — Cross Site Scripting Prevention — куди веде знайдений обхід: первинні захисти замість латання фільтра.
- OWASP Cheat Sheet — DOM based XSS Prevention — контекст вставки не завжди легко розпізнати.
Що таке XSS і чому цю вразливість класифікують як інʼєкцію?
XSS — дефект, за якого рядок від користувача доїжджає на сторінку в позицію, де браузер розбирає його як код, а не як текст. Окремого пункту в :2025 під нього немає: він живе всередині A05 Injection поруч із . Підстава для сусідства формальна й перевіряється: у дереві слабкість CWE-79 успадкована від класу «Injection» (CWE-74), тобто механізм справді той самий — дані опинилися там, де їх виконують. Профілі при цьому дзеркальні: XSS трапляється часто, а важить менше (понад 30 тисяч ), SQL- навпаки — рідше, зате болючіше (понад 14 тисяч). Живучість дефекту пояснюють дисципліною: розробникові треба нічого не пропустити в кожній точці виводу, а достатньо однієї пропущеної. Для тестувальника з цього випливає обсяг роботи: входами застосунку рахують параметри, заголовки, URL, cookie, JSON, SOAP і XML, а не лише видимі поля форми — і закривають їх ревʼю коду плюс автоматизованими перевірками, зокрема .
Чим збережений XSS відрізняється від відбитого?
Критерієм, за яким пейлоад дістається жертви. Збережений (stored, він же persistent, Type 2) — це два кроки, рознесені в часі: спершу ввід осідає у сховищі (коментар, профіль, лог відвідувань), потім хтось відкриває сторінку, застосунок вичитує ці дані й віддає без безпечної підготовки до рендерингу. Відбитий (reflected, non-persistent, Type 1) вкладається в один цикл «запит-відповідь»: сервер повертає ввід у тій самій відповіді — у результаті пошуку, у повідомленні про помилку — не зробивши його безпечним і при цьому нікуди не поклавши. Ключове слово в означенні відбитого — саме «і не зберегли»: щойно значення осіло, дефект уже інший за типом. У словнику CWE «HTML Injection» — не самостійна слабкість, а ще одна назва , і плутати її з окремою категорією не варто. А наслідок від типу не залежить взагалі: спосіб доставки не робить виконаний у браузері код менш шкідливим.
Чому збережений XSS вважають тяжчим, якщо шкода однакова?
Через аудиторію, а не через силу самого коду. Заражений вміст лежить на сервері й віддається кожному, хто відкриє сторінку, і щоразу, — тобто одна вдала вставка працює на потоці, без жодних додаткових дій атакувальника. Відбитий, навпаки, вимагає доставки: жертву треба привести на підготовлене посилання, і спрацює воно рівно в того, хто клацнув. Звідси й вибір місця інʼєкції: беруть не випадкове поле, а те, що читає багато людей або особливо цінні користувачі. Класичний приклад — лог відвідувань: його наповнює атакувальник, а переглядає адміністратор, тобто акаунт із найширшими правами. Тому «тяжчий» тут означає «шириться сам і б'є по найцікавіших», а не «дозволяє більше».
У тікеті пишуть «це лише reflected, знижуємо пріоритет». Що тут не так?
Аргументу немає: тип фіксує маршрут доставки, а не розмір збитку. Виконаний у браузері жертви код робить те саме незалежно від того, чи він приїхав із бази, чи повернувся в тій самій відповіді, — сама класифікація на цю різницю й вказує, а на важкість наслідку ні. До того ж вирок не остаточний: через забруднення параметрів HTTP (HTTP Parameter Pollution) відбитий дефект здатен перетворитися на збережений, і тоді «низький пріоритет» застаріє за один день. Ще один поворот того самого: коли пейлоад уже осів у сховищі, WAF на шляху запиту атаці більше не завадить — він її просто не побачить. Правильна логіка спирається на досяжний вплив і на те, чиї сесії дістає код, а не на слово «reflected» у назві типу.
У нас вітрина без форм і без логіну, лише читання. Чи може там бути XSS?
Може, і саме ця окремо застережена в джерелах. Відбитому типу форма не потрібна: значення прилітає параметром URL, сервер повертає його в тій самій відповіді, а доставку робить сторонній канал — лист або посилання з іншого сайту. Браузер виконує код тому, що той прийшов від «довіреного» сервера вітрини, і формально небезпечний вміст подала сама жертва — вона ж клацнула посилання. До того ж каналом доставки може бути й CSRF, тож «у нас нічого не приймається» описує лише форми, а не входи. І окремий шар — DOM-based: там значення взагалі не мусить доїхати до сервера, достатньо того, що його прочитає власний скрипт сторінки.
Що таке DOM-based XSS і чому його не знайти пошуком по тілу відповіді?
Це тип (у частині текстів type-0), у якому код спрацьовує через зміну DOM-оточення, з яким працює власний клієнтський скрипт сторінки: скрипт починає поводитися не так, як задумано, і виконує підсунуте значення. Головна прикмета перевертає всю методику: HTTP-відповідь при цьому не змінюється жодним байтом, отже звична перевірка «чи видно пейлоад у відповіді» такого дефекту не побачить ніколи — з тієї самої причини безсилі й серверні фільтри, а WAF тут прямо не рекомендують. Інʼєкція відбувається не під час серверної обробки, а під час виконання, у самому клієнті. Відповідальність від цього на браузер не переїжджає: сторінку з усіма її скриптами віддав сервер, тож обовʼязок зробити цей код безпечним лишається на власникові застосунку. Хрестоматійна ілюстрація — скрипт бере параметр з адресного рядка й кладе його у властивість innerHTML; так само джерелом буває канал postMessage. Шукати доводиться в іншому місці — у самому DOM і в переліку сінків, а не у вихідному тексті відповіді.
Кажуть, типів XSS три. Чому це формулювання неточне?
Бо три назви належать до двох різних осей. Stored і Reflected — вісь доставки: як недовірене значення дісталося до сторінки. Server і Client — вісь місця дефекту: чи потрапили дані в згенеровану сервером відповідь, чи DOM оновив небезпечний JS-виклик уже в браузері. Разом це матриця 2×2, а не список із трьох пунктів: серверний дефект буває і відбитим, і збереженим, і рівно так само подвоюється клієнтський — пейлоад цілком може полежати в базі, а виконатися вже скриптом сторінки. Сам DOM-based у цій системі — підмножина , та, де джерело даних лежить у DOM. На співбесіді сильна відповідь саме тут і відрізняється від слабкої: слабка перелічує три слова, сильна називає критерій кожної осі.
Що таке blind XSS і чому його важко підтвердити?
Це форма персистентного XSS: пейлоад зберігається на сервері, а повертається жертві вже з бекенд-застосунку — з адмінки, системи тікетів, панелі перегляду логів. Той, хто вставив значення, результату не бачить: у його інтерфейсі не відбувається нічого, спрацювання відбувається в чужому вікні й, можливо, за кілька годин. Через це звичайний «подивитися відповідь» не працює, а джерела чесно обмежуються констатацією, що підтвердити такий дефект у реальному сценарії важко, і покрокової процедури не дають. Практичний висновок для QA: місця, де ввід одного користувача читає інший — особливо адміністратор, — не можна перевіряти лише з боку того, хто вводить.
Що таке контекст виводу і чому «ми екрануємо все» — не відповідь?
Контекст — це частина документа, у яку значення: тіло HTML, значення атрибута, вбудований JavaScript, CSS, URL. Браузер розбирає ці частини за різними правилами, тому й методів кодування кілька, і вони не взаємозамінні: обраний навмання або зламає функціональність, або залишить діру. Тіло HTML закривається HTML entity encoding, значення атрибута — HTML attribute encoding і то лише для атрибутів з окремого переліку безпечних, у вбудованому JS єдине умовно безпечне місце — рядкове значення в лапках із символами у форматі \xHH, у CSS змінну кладуть тільки у значення властивості, а в URL усі символи переводять у %HH. Лапки тут не косметика, а частина захисту: із них важче вискочити, та й перелік символів, які доводиться кодувати, вони скорочують. Коли контексти вкладені, методи накладають послідовно: URL усередині атрибута спочатку переводять у відсоткове кодування, а вже потім застосовують кодування для атрибута. Тому фраза «ми екрануємо все» без назви методу й місця не означає нічого — питання завжди «чим саме і де».
Чому кодування виводу вважають сильнішим захистом, ніж валідацію вводу?
Бо воно працює там, де народжується дефект, — у точці, де значення стає частиною документа. Кодування перетворює недовірений рядок на форму, у якій браузер показує його як дані й не виконує як код; саме контекстно-залежне на сервері названо найпростішим і найсильнішим захистом від у більшості випадків. таким статусом не володіє: вона додає ще один шар, але зробити її правильно значно складніше, а головне — на вході ще не відомо, у який контекст поїде значення. Звідси й приреченість глобального фільтра: у момент фільтрації контекст ще невідомий, а HTML- і JS-кодування одне одного не заміняють. У ту саму категорію падають суцільне HTML-кодування без огляду на місце вставки — при типі вмісту text/xhtml воно може й не спрацювати — і JSON.stringify(), який кодуванням виводу не є взагалі. І покривати треба кожну змінну, що йде у вивід, а не лише ті, які здалися підозрілими.
Чи є місця, куди не можна підставляти змінну навіть закодовану?
Так, і їх називають . У контексті виконання закодований рядок усе одно виконається — джерело формулює це прямо: JavaScript-кодований рядок виконається попри те, що він закодований. Практично це означає, що вставка недовіреного значення всередину script чи style майже завжди небезпечна, і правильна дія — не підставляти туди змінних узагалі, а не шукати «правильне екранування». Сюди ж належать атрибути-обробники подій: усе, що приймає JavaScript, — onclick, onblur — з недовіреним значенням небезпечне в принципі, і MDN додає до переліку src елемента iframe. Ще одна пастка з цієї родини — втрата кодування при читанні: значення, вичитане з DOM через атрибут value, приходить уже розкодованим, і захист злітає.
Коли замість кодування застосовують санітизацію і чим вона ризикована?
Коли користувач за задумом має право писати HTML — редактор статті, опис товару, коментар із форматуванням. Кодування там зламає саму функціональність: замість жирного тексту читач побачить теги, тож на цьому місці застосовують санітизацію — вирізають небезпечний HTML і повертають безпечний, зокрема прибирають теги script і вбудовані обробники подій. Ризик у тому, що санітайзер — жива залежність, а не разова настройка: обходи до нього знаходять регулярно, тому його треба так само регулярно оновлювати. Для QA звідси два наслідки: по-перше, версія санітайзера — предмет перевірки нарівні з кодом; по-друге, «у нас є санітайзер» не закриває питання про решту точок виводу, де ніякого HTML користувач писати не мав.
Що таке сінк інʼєкції і чому виклик innerHTML сам собою ще не дефект?
(injection sink) — це така сильна функція платформи, у яку годиться передавати лише те, чому довіряють: значення перевірене або пропущене через санітизацію. Різновид, у якому недовірений рядок обертається саме клієнтською , називають . Дефект визначається не назвою функції, а рядка, що в неї приїхав: той самий innerHTML із константою в шаблоні нікому не шкодить. Проблема в тому, що з коду це не видно — рядок не несе жодної інформації про своє походження, тож застосунки регулярно передають у сінк контрольовані атакувальником значення, навіть не усвідомлюючи цього. Саме тому ревʼю у стилі «пошукаємо всі виклики innerHTML» вердикту не дає, а платформний захист будують на типах, а не на пошуку рядків. Для тестувальника корисно інше: перелік сінків скінченний і відомий наперед — їх понад 60, — тож інвентаризація місць, куди взагалі може приїхати недовірене значення, це робота з видимим кінцем.
Чому textContent вважають безпечним, а innerText радять із застереженням?
Критерій простий: значення в ньому лишається текстом і не виконується ніколи. textContent цьому критерію відповідає й тому названий базовим способом покласти недовірені дані в DOM; правильний фікс клієнтського XSS — саме замінити сінк на textContent або value, а не докрутити екранування перед небезпечним викликом. З innerText складніше: родина шпаргалок OWASP щодо нього розходиться — одна попереджає, що для окремих тегів виконання все ж можливе, друга просто не згадує його серед безпечних сінків. Тобто спільної позиції за ним немає, і як «просту заміну innerHTML» його радити не варто. Окремо стоїть eval: безпечного аналога для нього не називають узагалі — недовірене значення там шкідливе за будь-яких обставин, тож правильна дія не санітизувати аргумент, а не робити такого виклику. До тієї ж родини належать методи, яким код передають рядком, — setTimeout, setInterval, new Function, — а для розбору JSON беруть JSON.parse(), який відкидає все, що валідним JSON не є.
Де в застосунку найдешевше почати пошук XSS?
Там, де автоекранування шаблонізатора вимкнене штатно, — це найдешевша точка входу для тестувальника. У Django таке місце позначає фільтр safe, у React — dangerouslySetInnerHTML: обидва означають «я свідомо віддаю сюди сирий HTML», і саме там варто дивитися, звідки береться значення. Другий за дешевизною крок — інвентаризація сінків: сетери innerHTML і outerHTML, Document.write, setAttribute з атрибутом-обробником події на кшталт onclick. Третій — місця, де ввід одного користувача читає інший: коментарі, профілі, назви файлів, панель адміністратора. Такий порядок економить час, бо перелік місць скінченний, тоді як перелік пейлоадів — ні.
Що роблять Trusted Types і чому наявність заголовка ще не означає «увімкнено»?
— API, після вмикання якого небезпечні функції платформи перестають приймати звичайний рядок і чекають типізований обʼєкт, підробити який не вийде. Єдиний спосіб такий обʼєкт отримати — прогнати рядок через політику, оголошену самим застосунком і незмінну після оголошення; завдяки цьому перевіряти доводиться кілька невеликих ділянок коду, а не весь фронтенд. Слово «trusted» тут скромніше, ніж здається: воно фіксує рівно те, що автор коду ручається за це значення, — нічого механізм не чистить, уся його сила в непідробності. Примус вмикають через CSP: директива require-trusted-types-for перелічує групи сінків, де без типу вже не обійтися, а trusted-types визначає, які політики взагалі дозволено створювати. Пастка, через яку заголовок буває декоративним, — політика на імʼя default: браузер підключає її сам, без явного виклику, щойно в сінк приходить нетипізований рядок, і якщо це заглушка, яка повертає рядок як є, механізм працює вхолосту — небезпека просто концентрується в цій одній політиці. Тому перевірка «заголовок є» нічого не доводить: треба дивитися склад директив і те, чи є в списку default. Так само нічого не доводить зелений результат у режимі report-only: там порушення лише фіксується, а початкове значення спокійно йде далі; а сам звіт приносить урізаний пейлоад — разом з іменем сінка це всього 40 символів.
Чи достатньо CSP, щоб закрити XSS?
Ні, і це прямо записано в специфікації: CSP задумана як , а не як перша лінія проти інʼєкції вмісту. OWASP каже те саме прикладніше: політика — це перелік дозволеного, а зіпсувати такий перелік при впровадженні легко, тому роль головного механізму йому не дають; головними лишаються засоби самого фреймворку, кодування виводу та санітизація розмітки. Коли політику пишуть цілеспрямовано проти XSS, радять строгий варіант на nonce чи хешах — вони повідомляють браузеру, які саме скрипти в документі легітимні; протилежний бік тієї самої поради — не вписувати в дозволені джерела 'unsafe-inline' і схему data:. Сусідні шари мають такі самі межі: атрибути cookie зменшують збитки, але код на сторінці вони не зупиняють, а WAF ненадійний — жоден з них причини не усуває. Тому правильна відповідь на співбесіді звучить як «комбінація», а не як назва одного заголовка.
Що доводить спрацьований alert(1) і що доводить його відсутність?
Спрацювання доводить факт виконання коду в цьому конкретному місці, у цьому контексті й для цього типу доставки — і цього достатньо, щоб заводити дефект. А от відсутність алерту не доводить нічого, крім того, що ця конкретна проба не спрацювала. Причин може бути три: значення поїхало не в тіло HTML, а в атрибут, усередину рядка в скрипті або в URL, і потрібен інший зонд; результат видно не тут, а там, де дані читає інший користувач; або тіло відповіді взагалі не змінюється, бо весь сюжет відбувається в DOM. Плюс сам пейлоад тегом script не обмежений: зонд може складатися з одного обробника події, окремий шлях відкриває поблажливість до вмісту тега IMG, а обробник підставляється до тега будь-якого типу з будь-якою придатною подією — звідси й безліч варіацій тієї самої інʼєкції. Тому канонічна шпаргалка пейлоадів — це посібник із тестування, який демонструє неповноту фільтрації вводу, а не матеріал для . І знайдений обхід лікують не латанням фільтра, а кодуванням виводу й санітизацією.
Як ви побудуєте перевірку форми коментарів на XSS?
Спершу визначу входи: не лише текст коментаря, а й приховані поля, параметри URL, заголовки й cookie — усе, що доїжджає до запиту. Потім знімаю базову відповідь на нешкідливому маркері (унікальний рядок без спецсимволів) і дивлюся у вихідному коді, у який саме контекст він сів: тіло, атрибут, рядок у скрипті чи URL — від цього залежить, який зонд взагалі має сенс. Далі одна проба за раз, під конкретний контекст, і обовʼязково три місця перегляду результату: та сама відповідь для відбитого, сторінка, де коментар читає інший користувач, — для збереженого, і DOM у DevTools — для клієнтського, бо там тіло відповіді не змінюється. Окремо перевіряю другий рубіж: заголовки CSP і Trusted Types, склад директив, а не сам факт наявності заголовка. Активні проби роблю лише на стенді, на який є погодження команди: той самий крок без погодження перетворює тестувальника на атакувальника за означенням. І якщо обхід знайшовся в одному місці, це привід шукати далі: одна дірява ділянка сайту зазвичай означає, що поруч знайдуться інші.
Три кейси з робочого життя: як за пʼять хвилин довести, що тікет «перевірили поле, алерту немає» нічого не перевірив; як зловити , якої немає у відповіді сервера; і як зробити на , у якому страждає не той, хто вводив. Скрізь — що дивитися і чому саме це.
Активні проби — це вже дія проти застосунку, а не спостереження. Їхнє місце — стенд, на який є погодження команди: той самий крок без погодження перетворює тестувальника на атакувальника за означенням.
Кейс 1. «Алерту немає» — визначаємо контекст, а не вгадуємо пейлоад
Тікет закрили з коментарем «вставили <script>alert(1)</script> у пошук, вікна немає, поле чисте». Проблема в порядку дій: пейлоад підібрали до того, як зʼясували, куди взагалі їде значення. Робочий порядок зворотний — спершу контекст, потім зонд.
Крок перший — нешкідливий маркер замість пейлоада. Подаємо рядок, який точно не зламає розмітку й легко шукається: zzqa1337. Крок другий — дивимось не на екран, а у вихідний код сторінки (Ctrl+U, на macOS — Cmd+Option+U, або вкладка Sources), бо в панелі Elements браузер показує вже нормалізований DOM, а нам потрібен саме той текст, який згенерував сервер. Далі шукаємо маркер і читаємо, що стоїть навколо нього.
| Маркер у вихідному коді виглядає так | Контекст | Що з ним робити далі |
|---|---|---|
Нічого не знайдено за запитом zzqa1337 | тіло HTML між тегами | пробуємо, чи будується новий елемент: <b>zz</b>qa1337 |
<input value="zzqa1337"> | значення атрибута | перевіряємо вихід із лапок: zz" data-probe="1 |
var q = "zzqa1337"; | рядок у вбудованому скрипті | перевіряємо вихід із рядка: zz";window.qaProbe=1;// |
<a href="/search?q=zzqa1337"> | URL | перевіряємо схему: javascript: замість шляху |
| маркера немає ніде | значення або не виводиться, або живе тільки в DOM | далі за кейсом 2 |
Тепер видно, чому вихідний тікет нічого не довів: у трьох рядках із пʼяти тег script не мав шансу спрацювати за побудовою — значення сиділо в атрибуті, у рядку скрипта чи в URL, і туди потрібен зовсім інший зонд. Ще й самих зондів набагато більше, ніж здається: вставка обробника події працює з будь-яким тегом і будь-якою релевантною подією, а окремий вектор дає поблажливе ставлення до вмісту тега IMG — звідси класика <img src=x onerror=alert(1)>, де обробник викликає саме те, що src не завантажився.
Що дивитися і чому:
- Контекст визначає зонд, а не навпаки. Один пейлоад перевіряє одну позицію вставки; «нічого не сталося» означає рівно те, що ця проба не спрацювала в цьому місці.
- Вихідний код і панель Elements — різні джерела. Elements показує DOM після роботи скриптів, тож там маркер може зʼявитися й тоді, коли сервер його не віддавав, — це вже інший тип, і плутати ці два спостереження не можна.
- Маркер безпечніший за
alert(1)для інвентаризації. Він не ламає верстку, шукається грепом у відповіді й дає відповісти на головне питання — куди саме доїхало значення. - Помилка «поле чисте» коштує дорого саме тому, що виглядає як результат. Правильне формулювання у тікеті — «перевірено тіло HTML на сторінці результатів, інші контексти й типи доставки не перевірялися».
Кейс 2. Відповідь порожня, а вразливість є: DOM-based
Автотест шукає маркер у тілі відповіді й не знаходить його, сканер теж мовчить — а на екрані видно, що значення з адресного рядка потрапило на сторінку. Це і є прикмета DOM-based: сервер віддає ту саму розмітку, а всю роботу робить власний скрипт сторінки.
// винуватець: рядок з адресного рядка потрапляє у функцію, що розбирає HTML
const q = new URLSearchParams(location.search).get('q') ?? '';
document.querySelector('#search-summary').innerHTML = 'Ви шукали: ' + q;
Перевірка будується на різниці двох джерел: тіло HTTP-відповіді проти фактичного DOM. Зонд теж інший — замість alert(1), який залежить від діалогу й таймінгів, подаємо маркер, з якого браузер мусив би зібрати елемент. Якщо елемент зʼявився в DOM — рядок розібрано як розмітку, і це вже вердикт.
import { test, expect } from '@playwright/test';
// зонд: якщо значення парситься як HTML, у DOM зʼявиться вузол із цим атрибутом
const PROBE = '<b data-xss-probe="1">zzqa1337</b>';
test('значення з параметра URL лишається текстом, а не стає розміткою', async ({ page }) => {
const url = `https://app.example.com/search?q=${encodeURIComponent(PROBE)}`;
const response = await page.goto(url);
const serverHtml = (await response!.text());
// 1) сервер міг і не бачити пейлоада — це нормально й нічого не спростовує
test.info().annotations.push({
type: 'note',
description: serverHtml.includes('data-xss-probe')
? 'маркер є у відповіді сервера — дивимось і серверний тип теж'
: 'у відповіді маркера немає: далі вердикт виносить лише DOM',
});
// 2) справжній оракул для DOM-based: чи зібрався елемент у DOM
await expect(page.locator('[data-xss-probe]')).toHaveCount(0);
// 3) текст при цьому має відобразитися — інакше ми перевірили нефункціональне поле
await expect(page.getByText('zzqa1337')).toBeVisible();
});
Фікс, який має приїхати у відповідь на такий дефект, — не екранування перед innerHTML, а заміна самого сінка:
document.querySelector('#search-summary').textContent = 'Ви шукали: ' + q;
Що дивитися і чому:
- Тіло відповіді тут не . Воно не змінюється, тому
expect(body).not.toContain(payload)при DOM-based буде вічнозеленим і створить хибне відчуття . - Асерт на кількість вузлів надійніший за очікування діалогу.
page.on('dialog')ловить лише пейлоади, що дійсно викликалиalert, і мовчить там, де код виконався б інакше; наявність зібраного елемента доводить факт парсингу розмітки без жодного виконання. - Третій асерт — не формальність. Без нього тест лишиться зеленим і на полі, яке взагалі перестало виводити значення: перевіряти безпеку неробочої фічі немає сенсу.
- Фіксом вважається зміна сінка.
textContentкладе рядок як текст і не виконує його за жодних умов, тоді як спроби «правильно екранувати» передinnerHTMLлишають дефект живим до першого недоглянутого символу.
Кейс 3. Збережений XSS: вводить один, страждає інший
Форма коментаря пройшла перевірку, бо тестувальник дивився на власний екран після відправки. Але критерій збереженого типу інший: значення осідає у сховищі, а виконується там, де його читають — на сторінці статті, у стрічці модерації, у панелі адміністратора. Тому регрес будують на двох ролях у двох різних .
import { test, expect } from '@playwright/test';
const PROBE = '<b data-xss-probe="1">zzqa1337</b>';
test('коментар не стає розміткою в читача', async ({ browser }) => {
// 1) автор публікує коментар із зондом
const author = await browser.newContext({ storageState: 'auth/author.json' });
const authorPage = await author.newPage();
await authorPage.goto('https://app.example.com/articles/42');
await authorPage.getByTestId('comment-input').fill(PROBE);
await authorPage.getByTestId('comment-submit').click();
await expect(authorPage.getByText('zzqa1337')).toBeVisible();
// 2) інший користувач відкриває ту саму сторінку — саме тут і виконався б код
const reader = await browser.newContext({ storageState: 'auth/reader.json' });
const readerPage = await reader.newPage();
await readerPage.goto('https://app.example.com/articles/42');
await expect(readerPage.locator('[data-xss-probe]')).toHaveCount(0);
await expect(readerPage.getByText('zzqa1337')).toBeVisible();
// 3) і в модерації, куди той самий коментар потрапляє окремим шляхом
const admin = await browser.newContext({ storageState: 'auth/admin.json' });
const adminPage = await admin.newPage();
await adminPage.goto('https://app.example.com/admin/moderation');
await expect(adminPage.locator('[data-xss-probe]')).toHaveCount(0);
await Promise.all([author.close(), reader.close(), admin.close()]);
});
Окремим тестом перевіряють другий рубіж — заголовки. Тільки формулювати асерти треба так, щоб вони ловили саме декоративні конфігурації:
test('заголовки другого рубежу справді в режимі примусу', async ({ request }) => {
const res = await request.get('https://app.example.com/articles/42');
const csp = res.headers()['content-security-policy'];
// report-only не примушує ні до чого: порушення лише фіксується
expect(csp, 'політики в режимі примусу немає').toBeTruthy();
expect(csp).not.toContain("'unsafe-inline'");
expect(csp).toContain("require-trusted-types-for 'script'");
// default-політика пропускає звичайні рядки крізь себе
const tt = csp.match(/trusted-types ([^;]+)/)?.[1] ?? '';
expect(tt.split(/\s+/), 'у списку політик є default — примус декоративний')
.not.toContain('default');
});
Що дивитися і чому:
- Один контекст браузера перевіряє тільки автора. Збережений тип відрізняється саме роздвоєнням ролей у часі: пейлоад лежить у сховищі й віддається кожному читачеві, тож без другої ролі тест не покриває головне.
- Панель адміністратора — окремий шлях виводу, а не той самий. Модерація часто рендериться іншим шаблоном; саме на такій різниці й тримається , коли автор вставки взагалі не бачить результату.
Content-Security-Policy-Report-Onlyу зеленому тесті — типова пастка. Асерт, який приймає будь-який із двох заголовків, зафіксує «захист є» там, де насправді лише збирається статистика порушень.defaultу списку політик зводить нанівець. Браузер викликає її неявно на кожен звичайний рядок, тож наявність заголовка без перевірки складу директив ще нічого не означає.- Зелений регрес не заміняє пошуку. Він фіксує, що знайдений дефект не повернувся в цих трьох точках виводу; висновок «у застосунку немає XSS» із нього не випливає — обхід в одному місці якраз і є приводом шукати далі.
Типи й доставка
- Можу пояснити, чому XSS не отримав власного рядка в :2025 і опинився всередині A05 Injection: у дереві слабкість CWE-79 успадкована від класу «Injection» (CWE-74), а профілі різні — XSS часто й слабше (понад 30 тисяч ), рідше й важче (понад 14 тисяч).
- Знаю критерій (stored, persistent, Type 2): ввід осідає у сховищі, а потім потрапляє до читача так і не підготовленим до безпечного рендерингу; «HTML Injection» у словнику CWE — його синонім, а не окрема слабкість.
- Знаю критерій (reflected, non-persistent, Type 1) — два заперечення разом: значення вернулося в тій самій відповіді, безпечним його не зробили і нікуди не поклали.
- Розумію, що наслідок від типу не залежить, тому «це лише reflected» пріоритету не знижує, а через забруднення параметрів HTTP відбитий дефект здатен стати збереженим.
- Не купуюся на «у нас сайт лише для читання» — привести жертву на підготовлене посилання можна й без жодної форми на нашому домені.
- Знаю, що три назви не стоять в один ряд: Stored/Reflected відповідає на питання про доставку, Server/Client — про місце дефекту, перетин цих осей дає чотири комбінації, а DOM-based сидить усередині .
DOM-based і клієнтський бік
- Розумію головну прикмету : тіло HTTP-відповіді не змінюється, тож пошук пейлоада у відповіді такого дефекту не побачить ніколи, серверний фільтр і WAF проти нього безсилі, а джерелом буває параметр URL чи канал
postMessage. - Памʼятаю, що тут відбувається під час виконання в клієнті, але відповідальність лишається на власнику застосунку — сторінку з усіма скриптами усе одно віддав сервер.
Кодування виводу й контексти
- Можу пояснити, чому контекстно-залежне сильніше за , і чому глобальний фільтр на вході, суцільне HTML-кодування без огляду на місце вставки та
JSON.stringify()кодуванням виводу не є. - Знаю метод під кожен контекст: HTML entity encoding у тілі, HTML attribute encoding у значенні атрибута (і лише для безпечного переліку), рядок у лапках із
\xHHу вбудованому JS, тільки значення властивості в CSS,%HHв URL — а лапки при цьому частина захисту, і вкладені контексти кодують послідовно. - Знаю , де кодування не рятує: атрибути-обробники подій (
onclick,onblur),srcелементаiframe, вставка всерединуscriptчиstyle. - Можу пояснити, коли потрібна санітизація замість кодування (користувач має право писати HTML), чому санітайзер треба регулярно оновлювати, і де автоекранування вимкнене штатно — у React це
dangerouslySetInnerHTML, у Django — фільтрsafe.
Сінки
- Знаю означення та критерій : значення в ньому лишається текстом і не виконується ніколи.
- Розумію, чому сам виклик дефектом не є: рядок нічого не повідомляє про своє , тож питання завжди в тому, звідки він приїхав, а перелік сінків скінченний (понад 60).
- Можу перелічити небезпечні сінки — сетери
innerHTMLіouterHTML,Document.write,setAttributeз атрибутом-обробником події,setTimeout,setInterval,new Function, неявнийeval— і знаю, що фікс полягає в заміні сінка наtextContentабоvalue, тоді як щодоinnerTextшпаргалки OWASP розходяться.
Другий рубіж і тріаж
- Можу пояснити, що замикають небезпечні API на типізовані непідробні значення, і знаю пастку
default-політики: браузер підключає її сам, щойно в сінк приходить нетипізований рядок, тож заглушка зводить механізм нанівець. - Розумію, чому зелена перевірка в режимі report-only ще ні про що не свідчить і чому у звіті пейлоад приходить урізаним — разом з іменем сінка там усього 40 символів.
- Памʼятаю, що CSP — , а не перша лінія: головними названо засоби фреймворку, кодування виводу та санітизацію розмітки, а проти XSS радять на nonce або хешах без
'unsafe-inline'і схемиdata:. - Розумію, що відсутність алерту не доводить нічого: перевірено один зонд в одному контексті, а сам пейлоад тегом
scriptне обмежений — працюють обробники подій (onerror,onmouseover) і будь-який тег із придатною подією. - Знаю, де шукати результат за типом: відбитий — у тій самій відповіді, збережений — там, де дані читає другий користувач, DOM-based — тільки в DOM, а повертається жертві вже з бекенд-застосунку.
Квіз
Перед стартом
- Питань: 16
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
Збережений XSS (stored, Type 2) — за яким критерієм його визначають?