XSS, опасный HTML, URL и точки записи в DOM, CSRF, токены, цепочка поставки зависимостей, CSP и недоверенные серверные действия.
React безопасно экранирует обычный текст, но приложение остаётся браузерной системой с недоверенными данными, зависимостями и серверными командами.
Вы построите модель угроз, закроете опасные места вывода и проверите серверную авторизацию, CSRF и риски цепочки поставки.
Понадобятся JSX, формы и действия, маршрутизация, серверные данные и модель безопасности браузера. Итог урока — разбор безопасности комментариев с очисткой HTML, безопасными URL, сеансом через BFF и регрессионными тестами.
Обычная строка в тексте или атрибуте JSX экранируется. Но у HTML, URL, CSS и скрипта разные правила безопасности. Не заменяйте < вручную; используйте безопасный API и проверку по списку разрешённого (allowlist).
Одна и та же строка ведёт себя по-разному в зависимости от места вывода:
const value = '<img src=x onerror="alert(1)">';
<p>{value}</p> // ✅ выводится как текст, тег не создаётся
<div dangerouslySetInnerHTML={{ __html: value }} /> // ❌ создаётся элемент, срабатывает onerror
<a href={value}>ссылка</a> // ❌ другой контекст, другие правилаЭкранирование React работает только для текстового контекста и значений атрибутов. Функция, заменяющая < на <, ничем не поможет в контексте URL: javascript:alert(1) не содержит угловых скобок вообще. Поэтому правило одно — под каждый контекст свой безопасный API.
Проверьте себя. Передайте вредоносную строку как текст и в опасное место вывода, зафиксируйте разницу.
Частая ошибка. Считать одну функцию экранирования подходящей для всех контекстов.
dangerouslySetInnerHTML обходит экранирование React. Используйте его только для осознанного HTML после очистки (sanitization) по списку разрешённого, настроенному под конкретную модель угроз. Очищенное значение нельзя затем изменять небезопасными расширениями. Trusted Types и CSP усиливают эту границу.
Очистка выполняется библиотекой с явным списком разрешённых тегов и атрибутов:
// src/features/comments/CommentBody.tsx
const safeHtml = useMemo(
() =>
DOMPurify.sanitize(markdownToHtml(comment.body), {
ALLOWED_TAGS: ['p', 'a', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'title'],
}),
[comment.body],
);
return <div dangerouslySetInnerHTML={{ __html: safeHtml }} />;Самодельная проверка регулярным выражением закрывает один вектор из сотни:
// ❌ проходит <img src=x onerror=...>, <svg onload=...>, вложенные и закодированные варианты
const unsafe = html.replace(/<script[\s\S]*?<\/script>/gi, '');Порядок шагов важен: сначала преобразование Markdown в HTML, затем очистка. Если очистить сначала, а потом дописать разметку — например, добавить подсветку кода небезопасным расширением, — гарантия теряется.
Проверьте себя. Очистите HTML из Markdown и проверьте нагрузки с img, onerror и svg.
Частая ошибка. Regex удаляет только <script>.
Недоверенный href проверяют через URL и список разрешённых схем и узлов. Для внутренней навигации ограничивают источник и путь; адрес перенаправления не принимает произвольный внешний URL. javascript: и опасные data: исключаются. Текст ссылки должен осмысленно описывать назначение.
Проверка внешней ссылки — разбор через URL и сравнение схемы со списком:
// src/shared/security/url.ts
const SAFE_SCHEMES = new Set(['http:', 'https:', 'mailto:']);
export function toSafeHref(value: string): string | null {
try {
const url = new URL(value, window.location.origin);
return SAFE_SCHEMES.has(url.protocol) ? url.toString() : null;
} catch {
return null; // не разобралось — значит не ссылка
}
}Внутреннее перенаправление проверяют иначе: не «какая схема», а «остались ли мы у себя»:
export function toInternalPath(value: string): string {
const url = new URL(value, window.location.origin);
// проверяем именно origin: строка "//evil.com/x" разбирается как внешний адрес
return url.origin === window.location.origin ? url.pathname + url.search : '/';
}Проверка вида value.startsWith('/') пропускает //evil.com/x, а JavaScript: с заглавными буквами и %6a%61vascript: обходят сравнение подстрок. Разбор через URL нормализует все эти варианты.
Проверьте себя. Проверьте закодированную схему и URL, начинающийся с //.
Частая ошибка. href={userValue} без правила проверки.
Токен в localStorage доступен JavaScript и может быть украден при XSS. Cookie с HttpOnly скрыта от JavaScript, но браузер отправляет её автоматически, поэтому нужны SameSite и защита от CSRF. Выбор зависит от архитектуры; не записывайте учётные данные в журнал и не помещайте секреты в клиентский пакет.
Сравнение удобно держать перед глазами при проектировании сеанса:
| Хранение | Крадётся при XSS | Отправляется автоматически | Что обязательно |
|---|---|---|---|
localStorage | да, любым скриптом | нет | строгая CSP, очистка вывода |
Cookie HttpOnly | нет | да | SameSite, защита от CSRF, Secure |
Для варианта с cookie сервер-посредник (BFF) выставляет её сам, и клиентский код токена вообще не видит:
// BFF: браузер получает только cookie, токен провайдера остаётся на сервере
response.cookies.set('session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 8,
});Подпись JWT доказывает, что содержимое не подменяли, — и ничего больше. Она не мешает украденному токену работать до истечения срока и не отзывает его. Поэтому вопрос «где хранить» решают вместе с вопросами «как отозвать» и «как коротко живёт».
Проверьте себя. Нарисуйте модель угроз сеанса BFF на cookie.
Частая ошибка. Считать JWT безопасным только из-за подписи.
Изменение данных с cookie-защитой требует SameSite, токена CSRF или специального заголовка и проверки Origin или Fetch Metadata — согласно архитектуре. GET не изменяет состояние. CORS не является полной защитой от CSRF: браузер умеет отправлять простые запросы и формы. Серверное действие остаётся публичной границей, подобной конечной точке API.
Проверка источника выполняется на сервере до всякой предметной логики:
// app/api/tickets/route.ts
export async function POST(request: Request) {
const origin = request.headers.get('origin');
const site = request.headers.get('sec-fetch-site'); // Fetch Metadata
if (site !== 'same-origin' && origin !== process.env.APP_ORIGIN) {
return new Response('Forbidden', { status: 403 });
}
// дальше — аутентификация, разбор схемы, предметная операция
}Изменение состояния по GET открывает атаку без всякой формы: достаточно <img src="https://app/tickets/42/delete"> на чужом сайте, и браузер отправит cookie сам. CORS здесь не спасает: он ограничивает чтение ответа, а не отправку запроса — межсайтовая форма уйдёт на сервер, и запись произойдёт до того, как браузер откажет в чтении ответа.
Проверьте себя. Попробуйте межсайтовую отправку формы методом POST и проверьте отказ.
Частая ошибка. Изменение данных по ссылке с методом GET.
Экспортированную серверную функцию клиент может вызвать через протокол фреймворка. В каждой команде проверяйте аутентификацию, право на конкретный объект, схему, частоту, идемпотентность и безопасные ошибки. Скрытая кнопка не ограничивает доступ. Передавайте идентификаторы, а данные перечитывайте на сервере.
Скрытое поле формы — это ввод пользователя, ничем не отличающийся от текстового:
// ❌ app/actions.ts — userId и amount приходят из браузера
'use server';
export async function transfer(formData: FormData) {
const userId = formData.get('userId') as string; // подменяется в DevTools
const amount = Number(formData.get('amount'));
await db.transfer(userId, amount);
}Правильная форма: личность берём из сеанса, права проверяем на конкретный объект, значения перечитываем с сервера:
// ✅ app/actions.ts
'use server';
export async function transfer(formData: FormData) {
const session = await requireSession(); // кто это
const parsed = TransferSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: 'Некорректные данные' };
const account = await db.accounts.find(parsed.data.accountId);
if (account?.ownerId !== session.userId) { // право на этот объект
return { error: 'Операция недоступна' }; // без деталей о чужих данных
}
await db.transfer(account.id, parsed.data.amount);
}Сообщение об ошибке тоже часть границы: «счёт принадлежит другому пользователю» подтверждает существование объекта и помогает перебирать идентификаторы.
Проверьте себя. Вызовите действие вручную с чужим recordId.
Частая ошибка. Доверять userId из скрытого поля.
Зависимости выполняют код при установке, сборке и работе приложения. Файл блокировки (lockfile) и контрольные суммы дают воспроизводимость, но не безопасность. Сокращайте число пакетов и скриптов, проверяйте уведомления об уязвимостях, фиксируйте версии критичных средств и обновляйте с тестами. Сторонний скрипт получает права источника; песочница, согласие пользователя и CSP ограничивают риск.
Установка с проверкой файла блокировки и без исполнения сценариев пакетов — базовая гигиена в CI:
npm ci --ignore-scripts # ставим ровно то, что в lockfile, без postinstall
npm audit --audit-level=highФайл блокировки гарантирует, что вы поставите тот же код, что и в прошлый раз, — включая тот случай, когда этот код уже скомпрометирован. Отдельный вопрос — сторонние скрипты на странице: подключённый аналитический тег работает с правами вашего источника и читает DOM целиком, включая введённые в форму данные. Ограничить его можно только внешними средствами: iframe с песочницей, отложенная загрузка после согласия, узкая CSP.
Проверьте себя. Составьте перечень зависимостей и источников скриптов.
Частая ошибка. Добавить пакет ради тривиальной вспомогательной функции.
CSP ограничивает источники скриптов, стилей, соединений и фреймов и снижает последствия XSS; одноразовое значение (nonce) или хеш лучше широкого unsafe-inline. frame-ancestors защищает от подмены кликов (clickjacking). HSTS, nosniff, политика referrer и permissions дополняют защиту. CSP не заменяет очистку данных и авторизацию.
Внедрять политику начинают в режиме отчётов, чтобы увидеть реальные нарушения, ничего не сломав:
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' 'nonce-r4nd0m';
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
report-uri /api/csp-reportПосле разбора отчётов заголовок переводят в блокирующий режим. Обратный порядок действий — добавлять разрешения, пока не исчезнут ошибки в консоли, — заканчивается политикой script-src 'self' 'unsafe-inline' 'unsafe-eval', которая не мешает ни одному из известных вариантов XSS и существует только для отчётности.
Проверьте себя. Составьте CSP только для отчётов и разберите нарушения.
Частая ошибка. Добавить unsafe-inline/unsafe-eval до исчезновения ошибок.
Найдите и исправьте хранимую XSS в предпросмотре Markdown, URL со схемой javascript:, токен в localStorage и журнале, изменение без защиты CSRF и открытое перенаправление. Добавьте план CSP, проверку зависимостей и тесты злоупотребления серверным действием.
Работа готова, когда недоверенные данные не исполняются, учётные данные без необходимости не доступны клиентскому JavaScript, а сервер проверяет каждое изменение.
Далее: SSR, потоковая передача и гидратация