У React 19 парадигма Server Functions (у контексті роботи з формами відомих як Server Actions) стирає синтаксичну межу між клієнтом і сервером: асинхронну функцію на бекенді можна імпортувати та викликати безпосередньо з клієнтського компонента, як звичайний локальний метод. Однак за цим елегантним синтаксичним цукром криється складна розподілена RPC-архітектура (Remote Procedure Call), що функціонує через відкриту мережу інтернет.
Будь-яка функція, позначена директивою 'use server', компілюється збирачем у публічно доступний HTTP POST ендпоінт, відкритий для прямого виклику з будь-якої точки інтернету.
Сприйняття Server Action як «просто внутрішньої функції модуля» є головним джерелом критичних вразливостей у сучасних React 19 та Next.js проєктах:
curl або автоматизований скрипт, передавши довільні параметри в обхід масок введення, валідації форм та заблокованих кнопок (disabled).deleteInvoice(invoiceId) не перевіряє права поточної сесії на цей конкретний invoiceId, атакуючий зможе видалити рахунки інших користувачів простим перебором чисел або UUID.server-only.Origin, Host, Sec-Fetch-Site) запобігає виконанню мутацій шкідливими сторонніми сайтами від імені авторизованого користувача.Коли збирач зустрічає директиву '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, що гарантує цілісність ідентифікаторів на рівні фреймворку.
Користувач надсилає форму або викликає екшен. Runtime формує POST-запит із Action ID та Flight-аргументами.
Сервер звіряє збіг Origin та Host, перевіряє Sec-Fetch-Site. Крос-доменні запити відхиляються (403 Forbidden).
Дешифруються bound-аргументи, серверна Zod-схема суворо перевіряє типи та діапазони значень полів.
Перевіряються права поточної сесії на ресурси (RBAC / IDOR defense), виконуються транзакції в БД.
Результат мутації та оновлені серверні піддерева серіалізуються у Flight-потік для безшовного оновлення UI.
Яке фундаментальне твердження про безпеку функцій із директивою 'use server' є вірним?
Виконання транзакцій із повторною перевіркою прав поточної сесії безпосередньо в тілі функції, повністю ігноруючи клієнтські баланси або значення прихованих полів форми.
Безпечне зв'язування ID записів у кнопках списків через шифрування параметрів у action.bind(null, id). Користувач не може підробити токен і модифікувати чужий документ.
Розміщення секретних токенів доступу в модулях з імпортом import 'server-only', що виключає випадковий витік конфіденційних даних у публічний JS-бандл.
Поєднання Server Action з нативними хуками React 19 useOptimistic та useActionState без залучення важких сторонніх бібліотек управління стейтом.
Користувач клікає кнопку чи надсилає форму. Client Runtime серіалізує аргументи у Flight RPC потік.
Сервер перевіряє заголовки Origin, Host та Sec-Fetch-Site. Крос-доменні запити блокуються зі статусом 403.
Пошук функції в реєстрі за Action ID. Дешифрування зв'язаних аргументів (bound args) через секретний ключ.
Перевірка прав сесії користувача (RBAC/IDOR), валідація схеми 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-винятків. Помилки мають маскуватися загальними повідомленнями.
Під час розгортання власних Node.js серверів інженери часто забувають валідувати заголовки Origin та Host. Це дозволяє стороннім шкідливим сайтам надсилати приховані POST-запити на ендпоінти Server Actions від імені залогіненого користувача.