Перейти к содержимому
CodeAuditAgent
Все статьи

Чек-лист безопасности для 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, цитатой строки и предлагаемым патчем; вопросы проектирования — например, какие маршруты вообще должны быть публичными — по-прежнему требуют суждения вашей команды.