Чек-лист безопасности для App Router в Next.js
Чек-лист безопасности для приложений на Next.js App Router: server actions, route handlers, middleware, переменные окружения, CSRF, заголовки, вебхуки, кеш.
· Чтение: 8 мин · Lina Source LLC
App Router перенёс много кода из браузера обратно на сервер. В основном это выигрыш для безопасности: запросы, секреты и бизнес-логика теперь выполняются там, где пользователи их не прочитают. Но он же размыл границу между тем, что является публичным эндпоинтом, и тем, что лишь выглядит как вызов функции. Большинство серьёзных ошибок в приложениях на Next.js растут именно из этой размытости.
Этот чек-лист охватывает проблемы, которые мы чаще всего видим в проектах на App Router, в том порядке, в каком их стоит проверять. Ничего экзотического: каждый пункт — место, где удобство фреймворка прячет границу доверия.
1. Server actions — это публичные эндпоинты
Функция, помеченная 'use server', компилируется в HTTP-эндпоинт. Любой, кто может открыть ваш сайт, найдёт идентификатор действия и вызовет его с произвольными аргументами независимо от того, отрисовывает ли ваш интерфейс кнопку, которая им пользуется. Спрятать форму от не-администраторов — не значит защитить действие за ней.
Каждому действию нужны те же три шага, что и любому обработчику API: аутентифицировать вызывающего, проверить входные данные и авторизовать доступ именно к той записи, которую он трогает. Разместите эти проверки внутри тела самого действия. Проверка в компоненте страницы, который отрисовывает форму, выполняется при загрузке страницы, а не при вызове действия, поэтому она ничего не защищает.
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
const Input = z.object({
projectId: z.string().uuid(),
name: z.string().trim().min(1).max(100),
});
export async function renameProject(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
const { projectId, name } = Input.parse({
projectId: formData.get('projectId'),
name: formData.get('name'),
});
// Проверка принадлежности встроена в запись, а не вынесена в отдельный запрос
const result = await db.project.updateMany({
where: { id: projectId, ownerId: session.user.id },
data: { name },
});
if (result.count === 0) throw new Error('Not found');
revalidatePath('/projects');
}Следите за вспомогательными файлами, которые экспортируют много действий. Каждая экспортированная функция в модуле с 'use server' вызываема, включая ту, которую кто-то добавил для внутреннего скрипта и забыл о ней.
2. Route handlers требуют тех же проверок
Route handlers в app/api выглядят эндпоинтами куда очевиднее, но подвержены той же ошибке: динамический сегмент используется для загрузки записи без проверки того, кому она принадлежит. В свежих версиях Next.js params — это Promise; дождитесь его, проверьте и ограничьте запрос вызывающим пользователем.
// app/api/documents/[id]/route.ts
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: 'unauthorized' }, { status: 401 });
}
const { id } = await params;
const doc = await db.document.findFirst({
where: { id, ownerId: session.user.id },
});
if (!doc) return Response.json({ error: 'not_found' }, { status: 404 });
return Response.json(doc, {
headers: { 'Cache-Control': 'private, no-store' },
});
}Для записей, которые вызывающему не принадлежат, возвращайте 404, а не 403, чтобы эндпоинт не подтверждал, какие идентификаторы существуют. Никогда не доверяйте params, searchParams, заголовкам и cookie при решениях об авторизации: всё это ввод, контролируемый атакующим.
3. Middleware сама по себе не граница авторизации
Middleware (в Next.js 16 переименованная в proxy) полезна для перенаправления вышедших из системы пользователей и установки заголовков. Она не должна быть единственным местом, где происходит авторизация. В matcher легко ошибиться, новые маршруты появляются вне их охвата, а server actions отправляют запрос на путь страницы, который может не совпасть с задуманным вами шаблоном. В 2025 году CVE-2025-29927 показала, что подделанный внутренний заголовок мог заставить некоторые версии Next.js полностью пропустить middleware.
Относитесь к middleware как к слою удобства. Настоящая проверка живёт рядом с данными: в каждом действии и обработчике, а ещё лучше — в слое доступа к данным, через который проходит любое серверное чтение. Слой доступа к данным — это единственный серверный модуль, который предоставляет функции вроде getProjectForUser(projectId) и выполняет проверку сессии и фильтр принадлежности внутри себя. Страницы, действия и обработчики маршрутов вызывают его вместо клиента базы данных напрямую, поэтому новый маршрут не может забыть проверку: забывать просто нечего, непроверенного пути не существует.
4. Держите серверный код на сервере
Любая переменная окружения с префиксом NEXT_PUBLIC_ встраивается в JavaScript-бандл во время сборки и видна каждому посетителю. Для публикуемого ключа Stripe или идентификатора аналитики это нормально, для всего остального — утечка. Поищите в проекте NEXT_PUBLIC_ и убедитесь, что каждое такое значение было бы безопасно разместить на билборде. Обратная ошибка тоже случается: переменную без префикса читают в клиентском компоненте, в браузере она оказывается undefined, и кто-то переименовывает её в NEXT_PUBLIC_, чтобы ошибка ушла. Если значение нужно в браузере, сначала спросите себя, должно ли оно вообще там быть.
// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
export const getCurrentUser = cache(async () => {
const session = await auth();
if (!session?.user) return null;
// Возвращаем только нужные интерфейсу поля, а не всю строку
return db.user.findUnique({
where: { id: session.user.id },
select: { id: true, name: true, plan: true },
});
});Пакет server-only ломает сборку, если модуль импортируется клиентским компонентом, и тем самым не даёт клиентам базы данных и помощникам, читающим секреты, оказаться в браузере. Следите и за тем, что серверные компоненты передают клиентским как пропсы: всё, что пересекает эту границу, сериализуется в страницу, поэтому передача целой строки пользователя отправляет в браузер хеш его пароля и внутренние флаги.
5. CSRF: знайте, что покрывает фреймворк
Server actions принимают только POST, и Next.js сравнивает заголовок Origin с хостом, прежде чем их выполнить. Если вы разворачиваете приложение за прокси или на нескольких доменах, настраивайте serverActions.allowedOrigins осознанно, а не расширяйте список, пока не пропадут ошибки.
Route handlers такой защиты не получают. Если обработчик POST, PUT или DELETE аутентифицируется по cookie, форма на другом сайте всё ещё может отправить в него запрос. Проверки типа содержимого самой по себе недостаточно: HTML-форма умеет отправлять тела urlencoded, multipart и text/plain без предварительного запроса, а обработчик, который разбирает тело снисходительно, их примет. Устанавливайте cookie сессии с SameSite=Lax или Strict, никогда не меняйте состояние по GET и проверяйте заголовок Origin в изменяющих запросах с аутентификацией по cookie. Обработчики, которые принимают только bearer-токен в заголовке Authorization, классическому CSRF не подвержены.
6. Заголовки безопасности и CSP
Next.js по умолчанию отдаёт очень мало заголовков безопасности. Добавьте их в next.config через функцию headers() или в middleware, когда для строгой Content-Security-Policy нужен nonce на каждый запрос.
- Content-Security-Policy, в идеале на основе nonce, с object-src 'none' и base-uri 'self'.
- Strict-Transport-Security с большим max-age, когда HTTPS везде работает надёжно.
- X-Content-Type-Options: nosniff.
- Referrer-Policy: strict-origin-when-cross-origin.
- frame-ancestors в CSP или X-Frame-Options — для защиты от кликджекинга.
- poweredByHeader: false в next.config, чтобы убрать заголовок X-Powered-By.
7. Ограничение частоты запросов
Встроенного ограничителя частоты нет. Вход, регистрация, сброс пароля, проверка одноразового кода и всё, что стоит денег за вызов (письма, SMS, запросы к ИИ), требуют лимитов на пользователя и на IP. Счётчики в памяти не работают в бессерверных и многоинстансных развёртываниях; используйте общее хранилище вроде Redis или вашу базу данных. Применяйте лимиты внутри действия или обработчика, где известен аутентифицированный пользователь, а не только в middleware. Привязывайте лимиты к тому, что атакующий не может дёшево менять: к аккаунту для аутентифицированных маршрутов и к целевому адресу почты или номеру телефона для сброса пароля и одноразовых кодов — в дополнение к IP. Возвращайте 429 с заголовком Retry-After, чтобы легитимные клиенты корректно снижали нагрузку.
8. Проверяйте вебхуки по сырому телу запроса
Подписи вебхуков вычисляются по тем самым байтам, которые отправил провайдер. Разбор тела как JSON и повторная сериализация меняют эти байты и ломают проверку, из-за чего возникает соблазн её пропустить. В обработчике маршрута читайте тело через req.text() и проверяйте подпись до всего остального.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) return new Response('Missing signature', { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response('Invalid signature', { status: 400 });
}
// Обработчики должны быть идемпотентны: провайдеры повторяют доставку
await handleStripeEvent(event);
return new Response('ok');
}Сохраняйте идентификаторы обработанных событий с уникальным ограничением, чтобы повторная или воспроизведённая доставка не выдала подписку дважды, и обрабатывайте события, приходящие не по порядку, поскольку провайдеры не гарантируют порядок доставки.
9. Не кешируйте пользовательские данные глобально
App Router кеширует агрессивно, и значения по умолчанию менялись от версии к версии. Функция, обёрнутая в unstable_cache или 'use cache', которая возвращает данные «текущего пользователя», но не включает его идентификатор в ключ кеша, отдаст данные одного пользователя следующему. То же касается страниц, отрисованных статически там, где они должны были быть динамическими, и кеширования ответов API на CDN. Проверяйте это напрямую: войдите под двумя разными пользователями в двух браузерах и откройте одни и те же страницы. Всё, что показывает второму пользователю данные первого, — это ошибка кеширования, и обычно серьёзная.
- Передавайте идентификатор пользователя или арендатора в кешируемые функции явно, чтобы он стал частью ключа.
- Не читайте cookie и заголовки внутри кешируемых функций; читайте их снаружи и передавайте значения внутрь.
- Отдавайте Cache-Control: private, no-store в ответах, содержащих данные конкретного пользователя.
- Проверяйте вывод сборки: маршруты, которые должны быть динамическими, не должны значиться как статические.
Как пройти по чек-листу
Идите по списку по маршрутам, а не по файлам: для каждого действия и обработчика — кто может его вызвать, каким входным данным он доверяет, что кеширует и что возвращает. CodeAuditAgent может сделать первый проход по публичному репозиторию GitHub или вставленному фрагменту, описав каждую находку с критичностью, CWE, цитатой строки и предлагаемым патчем; вопросы проектирования — например, какие маршруты вообще должны быть публичными — по-прежнему требуют суждения вашей команды.