flawopen.com/xss/Javascript
XSS в JavaScript: защита DOM, React и Express
Используйте текстовые API, очищайте HTML, проверяйте URL и включайте Trusted Types для DOM/React (CWE-79).
Представьте пульт управления сценой. Если помощник режиссёра принимает каждую записку зрителя за сценическую команду, она может добавить неожиданный эпизод; если показать её как обычную реплику, она останется текстом. В браузере 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. Новые приёмники не проскочат незамеченными.
Связи со стандартами
- Ссылка CWE для слабого места, описанного в этом руководстве: CWE-79
- OWASP Top 10:2025 — A05:2025 Injection
Пошаговый сценарий атаки
Ввод попадает в приёмник
Поисковый запрос, поле профиля из API или ссылка, сохранённая другим пользователем, приходит строкой и передаётся в innerHTML, dangerouslySetInnerHTML, href или res.send().
Значение становится разметкой
С безобидным тестовым значением <img src=x onerror=alert(1)> вызовы innerHTML и res.send() создают настоящий элемент <img>; сохранённая ссылка javascript:alert(1) превращается в кликабельный скрипт.
Браузер его выполняет
Картинка не загружается и вызывает onerror, либо пользователь кликает по ссылке. Код выполняется сразу, без каких-либо дальнейших действий атакующего.
Скрипт действует от имени сайта
Он работает с источником (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>;
}
Чек-лист по защите системы для инженеров
- ✓Включите
react/no-dangerиeslint-plugin-no-unsanitizedв CI и блокируйтеinnerHTML,outerHTML,insertAdjacentHTMLиdocument.writeс нелитеральными значениями. - ✓Для текста используйте
textContent; любой HTML, который нужно показать, пропускайте черезDOMPurify.sanitize()в месте вставки. - ✓Проверяйте каждый URL от пользователя через
new URL()и белый списокhttp:/https:до того, как он попадёт вhref,srcилиwindow.location. - ✓В Express рендерите через движок представлений с автоэкранированием или
escape-htmlи отклоняйте нестроковые параметры query. - ✓Отправляйте CSP с
script-srcна основе nonce, без'unsafe-inline'и сrequire-trusted-types-for 'script'; храните токены сессии в cookieHttpOnly, а не вlocalStorage.