FlawOpen

flawopen.com/xss/Javascript

● CWE-79 · Высокий
Исследования · FlawOpen

XSS в JavaScript: защита DOM, React и Express

Используйте текстовые API, очищайте HTML, проверяйте URL и включайте Trusted Types для DOM/React (CWE-79).

💡 Простыми словами (ELI5)

Представьте пульт управления сценой. Если помощник режиссёра принимает каждую записку зрителя за сценическую команду, она может добавить неожиданный эпизод; если показать её как обычную реплику, она останется текстом. В браузере innerHTML и React dangerouslySetInnerHTML просят DOM разобрать переданный текст как разметку, а textContent показывает его буквально. Экранируйте обычный текст, очищайте только разрешённый HTML и проверяйте схемы URL перед созданием ссылок.

Ключевые понятия и термины

innerHTML vs textContent
Присваивание в innerHTML, outerHTML или insertAdjacentHTML заставляет браузер разобрать строку как HTML, и <img onerror> в ней становится живым элементом. textContent и createTextNode вставляют только текст; ничто в значении не разбирается как разметка.
Template literals in res.send()
res.send() в Express по умолчанию отправляет строки как text/html. Шаблонная строка вычисляет ${req.query.q} до отправки, поэтому ответ содержит всё, что пользователь положил в query string. req.query.q может быть и массивом или объектом, так что проверяйте тип.
dangerouslySetInnerHTML + DOMPurify
React автоматически экранирует текст в JSX; dangerouslySetInnerHTML — явный отказ от этого. Пропускайте значение через DOMPurify.sanitize(): он разбирает его собственным парсером браузера и удаляет скрипты, обработчики событий и опасные URL до того, как React его вставит.
javascript: URLs in href
JSX экранирует значение атрибута, но href="javascript:..." — корректный URL, который выполняет код при клике. Разбирайте его через new URL() и разрешайте только http: и https:; парсер URL убирает начальные пробелы и табуляции, которые пропускают простые проверки префикса.
Trusted Types
С директивой CSP require-trusted-types-for 'script' поддерживающие браузеры отвергают обычные строки в приёмниках вроде innerHTML и принимают только объекты TrustedHTML, созданные заданной вами политикой, обычно вызывающей DOMPurify. Новые приёмники не проскочат незамеченными.

Связи со стандартами

Пошаговый сценарий атаки

Шаг 1

Ввод попадает в приёмник

Поисковый запрос, поле профиля из API или ссылка, сохранённая другим пользователем, приходит строкой и передаётся в innerHTML, dangerouslySetInnerHTML, href или res.send().

Шаг 2

Значение становится разметкой

С безобидным тестовым значением <img src=x onerror=alert(1)> вызовы innerHTML и res.send() создают настоящий элемент <img>; сохранённая ссылка javascript:alert(1) превращается в кликабельный скрипт.

Шаг 3

Браузер его выполняет

Картинка не загружается и вызывает onerror, либо пользователь кликает по ссылке. Код выполняется сразу, без каких-либо дальнейших действий атакующего.

Шаг 4

Скрипт действует от имени сайта

Он работает с источником (origin) страницы: читает DOM, вызывает API сайта с cookie жертвы или токенами из localStorage и меняет то, что видит пользователь.

Исходный код: Уязвимый vs Защищённый вариант

УЯЗВИМАЯ РЕАЛИЗАЦИЯ
// profile.jsx: каждая функция ниже позволяет разобрать строку как HTML
import express from 'express';

const app = express();

// Express: значение из query попадает в HTML до отправки
app.get('/search', (req, res) => {
  res.send(`<h1>Results for ${req.query.q}</h1>`);
});

// innerHTML разбирает name как разметку
export function showGreeting(el, name) {
  el.innerHTML = `Hello, ${name}`;
}

// React вставляет HTML ровно в том виде, в каком он пришёл
export function Bio({ html }) {
  return <div className="bio" dangerouslySetInnerHTML={{ __html: html }} />;
}

// URL javascript: выполняется при клике по ссылке
export function Website({ url }) {
  return <a href={url}>Website</a>;
}
БЕЗОПАСНЫЙ ИСПРАВЛЕННЫЙ ВАРИАНТ
// profile.jsx: текст остаётся текстом; HTML и URL проверяются заранее
import DOMPurify from 'dompurify';
import escapeHtml from 'escape-html';
import express from 'express';

const app = express();

// принимаются только строки, и они экранируются как HTML
app.get('/search', (req, res) => {
  const q = typeof req.query.q === 'string' ? req.query.q : '';
  res.send(`<h1>Results for ${escapeHtml(q)}</h1>`);
});

// textContent никогда не разбирает разметку
export function showGreeting(el, name) {
  el.textContent = `Hello, ${name}`;
}

// DOMPurify удаляет скрипты и обработчики событий до вставки
export function Bio({ html }) {
  return <div className="bio" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}

// остаются только URL http и https, всё прочее становится #
function safeHref(url) {
  try {
    const parsed = new URL(url, window.location.origin);
    return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : '#';
  } catch {
    return '#';
  }
}

export function Website({ url }) {
  return <a href={safeHref(url)}>Website</a>;
}

Чек-лист по защите системы для инженеров

Источники