В React 19 парадигма Server Functions (в контексте форм называемых Server Actions) стирает синтаксическую границу между клиентом и сервером: асинхронную функцию на бэкенде можно импортировать и вызвать прямо из клиентского компонента, как обычный локальный метод. Однако за этим элегантным синтаксическим сахаром скрывается сложнейшая низкоуровневая распределенная RPC-архитектура (Remote Procedure Call).
Любая функция, помеченная директивой 'use server', компилируется сборщиком в публично доступный HTTP POST эндпоинт, открытый для прямого вызова из любой точки интернета.
Восприятие Server Action как «просто внутренней функции модуля» — главный источник критических уязвимостей в современных Next.js и React 19 проектах:
curl или автоматизированный скрипт, передав любые значения параметров в обход масок ввода, валидации форм и клиентских disabled-кнопок.deleteInvoice(invoiceId) не проверяет права текущей сессии на этот конкретный invoiceId, атакующий сможет удалить счета других пользователей простым перебором чисел.server-only.Когда сборщик встречает директиву 'use server', он расщепляет исходный код на две части. На сервере регистрируется таблица маршрутизации RPC, а в клиентский бандл вместо реального тела функции помещается легковесный стаб — Server Reference.
При вызове функции клиентский стаб формирует HTTP POST-запрос с заголовком React-Server-Action, содержащим криптографический Action ID. Серверный диспетчер проверяет происхождение запроса (Origin/Host), находит функцию в реестре, валидирует сессию и исполняет мутацию в защищенной среде Node.js/Edge.
Если серверная функция создается с привязкой аргументов (например, updateTodo.bind(null, todo.id)), React шифрует эти аргументы на сервере с использованием симметричного ключа сессии перед отправкой клиенту. Клиент физически не может модифицировать зашифрованный токен todo.id (защита от IDOR на уровне фреймворка).
Форма отправляется или вызывается экшен. Runtime формирует POST-запрос с Action ID и заголовками безопасности.
Сервер сверяет совпадение Origin и Host. Неавторизованные кросс-доменные запросы блокируются (403 Forbidden).
Дешифруются bound-аргументы, схема Zod на бэкенде проверяет типы и диапазоны значений полей.
Проверяются права пользователя в сессии (RBAC), выполняются транзакции в базе данных.
Результат сериализуется в протокол Flight и возвращается клиенту для бесшовного обновления UI.
Какое фундаментальное утверждение о безопасности функций с директивой 'use server' является верным?
Выполнение бизнес-логики с повторной проверкой прав текущей сессии непосредственно в теле функции, полностью игнорируя клиентские флаги или переданные в скрытых полях балансы.
Безопасная передача ID записей в кнопках таблиц через шифрование параметров в bind(). Пользователь не может подделать токен и удалить чужой документ.
Размещение секретных токенов доступа к инфраструктуре в модулях с директивой import 'server-only', исключающей случайную утечку конфиденциальных ключей в публичный бандл.
Вызов Server Action в связке с хуками React 19 useOptimistic и useActionState без привлечения сторонних тяжелых библиотек управления состоянием.
Пользователь кликает на кнопку или отправляет форму. Client Runtime сериализует аргументы в поток Flight RPC.
Сервер проверяет заголовки Origin, Host и Sec-Fetch-Site. Запросы с чужих доменов немедленно отклоняются со статусом 403.
Поиск функции в реестре по Action ID. Дешифровка связанных аргументов (bound args) через приватный ключ сервера.
Проверка активной сессии пользователя, RBAC прав и Zod-валидация. Выполнение мутации в БД.
Когда файл помечается директивой 'use server', компилятор генерирует клиентский прокси-объект:
import { createServerReference } from 'react-server-dom-webpack/client';
// Клиентская заглушка: содержит только Action ID и сетевой транспорт
export const transferMoney = createServerReference(
'action-id-9f8a3c2e1b', // Захэшированный идентификатор функции
callServer // Транспортная функция отправки HTTP POST
);А на сервере создается защищенная таблица диспетчеризации:
export const actionsManifest = {
'action-id-9f8a3c2e1b': {
module: './src/actions/banking.ts',
name: 'transferMoney',
// Привязанный хэш сборки для защиты от атак повторного воспроизведения (Replay Attacks)
buildHash: 'v1.42.0-prod',
},
};В React 19 встроена строгая проверка происхождения запроса. Если Server Action вызывается сторонним сайтом через форму или fetch, сервер сравнивает заголовок Origin с заголовком Host:
import { Request } from 'express';
export function validateActionSecurity(req: Request): boolean {
// 1. Метод обязан быть строго POST
if (req.method !== 'POST') {
return false;
}
// 2. Проверка заголовка Sec-Fetch-Site (современные браузеры)
const fetchSite = req.headers['sec-fetch-site'];
if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'same-site') {
console.error(`[CSRF Block] Недопустимый Sec-Fetch-Site: ${fetchSite}`);
return false;
}
// 3. Проверка совпадения Origin и Host
const origin = req.headers['origin'];
const host = req.headers['host'];
if (origin && host) {
const originHost = new URL(origin).host;
if (originHost !== host) {
console.error(`[CSRF Block] Origin (${originHost}) не совпадает с Host (${host})`);
return false;
}
}
return true;
}Ниже представлен эталонный пример Server Function для перевода средств с полной защитой от IDOR, строгой валидацией Zod, проверкой прав доступа и изоляцией секретов:
'use server';
import 'server-only'; // Запрет утечки на клиент
import { z } from 'zod';
import { db } from '@/lib/db';
import { getAuthenticatedUser } from '@/lib/auth';
// Схема входных данных, валидируемая на бэкенде
const TransferPayloadSchema = z.object({
recipientIban: z.string().regex(/^DE\d{20}$/, 'Некорректный немецкий IBAN'),
amountCents: z.number().int().positive('Сумма должна быть положительной'),
referenceNote: z.string().max(140).optional(),
});
export interface TransferResult {
success: boolean;
transactionId?: string;
error?: string;
}
export async function transferFunds(
targetAccountId: string, // Привязан через .bind()
prevState: TransferResult,
formData: FormData
): Promise<TransferResult> {
// 1. АУТЕНТИФИКАЦИЯ: Проверка сессии из Cookie/Headers
const currentUser = await getAuthenticatedUser();
if (!currentUser) {
return { success: false, error: 'Требуется повторная авторизация' };
}
// 2. АВТОРИЗАЦИЯ (Защита от IDOR): Принадлежит ли targetAccountId текущему пользователю?
const sourceAccount = await db.accounts.findFirst({
where: { id: targetAccountId, ownerId: currentUser.id },
});
if (!sourceAccount) {
// Пользователь пытается совершить действие со счетом, который ему не принадлежит!
console.warn(`[Security Alert] Пользователь ${currentUser.id} попытался списать средства со счета ${targetAccountId}`);
return { success: false, error: 'Счет списания не найден или доступ запрещен' };
}
// 3. ВАЛИДАЦИЯ ДАННЫХ
const rawData = {
recipientIban: formData.get('recipientIban'),
amountCents: Number(formData.get('amountCents')),
referenceNote: formData.get('referenceNote') || undefined,
};
const parsed = TransferPayloadSchema.safeParse(rawData);
if (!parsed.success) {
const issue = parsed.error.issues[0]?.message || 'Ошибка валидации полей';
return { success: false, error: issue };
}
// 4. БИЗНЕС-ЛОГИКА: Проверка баланса
if (sourceAccount.balanceCents < parsed.data.amountCents) {
return { success: false, error: 'Недостаточно средств на счете' };
}
// 5. ИСПОЛНЕНИЕ ТРАНЗАКЦИИ
try {
const tx = await db.$transaction(async (prisma) => {
await prisma.accounts.update({
where: { id: sourceAccount.id },
data: { balanceCents: { decrement: parsed.data.amountCents } },
});
return prisma.transactions.create({
data: {
sourceAccountId: sourceAccount.id,
recipientIban: parsed.data.recipientIban,
amountCents: parsed.data.amountCents,
note: parsed.data.referenceNote,
},
});
});
return { success: true, transactionId: tx.id };
} catch (err: unknown) {
// 6. БЕЗОПАСНАЯ ОБРАБОТКА ОШИБОК: Скрываем системные детали БД
console.error('[Transaction Error]:', err);
return { success: false, error: 'Сбой процессинга платежа. Попробуйте позже.' };
}
}Почему при передаче связанных аргументов через action.bind(null, id) на сервере React шифрует их, но разработчик все равно обязан проверять права владения id в базе данных?
Для стандартизации авторизации и валидации создадим переиспользуемый строитель защищенных экшенов:
import { z } from 'zod';
export interface ActionError {
fieldErrors?: Record<string, string[]>;
message: string;
}
export interface ActionState<TOutput> {
data: TOutput | null;
error: ActionError | null;
success: boolean;
}
export interface SecurityContext<TUser> {
user: TUser;
}
export function createSecureAction<TSchema extends z.ZodTypeAny, TOutput, TUser>(
schema: TSchema,
authResolver: () => Promise<TUser | null>,
handler: (input: z.infer<TSchema>, ctx: SecurityContext<TUser>) => Promise<TOutput>
) {
return async (
prevState: ActionState<TOutput>,
payload: unknown
): Promise<ActionState<TOutput>> => {
// 1. Проверка авторизации сессии
const user = await authResolver();
if (!user) {
return {
data: null,
error: { message: 'Доступ запрещен. Необходима авторизация.' },
success: false,
};
}
// 2. Валидация входных данных через Zod
const validation = schema.safeParse(payload);
if (!validation.success) {
return {
data: null,
error: {
fieldErrors: validation.error.flatten().fieldErrors,
message: 'Ошибка валидации переданных параметров',
},
success: false,
};
}
// 3. Выполнение бизнес-логики в безопасном контексте
try {
const result = await handler(validation.data, { user });
return {
data: result,
error: null,
success: true,
};
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : 'Внутренний сбой сервера';
return {
data: null,
error: { message: errMsg },
success: false,
};
}
};
}Грубейшая архитектурная уязвимость — доверие переданному с клиента идентификатору (например, deletePost(postId)). Если внутри функции нет проверки, что текущий пользователь действительно является автором поста postId, злоумышленник сможет удалять чужие записи простым перебором чисел через скрипт автоматизации.
// ❌ КРИТИЧЕСКАЯ УЯЗВИМОСТЬ: удаление записи без проверки владельца (IDOR)
export async function deletePost(postId: string) {
'use server';
await db.post.delete({ where: { id: postId } }); // Любой пользователь удалит любой пост!
}
// ✅ ПРАВИЛЬНО: проверка прав текущей сессии на уровне БД
export async function deletePost(postId: string) {
'use server';
const user = await getCurrentUser();
await db.post.delete({
where: { id: postId, authorId: user.id }, // Удаление только своего контента
});
}Если файл содержит инициализацию клиентов баз данных, приватные ключи или функции хэширования паролей, но не содержит import 'server-only', случайный импорт любой константы из этого файла в Client Component приведет к попаданию приватных токенов в публичный JS-бандл.
Возврат клиенту сырого текста ошибки (catch (e) { return { error: e.message } }) позволяет злоумышленникам извлекать структуру таблиц, имена колонок базы данных и пути к файлам на диске при возникновении SQL-исключений. Ошибки должны маскироваться общими сообщениями.
При развертывании кастомных серверов без Next.js разработчики часто забывают валидировать заголовок Origin. Это позволяет сторонним вредоносным сайтам отправлять скрытые POST-запросы на эндпоинты Server Actions от лица залогиненного пользователя.