Server Actions часто воспринимаются фронтенд-инженерами как «бесшовный вызов серверной функции прямо из React-компонента». Однако с точки зрения сетевой архитектуры любой Server Action — это публично доступный HTTP POST RPC-эндпоинт с уникальным сгенерированным идентификатором (Action ID). Любой внешний клиент (скрипт автоматизации, Postman, cURL) может отправить произвольный POST-запрос с этим Action ID, обойдя клиентский интерфейс приложения.
В Next.js 16 безопасность Server Actions построена на трех криптографических и сетевых эшелонах:
Когда серверный компонент привязывает аргументы к Server Action через метод .bind() (например, передавая documentId), Next.js не отправляет этот аргумент в браузер в открытом виде. Вместо этого сериализатор упаковывает идентификатор экшена и связанные параметры, шифрует их с использованием алгоритма AES-256-GCM и закрытого серверного ключа, генерируемого для каждого развертывания, и передает на клиент зашифрованный непрозрачный токен.
При обратном вызове клиент отправляет зашифрованную полезную нагрузку. Сервер расшифровывает параметры, валидирует заголовок Origin на соответствие разрешенным доменам и передает управление в слой бизнес-логики.
1. Рендеринг Server Component:
deleteDocument.bind(null, doc.id)
│
▼
[Криптографический модуль Next.js]
AES-256-GCM(ActionID + doc.id, SERVER_ACTIONS_KEY)
│
▼
Клиент получает зашифрованный токен: "a8f3b29c..." (Подмена doc.id невозможна!)
2. Вызов экшена из браузера (POST-запрос):
Headers:
POST / HTTP/1.1
Next-Action: 4f981b2...
Origin: https://app.example.com
Host: app.example.com
│
▼
[Эшелон 1: CSRF & Origin Guard] ──► Origin === Host? (Да -> Пропускаем)
│
▼
[Эшелон 2: Rate Limiter (Redis)] ──► Менее 10 req/min для этого IP/User? (Да)
│
▼
[Эшелон 3: Closure Decryption] ──► Расшифровка параметров -> doc.id = 104
│
▼
[Эшелон 4: Zod 4 Schema Validation] ──► Валидация входных данных формы
│
▼
[Эшелон 5: Data Access Layer (DAL)] ──► Проверка сессии и прав на удалениеНа этапе RSC-рендеринга аргументы .bind() запечатываются шифром AES-256-GCM. Клиент получает криптографический блоб.
При вызове ядро Next.js инспектирует заголовок Origin. Запросы со сторонних доменов мгновенно отклоняются с 403 Forbidden.
Служебный перехватчик проверяет лимиты в Redis через алгоритм Sliding Window Log по комбинации IP и UserID.
Сервер расшифровывает привязанные аргументы своим приватным ключом. Подделка значений на клиенте математически исключена.
Входные параметры парсятся через Zod 4 со строгим отсечением неизвестных полей (.strict()), после чего управление переходит в DAL.
Почему при передаче идентификатора записи через updatePost.bind(null, post.id) злоумышленник не может изменить post.id в браузере и отредактировать чужой пост?
При совершении банковского перевода недопустима подмена счета получателя или валюты. Идентификатор счета плательщика привязывается через .bind(null, verifiedAccountId) и шифруется, исключая модификацию в DOM-дереве браузера.
Форма подтверждения кода сброса пароля из SMS/Email подвержена атакам перебора. Rate Limiter на базе Sliding Window в Redis блокирует запросы после 5 неудачных попыток в течение 15 минут для конкретного IP-адреса и номера телефона.
Генерация тяжелых CSV/PDF-отчетов потребляет значительный объем CPU и оперативной памяти сервера. Ограничение частоты (1 запрос в минуту на компанию) предотвращает исчерпание пула воркеров Node.js.
Форма отправки комментариев или отзывов защищена нативной проверкой Origin и схемой Zod: автоматизированные спам-боты, отправляющие поддельные запросы со сторонних веб-страниц без легитимного заголовка Origin, немедленно отсекаются сервером.
Клиент отправляет запрос с заголовком Next-Action. Ядро Next.js проверяет совпадение Origin и Host.
Защита CSRF не требует дополнительных скрытых полей с токенами.
Скрипт проверяет скользящее окно запросов по ключу rl:action:{userId}:{ip} в Redis.
При превышении порога возвращается структурированная ошибка 429 Too Many Requests.
Приватный ключ расшифровывает полезную нагрузку .bind(), восстанавливая оригинальные аргументы.
Аутентифицированное шифрование GCM исключает бит-флиппинг атаки.
Данные валидируются через Zod 4 .strict(), проверяется сессия, и мутация фиксируется в БД.
Результат возвращается в клиентское дерево с ревалидацией тегов кэша.
next.config.ts#По умолчанию Next.js 16 проверяет, что заголовок Origin совпадает с текущим хостом. Если приложение работает за обратным прокси, мультидоменной конфигурацией или в среде микрофронтендов, доверенные источники указываются явно:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
serverActions: {
// Доверенные домены, имеющие право инициировать Server Actions
allowedOrigins: [
'app.enterprise-corp.com',
'admin.enterprise-corp.com',
],
// Ограничение максимального размера полезной нагрузки тела запроса (защита от DoS)
bodySizeLimit: '2mb',
},
},
};
export default nextConfig;lib/security/rate-limit.ts#Алгоритм Sliding Window Log на основе Redis исключает проблему скачков трафика на границах минут (в отличие от наивного Fixed Window).
import 'server-only';
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
interface RateLimitConfig {
maxRequests: number; // Максимум запросов
windowSeconds: number; // Размер скользящего окна в секундах
}
interface RateLimitResult {
success: boolean;
limit: number;
remaining: number;
resetSeconds: number;
}
export async function rateLimit(
identifier: string,
config: RateLimitConfig
): Promise<RateLimitResult> {
const { maxRequests, windowSeconds } = config;
const now = Date.now();
const windowMs = windowSeconds * 1000;
const clearBefore = now - windowMs;
const key = `ratelimit:${identifier}`;
// Атомарный пайплайн в Redis: очистка устаревших записей, добавление текущей, подсчет
const pipeline = redis.pipeline();
pipeline.zremrangebyscore(key, 0, clearBefore);
pipeline.zadd(key, now, `${now}-${Math.random()}`);
pipeline.zcard(key);
pipeline.expire(key, windowSeconds);
const results = await pipeline.exec();
const count = (results?.[2]?.[1] as number) || 0;
if (count > maxRequests) {
return {
success: false,
limit: maxRequests,
remaining: 0,
resetSeconds: windowSeconds,
};
}
return {
success: true,
limit: maxRequests,
remaining: Math.max(0, maxRequests - count),
resetSeconds: windowSeconds,
};
}app/actions/transfer-funds.ts#Полная реализация промышленного Server Action: проверка сессии, шифрование замыкания счета отправителя, ограничение частоты, валидация через Zod 4 и обработка транзакции.
'use server';
import { z } from 'zod';
import { revalidateTag } from 'next/cache';
import { headers } from 'next/headers';
import { verifySession } from '@/lib/dal/auth';
import { rateLimit } from '@/lib/security/rate-limit';
import { db } from '@/lib/db';
// Строгая схема валидации входных данных через Zod 4
const TransferInputSchema = z
.object({
recipientIban: z.string().regex(/^DE\d{20}$/, 'Некорректный IBAN стандарта DE'),
amountCents: z.number().int().positive().max(10_000_00, 'Лимит операции: 10 000 EUR'),
reference: z.string().max(140).trim().optional(),
})
.strict(); // Запрещаем передачу лишних необъявленных свойств
export type ActionState = {
status: 'idle' | 'success' | 'error';
message: string;
fieldErrors?: Record<string, string[]>;
};
/**
* Идентификатор sourceAccountId привязывается через .bind() в Server Component
* и гарантированно зашифрован платформой Next.js!
*/
export async function transferFundsAction(
sourceAccountId: string,
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// 1. Проверка сессии в Data Access Layer
const session = await verifySession();
// 2. Rate Limiting: не более 5 переводов в минуту на пользователя
const clientIp = (await headers()).get('x-forwarded-for')?.split(',')[0] || '127.0.0.1';
const limitCheck = await rateLimit(`transfer:${session.userId}:${clientIp}`, {
maxRequests: 5,
windowSeconds: 60,
});
if (!limitCheck.success) {
return {
status: 'error',
message: `Превышен лимит запросов. Попробуйте снова через ${limitCheck.resetSeconds} сек.`,
};
}
// 3. Валидация входных данных формы
const rawData = {
recipientIban: formData.get('recipientIban'),
amountCents: Number(formData.get('amountCents')),
reference: formData.get('reference') || undefined,
};
const parsed = TransferInputSchema.safeParse(rawData);
if (!parsed.success) {
return {
status: 'error',
message: 'Ошибка валидации полей формы',
fieldErrors: parsed.error.flatten().fieldErrors,
};
}
const { recipientIban, amountCents, reference } = parsed.data;
// 4. Проверка владения счетом отправителя в DAL (ABAC)
const sourceAccount = await db.accounts.findFirst({
where: {
id: sourceAccountId,
userId: session.userId,
},
});
if (!sourceAccount || sourceAccount.balanceCents < amountCents) {
return {
status: 'error',
message: 'Недостаточно средств на счете или счет не найден.',
};
}
// 5. Исполнение финансовой транзакции
await db.$transaction(async (tx) => {
await tx.accounts.update({
where: { id: sourceAccountId },
data: { balanceCents: { decrement: amountCents } },
});
await tx.transactions.create({
data: {
fromAccountId: sourceAccountId,
recipientIban,
amountCents,
reference: reference || 'Перевод средств',
createdByUserId: session.userId,
},
});
});
// 6. Инвалидация кэша баланса
revalidateTag(`account-balance-${sourceAccountId}`);
return {
status: 'success',
message: 'Транзакция успешно обработана.',
};
}.bind() в Server Component#import { verifySession } from '@/lib/dal/auth';
import { db } from '@/lib/db';
import { transferFundsAction } from '@/app/actions/transfer-funds';
import { TransferForm } from './TransferForm';
import { notFound } from 'next/navigation';
export default async function AccountDetailPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params;
const session = await verifySession();
const account = await db.accounts.findFirst({
where: { id, userId: session.userId },
});
if (!account) {
notFound();
}
// БЕЗОПАСНАЯ ПРИВЯЗКА АРГУМЕНТА:
// Next.js зашифрует account.id с помощью AES-256-GCM.
// Клиентский компонент получит форму, отправляющую зашифрованный токен.
const boundAction = transferFundsAction.bind(null, account.id);
return (
<div className="p-6">
<h1 className="text-xl font-bold">Счет: {account.iban}</h1>
<p className="text-slate-400">Баланс: {(account.balanceCents / 100).toFixed(2)} EUR</p>
<div className="mt-6 max-w-md">
<TransferForm action={boundAction} />
</div>
</div>
);
}Каким образом Next.js предотвращает CSRF-атаки на Server Actions без использования классических скрытых токенов в форме?
Инженер полагается на валидацию в браузере (<input type="number" min="1" max="100" required />). Злоумышленник отправляет прямой POST-запрос на Action ID с отрицательной суммой -5000, пополняя баланс своего счета из-за отсутствия проверки на сервере.
Как избежать: Всегда парсите formData или аргументы через строгую схему Zod с валидацией знаков и допустимых диапазонов (.positive()).
Создание Server Action для сброса пароля или отправки SMS без ограничения частоты позволяет злоумышленникам за несколько минут исчерпать платный баланс SMS-шлюза (SMS Toll Fraud) или подобрать код подтверждения перебором. Как избежать: Защищайте все публичные экшены распределенным счетчиком в Redis на базе алгоритма Sliding Window.
При развертывании приложения за балансировщиком нагрузки (Nginx, Traefik, AWS ALB) заголовок Host может содержать внутреннее имя сервиса (localhost:3000), в то время как Origin равен публичному домену (https://example.com). Next.js заблокирует все Server Actions с ошибкой CSRF.
Как избежать: Настройте прокси на корректную передачу заголовков X-Forwarded-Host и X-Forwarded-Proto либо пропишите публичный домен в allowedOrigins.
При валидации объекта через z.object({...}) без модификатора .strict() лишние переданные поля не вызывают ошибку. Если разработчик передаст результат в db.user.update({ data: validatedData }), злоумышленник может инжектировать поле role: 'admin'.
Как избежать: Всегда вызывайте метод .strict() на объектах Zod, чтобы исключить несанкционированные свойства.