Архитектура React Server Components (RSC) фундаментально изменила границу между сервером и клиентом. В единой кодовой базе сосуществуют компоненты, исполняемые исключительно на сервере, и компоненты, компилируемые в клиентский JavaScript-бандл. Такая конвергенция порождает два критических вектора утечек:
'use client'.props).Для решения первой проблемы Next.js использует статический страж сборки — пакет server-only. Однако server-only защищает исключительно модули (файлы с кодом) на этапе сборки Turbopack, но бессилен против передачи сырых серверных объектов через props в рантайме.
Для ликвидации второго класса угроз в React 19 и Next.js 16 внедрен React Taint API. Этот механизм предоставляет две примитивные функции защиты:
experimental_taintObjectReference: клеймит конкретную ссылку на объект в оперативной памяти (WeakMap), запрещая передачу объекта целиком.experimental_taintUniqueValue: клеймит уникальное скалярное значение (секретный токен, приватный ключ, пароль), блокируя его попадание в клиентский поток, даже если оно вложено в произвольную структуру или объединено с другой строкой.В рантайме React передача данных из Server Components в Client Components происходит путем сериализации в специализированный текстовый формат — RSC Flight Protocol. Сериализатор рекурсивно обходит дерево свойств (props), превращая объекты в JSON-подобный поток байтов.
React Taint API встраивает в сериализатор Flight контрольный барьер безопасности. Прежде чем записать узел в выходной поток, сериализатор сверяет ссылку объекта с внутренним реестром Tainted Objects, а строки — с глобальным множеством Tainted Values. При совпадении рендеринг немедленно прерывается с фатальным исключением, предотвращая попадание данных в HTTP-ответ.
Server Component (Node.js Environment)
│
├── db.user.findUnique() ──────────► [Сырой объект User: { id, email, passwordHash }]
│ │
│ ▼
│ taintObjectReference(
│ 'Не передавайте сырой объект User клиенту!',
│ user
│ )
│ │
▼ ▼
<UserCard user={user} /> ──────────► [Flight Serializer Engine]
('use client') │
├─ Проверка в WeakMap: ОБЪЕКТ ЗАБЛОКИРОВАН!
▼
💥 Taint Violation Error:
"A taint violation was found. Do not pass tainted
objects to Client Components..."
(HTTP-ответ прерван, утечка предотвращена)Пакет server-only внедряет маркер в граф модулей Turbopack. Любая попытка импорта файла в дерево 'use client' крашит билд.
При извлечении данных из БД репозиторий вызывает taintObjectReference для сырых сущностей и taintUniqueValue для секретов.
Серверный компонент монтирует клиентский узел. Сериализатор Flight начинает рекурсивную упаковку переданных свойств.
Сериализатор сверяет ссылки через WeakMap и выполняет поиск сигнатур уникальных строковых секретов.
При обнаружении заблокированного значения поток прерывается, пишется лог аудита, а клиенту не передается ни байта данных.
Почему директивы import 'server-only' недостаточно для предотвращения утечки хэша пароля в клиентский компонент?
В биллинговых сервисах объект PaymentProfile содержит номер счета, токен шлюза Stripe и временный код CVC. При выборке профиля в DAL накладывается taintObjectReference. Если разработчик случайно напишет <BillingSummary profile={paymentProfile} />, приложение упадет на сервере, предотвратив компрометацию данных стандарта PCI DSS.
При аутентификации объект пользователя содержит системные атрибуты: passwordHash, twoFactorSecret, recoveryTokens. Даже при использовании выборки Prisma findUnique пометка объекта через Taint API страхует всю команду от человеческой ошибки при рефакторинге.
Система взаимодействует с OpenAI, AWS KMS или платежными шлюзами. Приватный мастер-ключ загружается из process.env.OPENAI_API_KEY. Вызов experimental_taintUniqueValue гарантирует, что даже если разработчик случайно поместит ключ в глобальное состояние темы или контекста страницы, он не будет отправлен в браузер.
В архитектуре BFF (Backend For Frontend) сервер получает долгоживущие токены доступа к микросервисам (OAuth Bearer Tokens). Пометка токена через Taint API блокирует его рендеринг в скрытых полях клиентских форм.
Директива import 'server-only' проверяет окружение модуля на этапе построения AST-графа.
Блокирует случайный client-side импорт до компиляции.
Репозиторий помечает объект в WeakMap и регистрирует скалярные секреты в реестре сериализатора.
Оверхед по памяти минимален благодаря сборщику мусора WeakMap.
Серверный компонент формирует виртуальное дерево элементов и передает свойства в дочерние узлы.
Серверные компоненты могут свободно оперировать объектом.
Сериализатор проверяет каждый узел перед передачей клиенту. Помеченные данные вызывают немедленный abort.
Исключение фиксируется в системе аудита безопасности.
import 'server-only'#Пакет server-only подключается первой строкой файла. Если модуль или любая экспортируемая из него утилита импортируется файлом с директивой 'use client', компилятор прерывает сборку.
import 'server-only';
import { Pool } from 'pg';
// Этот пул соединений физически не может попасть в клиентский бандл
export const pgPool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: true } : false,
});
export async function executeQuery<T>(sql: string, params: unknown[] = []): Promise<T[]> {
const client = await pgPool.connect();
try {
const result = await client.query(sql, params);
return result.rows as T[];
} finally {
client.release();
}
}experimental_taintObjectReference#Функция experimental_taintObjectReference принимает сообщение об ошибке и целевой объект. Она сохраняет ссылку в закрытый WeakMap внутри ядра React.
import 'server-only';
import { experimental_taintObjectReference as taintObjectReference } from 'react';
import { executeQuery } from '@/lib/db/connection';
export interface RawUserRecord {
id: string;
email: string;
passwordHash: string;
totpSecret: string | null;
role: string;
}
export interface SafeUserDTO {
id: string;
email: string;
role: string;
}
export async function getUserById(userId: string): Promise<RawUserRecord | null> {
const users = await executeQuery<RawUserRecord>(
'SELECT id, email, password_hash as "passwordHash", totp_secret as "totpSecret", role FROM users WHERE id = $1 LIMIT 1',
[userId]
);
const rawUser = users[0] || null;
if (rawUser) {
// Регистрируем объект в реестре Taint API
// Любая попытка передать rawUser в клиентский компонент вызовет аварийную остановку
taintObjectReference(
'БЕЗОПАСНОСТЬ: Запрещено передавать сырой объект пользователя (RawUserRecord) в Client Components. Используйте toSafeUserDTO()!',
rawUser
);
}
return rawUser;
}
// Преобразование сырой сущности в безопасный DTO
export function toSafeUserDTO(raw: RawUserRecord): SafeUserDTO {
return {
id: raw.id,
email: raw.email,
role: raw.role,
};
}experimental_taintUniqueValue#В отличие от объектов, строковые примитивы не имеют устойчивой ссылки в памяти. Для них React предоставляет experimental_taintUniqueValue, который перехватывает конкретное строковое значение, где бы оно ни появилось.
import 'server-only';
import { experimental_taintUniqueValue as taintUniqueValue } from 'react';
// Инициализируем защиту переменных окружения при старте приложения
export function initializeSecretsProtection() {
const masterKey = process.env.ENCRYPTION_MASTER_KEY;
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
const jwtSecret = process.env.JWT_SIGNING_SECRET;
if (masterKey) {
taintUniqueValue(
'КРИТИЧЕСКАЯ УТЕЧКА: ENCRYPTION_MASTER_KEY был передан в клиентский сериализатор Flight!',
process,
masterKey
);
}
if (stripeSecretKey) {
taintUniqueValue(
'КРИТИЧЕСКАЯ УТЕЧКА: STRIPE_SECRET_KEY обнаружен в пропсах Client Component!',
process,
stripeSecretKey
);
}
if (jwtSecret) {
taintUniqueValue(
'КРИТИЧЕСКАЯ УТЕЧКА: Секрет подписи JWT отправлен на клиент!',
process,
jwtSecret
);
}
}
// Запускаем инициализацию при загрузке модуля
initializeSecretsProtection();app/profile/page.tsx#Рассмотрим, что произойдет при нарушении правила безопасности.
import { getUserById, toSafeUserDTO } from '@/lib/dal/repositories/user-repository';
import { verifySession } from '@/lib/dal/auth';
import { UserProfileCard } from './UserProfileCard';
export default async function ProfilePage() {
const session = await verifySession();
const rawUser = await getUserById(session.userId);
if (!rawUser) {
return <div>Пользователь не найден</div>;
}
// ОШИБКА: Если разработчик раскомментирует строку ниже:
// return <UserProfileCard user={rawUser} />;
// Next.js немедленно упадет с ошибкой:
// Error: A taint violation was found. БЕЗОПАСНОСТЬ: Запрещено передавать сырой объект...
// ПРАВИЛЬНО: Передаем только очищенный DTO
const safeUser = toSafeUserDTO(rawUser);
return (
<main className="p-8">
<h1 className="text-xl font-bold">Профиль безопасности</h1>
<UserProfileCard user={safeUser} />
</main>
);
}'use client';
import { SafeUserDTO } from '@/lib/dal/repositories/user-repository';
interface UserProfileCardProps {
user: SafeUserDTO;
}
export function UserProfileCard({ user }: UserProfileCardProps) {
return (
<div className="p-4 border rounded-lg bg-slate-900 border-slate-800">
<p className="text-sm text-slate-400">Идентификатор: {user.id}</p>
<p className="text-sm font-semibold text-slate-200">Email: {user.email}</p>
<span className="inline-block mt-2 px-2 py-0.5 text-xs bg-cyan-950 text-cyan-400 border border-cyan-800 rounded">
Роль: {user.role}
</span>
</div>
);
}Что произойдет, если секретный токен, защищенный через taintUniqueValue, будет объединен со строкой 'Bearer ' + secretToken и передан в свойство клиентского компонента?
Попытка защитить через taintUniqueValue короткие строки, цифры или булевы флаги (например, 'true', 'active', 42, 'user') приведет к тому, что сериализатор будет падать на любом легитимном клиентском компоненте, где встречается это слово.
Как избежать: Используйте taintUniqueValue строго для длинных криптографических секретов (энтропия более 64 бит): JWT-ключи, API-токены, приватные ключи, соли.
Если объект user, помеченный через taintObjectReference, клонировать: const cloned = { ...user }, создается новый объект в памяти с другим адресом. WeakMap не связывает клон со старой ссылкой, и защита будет обойдена.
Как избежать: Не клонируйте защищенные сущности. Трансформируйте их в явно объявленные DTO с белым списком безопасных полей.
Функции experimental_taintObjectReference и experimental_taintUniqueValue существуют исключительно в серверном рантайме React. Импорт их в Client Component приведет к ошибке компиляции.
Как избежать: Размещайте вызовы Taint API исключительно в серверных модулях под защитой import 'server-only'.
Удаление свойства delete rawUser.passwordHash перед передачей объекта в Client Component является антипаттерном: мутация серверного кэшированного объекта ломает параллельные потоки, а добавление нового секретного поля в БД в будущем приведет к немедленной утечке.
Как избежать: Используйте строгую модель DTO с созданием нового объекта только с публичными полями.