CSRF и безопасность экшенов: каждый server action — публичный POST-эндпоинт
Каждый server action — публичный POST-эндпоинт. Next сверяет Origin с Host — это останавливает межсайтовую подделку и ничего больше, а route handlers не получают и этого. Аутентификация, по-строчная авторизация, zod-валидация и rate limiting живут внутри каждого экшена.
Второй день пентеста: тестировщик перестаёт кликать по UI и открывает вкладку Network. Каждый server action в приложении — это POST на текущий URL с заголовком Next-Action, несущим непрозрачный id, — а сами id лежат прямо в публичном JS-бандле. Первая попытка: поддельная форма на внешнем хосте, постящая в приложение. Заблокирована — Next сравнил заголовок Origin с Host и отверг расхождение; CSRF-проверка фреймворка отрабатывает свой хлеб. Вторая попытка: воспроизвести экшен «переименовать проект» из собственной залогиненной сессии тестировщика, подменив аргумент projectId на проект чужого тенанта. Проходит. Экшен аутентифицировал вызывающего и ни разу не спросил, можно ли этому вызывающему трогать эту строку — юнит 02 называл это паттерном IDOR, и он переживает любую защиту фреймворка, потому что это не подделка: это совершенно подлинный запрос, делающий то, что ему никогда не должны были позволить. Итоговая строка отчёта пишется сама: «Фреймворк остановил атаки, нацеленные на фреймворк. Атаки, нацеленные на приложение, не остановил никто».
Экшен — публичный POST-маршрут с хешем вместо имени
Прежде чем написать следующий server action, подумайте, что с ним может сделать атакующий, у которого уже есть валидная сессия — и заметьте, что фреймворк ничего не делает, чтобы ограничить это. Всё дальнейшее следует из этого пробела.
Снимите RPC-иллюзию: когда вы экспортируете функцию из файла с 'use server', Next.js создаёт HTTP-эндпоинт. Клиент вызывает его POST-ом на URL страницы с заголовком Next-Action, содержащим id экшена; аргументы едут сериализованными в теле. Отсюда три свойства, каждое с весом для безопасности. Достижимость не закрывается UI: экшен вызываем независимо от того, рендерит ли его хоть одна кнопка — «мы убрали кнопку удаления для не-админов» меняет пиксели, а не эндпоинт. Каждый экспортированный экшен из каждого файла с 'use server' — эндпоинт, включая тот, чей UI вы удалили в прошлом спринте; Next смягчает это устранением мёртвого кода для неиспользуемых id, но экшен, на который где-то осталась ссылка, жив, — так что считайте экспортированный синонимом публичный. Id экшенов — не секреты: они недетерминированы между сборками, но лежат в JS-бандле, который скачивает каждый посетитель. Безопасность через неугадываемые id живёт ровно до первого открытого devtools.
Один нюанс играет за вас: значения из замыканий шифруются. Если экшен, определённый внутри компонента, замыкает переменную из области рендера, Next шифрует эти значения ключом сборки перед круговым путешествием через клиента. Но аргументы — всё, что клиент передаёт при вызове, — это контролируемый атакующим открытый текст, ровно как тело запроса, потому что они им и являются.
CSRF-защита, которую вы получаете, и её точные края
Полезно быть точным в том, что такое CSRF (Cross-Site Request Forgery — межсайтовая подделка запроса с чужого origin верхом на чужих cookie), потому что точное определение сразу показывает, что он не покрывает — а именно в этом пробеле живут реальные инциденты.
Классический CSRF: evil.com автоматически отправляет форму на ваш origin из браузера жертвы, верхом на её cookie. Next.js даёт экшенам два слоя защиты. Первый: экшены принимают только POST — а сессионные cookie с SameSite=Lax (урок 01) на межсайтовые POST-ы не отправляются вовсе, так что в современных браузерах поддельный запрос обычно приходит анонимным. Второй: Next сравнивает заголовок Origin с Host (X-Forwarded-Host) на каждом вызове экшена и отвергает расхождения. Это закрывает дыры, которые оставляет слой cookie: старые SameSite=None, тонкие same-site-случаи — в частности, поддельный POST с братского поддомена (status.example.com → app.example.com) является same-site, и Lax-cookie едут с ним — но Origin всё равно отличается от Host, и проверка экшенов его отвергает.
Теперь края — потому что инциденты живут именно там. Край первый: проверка защищает только экшены. Ваши route handlers — app/api/*/route.ts — не получают никакой автоматической CSRF-защиты; POST-handler, полагающийся на cookie-аутентификацию с SameSite=None, подделывается очевидно, и даже с Lax считайте CSRF обработчиков своей собственной проблемой. Край второй: прокси могут сломать или ослабить её. Если приложение стоит за reverse-proxy, переписывающим Host, легитимные запросы проваливают сравнение — а документированный люк, serverActions.allowedOrigins, это список, который пентесты находят набитым вайлдкардами, добавленными в три часа ночи во время отладки. Каждая запись — это origin, которому вы объявили доверие подделывать запросы. Край третий — тот, что из Hook: CSRF-защита — о том, откуда пришёл запрос, а не о том, что ему позволено делать. Аутентифицированный пользователь, атакующий вашу модель авторизации, проплывает мимо любой проверки Origin, потому что в его запросе нет ничего поддельного.
Приложение использует server actions для внутренних мутаций и route handler POST /api/export для легаси-интеграции, с cookie-аутентификацией. Какую CSRF-поверхность фреймворк реально закрывает?
Авторизуй внутри, валидируй на границе, ограничивай частоту мутаций
Всё, чего фреймворк знать не может — кто что может делать с какой строкой, как выглядит валидный payload, с какой частотой мутация правдоподобна, — обязано жить внутри экшена, каждого экшена, без исключений для «внутренних». Дисциплина сжимается в конвейер; урок о server actions в юните 02 показал его скелет — вот полная бронированная версия:
'use server';
import { z } from 'zod';
import { notFound } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { verifySession } from '~/lib/dal';
import { ratelimit } from '~/lib/ratelimit';
import { db } from '~/lib/db';
const RenameInput = z.object({
projectId: z.string().uuid(),
name: z.string().min(1).max(120),
});
export async function renameProject(raw: unknown) {
const session = await verifySession(); // 1. аутентифицировать
const { success } = await ratelimit.limit(session.userId); // 2. напр. 20 записей / 10 с
if (!success) throw new Error('Too many requests');
const input = RenameInput.parse(raw); // 3. провод нетипизирован
const project = await db.project.findUnique({ where: { id: input.projectId } });
if (project?.ownerId !== session.userId) notFound(); // 4. авторизовать ЭТУ строку
await db.project.update({ where: { id: input.projectId }, data: { name: input.name } });
revalidatePath(`/projects/${input.projectId}`); // 5. сказать кешам
}Шаг 3 заслуживает отдельного предложения: тип raw: unknown — единственно честный. Ваша TypeScript-сигнатура — фикция этапа компиляции; в рантайме тело — это те байты, которые прислал вызывающий, и RenameInput.parse — момент, когда фикция становится проверенным фактом. Валидация заодно закрывает mass assignment: экшен, распыляющий ...data в update, позволяет сконструированному payload-у выставить role: 'admin', если схема не задаёт allowlist полей. Шаг 4 — та самая IDOR-проверка из Hook: владение сверяется по реальной строке, а не по тому, какие кнопки отрендерились. Шаг 2 признаёт, что мутации — дорогая и злоупотребляемая поверхность: экшен логина без rate limiting — бесплатный оракул для credential stuffing (типичная политика: 5 попыток за 15 минут на IP+идентификатор), а дорогая мутация без лимитов — приглашение к denial-of-wallet; sliding-window-лимиты в KV стоят ~1 мс и прекрасно работают в экшенах. Применим тот же паттерн общего враппера вокруг verifySession из урока про DAL: закодируйте конвейер один раз, сделайте безопасный путь ленивым путём — и ревьюерам останется выслеживать только мутации в обход враппера.
▸Почему это работает
Почему фреймворк не авторизует за вас? Потому что авторизация — доменное утверждение, а не свойство транспорта. Next.js видит заголовки — и проверяет заголовки: Origin против Host. Он не может знать, что у проектов есть владельцы, что владельцам можно переименовывать, а зрителям нельзя, или что role — не выставляемое клиентом поле. Каждый фреймворк проводит ту же черту; с server actions меняется соблазн о ней забыть, потому что await renameProject(id) ощущается локальным вызовом функции, а десять лет инстинктов говорят, что локальным вызовам доверяют. Провод не согласен: это POST из враждебной сети, носящий имя вашей функции.
Экшен deleteDocument(docId) вызывает verifySession(), затем db.document.delete({ where: { id: docId } }). Кнопка удаления рендерится только владельцам документа. Пентестер удаляет чужой документ. Как?
- 01Какую CSRF-защиту получают server actions и каковы её три края?
- 02Перечислите пятишаговый конвейер каждого мутирующего экшена и атаку, которую убивает каждый шаг.
Экспорт функции из файла с ‘use server’ создаёт HTTP-эндпоинт: клиент шлёт POST на URL страницы с Next-Action-id из публичного бандла, а аргументы приходят открытым текстом под контролем атакующего — пер-сборочное шифрование получают только значения из замыканий. Достижимость не связана с UI: убранные кнопки, рендер только для админов и неиспользуемые-но-экспортированные экшены — всё это живые эндпоинты, так что экспортированный значит публичный. CSRF-история фреймворка для экшенов — два настоящих слоя: SameSite=Lax-cookie, остающиеся дома при межсайтовых POST-ах, и сравнение Origin с Host на каждом вызове, ловящее даже подделки с братских поддоменов, куда Lax-cookie ещё едут, — и три точных края: route handlers не получают ничего и приносят свою CSRF-защиту; прокси, переписывающие Host, ломают сравнение, а каждая запись serverActions.allowedOrigins — origin, которому вы объявили доверие подделывать; и проверка устанавливает происхождение, а не права — аутентифицированный атакующий для неё невидим. Этот последний край — вечно повторяющаяся находка пентестов: IDOR (Insecure Direct Object Reference — прямое обращение к чужому объекту через подменённый id), валидная сессия, наводящая настоящий экшен на чужую строку. Броня — пятишаговый конвейер внутри каждого мутирующего экшена: verifySession для аутентификации; rate limit, потому что логин без него — оракул credential stuffing, а дорогие мутации — приглашение к denial-of-wallet; zod-парсинг payload-а, честно типизированного unknown, заодно закрывающий mass assignment через allowlist полей; по-строчное сравнение владения перед записью — единственная строка, останавливающая IDOR; затем ревалидация. Теперь, когда пишете server action, принимающий id от вызывающего, первое, что нужно представить — пентестер, подменяющий этот id на чужой. Проверка владения — единственное, что стоит между ним и данными.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.