BackНазад к блогу

Принципы безопасности в Next.js

Узнайте о встроенных механизмах безопасности в Next.js и ознакомьтесь с руководством по аудиту приложений.

React Server Components (RSC) в App Router представляют собой новую парадигму, которая устраняет многие избыточности и потенциальные риски, связанные с традиционными подходами. Учитывая новизну, разработчикам и командам безопасности может быть сложно адаптировать существующие протоколы безопасности к этой модели.

В этом документе выделены ключевые аспекты для внимания, встроенные механизмы защиты и приведено руководство по аудиту приложений. Особое внимание уделено рискам случайного раскрытия данных.

Выбор модели обработки данных

React Server Components стирают границы между сервером и клиентом. Обработка данных играет ключевую роль в понимании того, где информация обрабатывается и становится доступной.

Первым шагом является выбор подходящего подхода к обработке данных для проекта:

Рекомендуется придерживаться одного подхода и не смешивать их слишком часто. Это упрощает работу разработчиков и аудит безопасности, так как исключения становятся заметными.

HTTP API

При внедрении Server Components в существующий проект рекомендуется рассматривать их во время выполнения как ненадежные по умолчанию, аналогично SSR или клиентскому коду. В этом случае не предполагается наличие внутренней сети или зон доверия, применяется концепция Zero Trust. Вместо этого Server Components вызывают пользовательские API-эндпоинты (REST или GraphQL) с помощью fetch(), как если бы код выполнялся на клиенте, передавая соответствующие куки.

Если в проекте использовались getStaticProps/getServerSideProps для подключения к базе данных, рекомендуется консолидировать модель и перенести их в API-эндпоинты для единообразия.

Важно проверить, что контроль доступа не предполагает безопасность внутренних запросов по умолчанию.

Этот подход позволяет сохранить существующую организационную структуру, где специализированные backend-команды могут применять привычные практики безопасности. Если эти команды используют языки, отличные от JavaScript, такой подход также подходит.

Преимущества Server Components сохраняются: меньший объем кода на клиенте и низкая задержка при выполнении последовательных запросов.

Слой доступа к данным (Data Access Layer)

Рекомендуемый подход для новых проектов — создание отдельного слоя доступа к данным в JavaScript-коде и консолидация всех операций с данными в нем. Это обеспечивает единообразие доступа и снижает вероятность ошибок авторизации. Такой подход также упрощает поддержку благодаря централизации в одной библиотеке. Возможны преимущества в виде лучшей сплоченности команды при использовании одного языка программирования, повышения производительности за счет меньших накладных расходов и возможности совместного использования кэша в памяти в рамках одного запроса.

Создается внутренняя JavaScript-библиотека, которая выполняет проверки доступа перед возвратом данных. Аналогично HTTP-эндпоинтам, но в рамках одной модели памяти. Каждый API должен принимать текущего пользователя и проверять его права перед возвратом данных. Принцип заключается в том, что тело функции Server Component должно получать только те данные, к которым у текущего пользователя есть права доступа.

Далее применяются стандартные практики безопасности для реализации API.

data/auth.tsx
import { cache } from 'react';
import { cookies } from 'next/headers';
 
// Кэшированные вспомогательные методы упрощают получение одного значения
// в разных местах без ручной передачи. Это снижает риск передачи данных
// из Server Component в Client Component.
export const getCurrentUser = cache(async () => {
  const token = cookies().get('AUTH_TOKEN');
  const decodedToken = await decryptAndValidate(token);
  // Не включайте секретные токены или приватную информацию в публичные поля.
  // Используйте классы, чтобы избежать случайной передачи всего объекта клиенту.
  return new User(decodedToken.id);
});
data/user-dto.tsx
import 'server-only';
import { getCurrentUser } from './auth';
 
function canSeeUsername(viewer: User) {
  // Пока публичная информация, но может измениться
  return true;
}
 
function canSeePhoneNumber(viewer: User, team: string) {
  // Правила конфиденциальности
  return viewer.isAdmin || team === viewer.team;
}
 
export async function getProfileDTO(slug: string) {
  // Не передавайте значения, читайте кэшированные — это решает проблемы контекста
  // и упрощает ленивую загрузку
 
  // Используйте API базы данных с безопасным шаблонизированием запросов
  const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`;
  const userData = rows[0];
 
  const currentUser = await getCurrentUser();
 
  // Возвращайте только релевантные данные, а не все
  // <https://www.w3.org/2001/tag/doc/APIMinimization>
  return {
    username: canSeeUsername(currentUser) ? userData.username : null,
    phonenumber: canSeePhoneNumber(currentUser, userData.team)
      ? userData.phonenumber
      : null,
  };
}

Эти методы должны возвращать объекты, безопасные для передачи клиенту. Их называют Data Transfer Objects (DTO), чтобы подчеркнуть готовность к использованию клиентом.

На практике они могут использоваться только Server Components. Это создает слои, где аудит безопасности может фокусироваться на Data Access Layer, а UI — быстро итерироваться. Меньшая площадь покрытия и меньше кода упрощают выявление проблем безопасности.

import {getProfile} from '../../data/user'
export async function Page({ params: { slug } }) {
  // Эта страница может безопасно передавать профиль, зная,
  // что он не содержит конфиденциальных данных.
  const profile = await getProfile(slug);
  ...
}

Секретные ключи могут храниться в переменных окружения, но только Data Access Layer должен обращаться к process.env в этом подходе.

Доступ к данным на уровне компонентов

Другой подход — размещение запросов к базе данных непосредственно в Server Components. Подходит только для быстрого прототипирования, например, для небольших продуктов, где все осознают риски.

В этом подходе необходимо тщательно проверять файлы с "use client". При аудите и ревью PR обращайте внимание на экспортируемые функции, принимающие слишком общие объекты (например, User) или пропсы вроде token или creditCard. Даже поля вроде phoneNumber требуют особого внимания. Client Component должен получать только минимально необходимые данные.

import Profile from './components/profile.tsx';
 
export async function Page({ params: { slug } }) {
  const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`;
  const userData = rows[0];
  // УТЕЧКА: Все поля userData передаются клиенту, так как
  // данные передаются из Server Component в Client.
  // Аналогично возврату `userData` в `getServerSideProps`
  return <Profile user={userData} />;
}
'use client';
// ПЛОХО: Интерфейс пропсов принимает больше данных, чем
// необходимо Client Component, что поощряет передачу избыточных данных.
// Лучше принимать ограниченный объект только с необходимыми полями.
export default async function Profile({ user }: { user: User }) {
  return (
    <div>
      <h1>{user.name}</h1>
      ...
    </div>
  );
}

Всегда используйте параметризованные запросы или библиотеки, которые их обеспечивают, чтобы избежать SQL-инъекций.

Только сервер (Server Only)

Код, который должен выполняться только на сервере, можно пометить:

import 'server-only';

Это приведет к ошибке сборки, если Client Component попытается импортировать этот модуль. Позволяет предотвратить случайную утечку конфиденциального кода или бизнес-логики на клиент.

Основной способ передачи данных — протокол React Server Components, который автоматически активируется при передаче пропсов в Client Components. Сериализация поддерживает расширенный JSON. Передача пользовательских классов не поддерживается и вызывает ошибку.

Полезный прием для предотвращения передачи больших объектов — использование class для записей доступа к данным.

В предстоящем релизе Next.js 14 можно опробовать экспериментальные React Taint API, включив флаг taint в next.config.js.

next.config.js
module.exports = {
  experimental: {
    taint: true,
  },
};

Это позволяет пометить объекты, которые нельзя передавать клиенту.

app/data.ts
import { experimental_taintObjectReference } from 'react';
 
export async function getUserData(id) {
  const data = ...;
  experimental_taintObjectReference(
    'Не передавайте пользовательские данные клиенту',
    data
  );
  return data;
}
app/page.tsx
import { getUserData } from './data';
 
export async function Page({ searchParams }) {
  const userData = getUserData(searchParams.id);
  return <ClientComponent user={userData} />; // ошибка
}

Это не защищает от извлечения полей объекта и их передачи:

app/page.tsx
export async function Page({ searchParams }) {
  const { name, phone } = getUserData(searchParams.id);
  // Намеренное раскрытие персональных данных
  return <ClientComponent name={name} phoneNumber={phone} />;
}

Для уникальных значений, таких как токены, можно блокировать передачу самого значения с помощью taintUniqueValue.

app/data.ts
import { experimental_taintObjectReference, experimental_taintUniqueValue } from 'react';
 
export async function getUserData(id) {
  const data = ...;
  experimental_taintObjectReference(
    'Не передавайте пользовательские данные клиенту',
    data
  );
  experimental_taintUniqueValue(
    'Не передавайте токены клиенту',
    data,
    data.token
  );
  return data;
}

Однако это не блокирует производные значения.

Лучше предотвращать попадание данных в Server Components с помощью Data Access Layer. Taint-проверка добавляет дополнительный уровень защиты от ошибок, но помните, что функции и классы уже блокируются при передаче в Client Components. Чем больше уровней защиты, тем ниже риск утечки.

По умолчанию переменные окружения доступны только на сервере. Next.js также раскрывает клиенту переменные с префиксом NEXT_PUBLIC_. Это позволяет явно передавать конфигурацию клиенту.

SSR vs RSC

При первоначальной загрузке Next.js выполняет и Server Components, и Client Components на сервере для генерации HTML.

Server Components (RSC) выполняются в отдельной модульной системе от Client Components, чтобы избежать случайного раскрытия информации между ними.

Client Components, рендерящиеся через Server-side Rendering (SSR), должны рассматриваться с точки зрения политики безопасности как клиент в браузере. Они не должны получать доступ к привилегированным данным или приватным API. Настоятельно не рекомендуется использовать обходные пути (например, хранение данных в глобальном объекте). Принцип заключается в том, что этот код должен выполняться одинаково на сервере и клиенте. В соответствии с практикой безопасности по умолчанию Next.js прерывает сборку, если Client Component импортирует модули server-only.

Чтение

В Next.js App Router чтение данных из базы или API реализуется через рендеринг страниц Server Components.

Входными данными для страниц являются searchParams из URL, динамические параметры и заголовки. Клиент может подменить эти значения. Они не должны доверяться и должны перепроверяться при каждом чтении. Например, searchParam не должен использоваться для параметров вроде ?isAdmin=true. Тот факт, что пользователь находится на /[team]/, не означает автоматический доступ к команде — это нужно проверять при чтении данных. Принцип: всегда перепроверяйте контроль доступа и cookies() при чтении данных. Не передавайте их как пропсы или параметры.

Рендеринг Server Component никогда не должен вызывать побочные эффекты, такие как мутации. Это характерно не только для Server Components. React discourages побочные эффекты даже при рендеринге Client Components (вне useEffect), например, через двойной рендеринг.

Кроме того, в Next.js нет возможности установить куки или вызвать ревалидацию кэша во время рендеринга. Это также препятствует использованию рендеринга для мутаций.

Например, searchParams не должны использоваться для побочных эффектов вроде сохранения изменений или выхода из системы. Для этого следует использовать Server Actions.

Таким образом, модель Next.js при правильном использовании никогда не применяет GET-запросы для побочных эффектов. Это помогает избежать многих проблем CSRF.

Next.js поддерживает Custom Route Handlers (route.tsx), которые могут устанавливать куки для GET. Это считается обходным решением, а не частью основной модели. Они должны явно разрешать GET-запросы. Нет обработчиков по умолчанию, которые могли бы случайно получить GET. Если вы создаете кастомный GET-обработчик, он может потребовать дополнительного аудита.

Запись

Идиоматический способ выполнения записей и мутаций в Next.js App Router — использование Server Actions.

actions.ts
'use server';
 
export function logout() {
  cookies().delete('AUTH_TOKEN');
}

Аннотация "use server" создает эндпоинт, делающий все экспортируемые функции доступными для вызова клиентом. Идентификатор в настоящее время является хэшем местоположения в исходном коде. Если пользователь получает идентификатор действия, он может вызвать его с любыми аргументами.

Поэтому такие функции всегда должны начинаться с проверки прав текущего пользователя. Также необходимо проверять целостность каждого аргумента, вручную или с помощью инструментов вроде zod.

actions.ts
"use server";
 
export async function deletePost(id: number) {
  if (typeof id !== 'number') {
    // Аннотации TypeScript не применяются автоматически,
    // поэтому нужно проверять тип id.
    throw new Error();
  }
  const user = await getCurrentUser();
  if (!canDeletePost(user, id)) {
    throw new Error();
  }
  ...
}

Замыкания (Closures)

Серверные действия (Server Actions) также могут быть закодированы в замыканиях (closures). Это позволяет связать действие со снимком данных, использованных во время рендеринга, чтобы их можно было использовать при вызове действия:

app/page.tsx
export default function Page() {
  const publishVersion = await getLatestVersion();
  async function publish() {
    "use server";
    if (publishVersion !== await getLatestVersion()) {
      throw new Error('Версия изменилась с момента нажатия кнопки публикации');
    }
    ...
  }
  return <button action={publish}>Опубликовать</button>;
}
 

Снимок замыкания должен быть отправлен клиенту и возвращен при вызове сервера.

В Next.js 14 переменные, захваченные замыканием, шифруются с помощью идентификатора действия перед отправкой клиенту. По умолчанию закрытый ключ генерируется автоматически во время сборки проекта Next.js. Каждая пересборка создает новый закрытый ключ, что означает, что каждое серверное действие может быть вызвано только для конкретной сборки. Для обеспечения вызова правильной версии при повторных деплоях можно использовать Защиту от рассинхронизации версий (Skew Protection).

Если требуется ключ, который обновляется чаще или сохраняется между сборками, его можно настроить вручную с помощью переменной окружения NEXT_SERVER_ACTIONS_ENCRYPTION_KEY.

Шифрование всех захваченных переменных предотвращает случайное раскрытие секретов. Подпись делает сложнее для злоумышленника вмешательство во входные данные действия.

Альтернативой замыканиям является использование функции .bind(...) в JavaScript. Эти данные НЕ шифруются. Это позволяет отказаться от шифрования для производительности и согласуется с поведением .bind() на клиенте.

app/page.tsx
async function deletePost(id: number) {
  "use server";
  // проверка id и возможности удаления
  ...
}
 
export async function Page({ slug }) {
  const post = await getPost(slug);
  return <button action={deletePost.bind(null, post.id)}>
    Удалить
  </button>;
}

Принцип заключается в том, что список аргументов серверных действий ("use server") всегда должен рассматриваться как ненадежный, и входные данные необходимо проверять.

CSRF

Все серверные действия могут быть вызваны с помощью простого <form>, что делает их уязвимыми для CSRF-атак. Внутри серверные действия всегда реализуются с использованием POST, и только этот HTTP-метод разрешен для их вызова. Это само по себе предотвращает большинство уязвимостей CSRF в современных браузерах, особенно благодаря тому, что Same-Site cookies используются по умолчанию.

В качестве дополнительной защиты серверные действия в Next.js 14 также сравнивают заголовок Origin с заголовком Host (или X-Forwarded-Host). Если они не совпадают, действие будет отклонено. Другими словами, серверные действия могут быть вызваны только на том же хосте, что и страница, на которой они размещены. Очень старые неподдерживаемые браузеры, которые не поддерживают заголовок Origin, могут быть подвержены риску.

Серверные действия не используют CSRF-токены, поэтому критически важна санитизация HTML.

При использовании пользовательских обработчиков маршрутов (route.tsx) может потребоваться дополнительная проверка, поскольку защита от CSRF должна быть реализована вручную. Там применяются традиционные правила.

Обработка ошибок

Ошибки случаются. Когда ошибки возникают на сервере, они в конечном итоге перебрасываются в клиентский код для обработки в интерфейсе. Сообщения об ошибках и трассировки стека могут содержать конфиденциальную информацию. Например, [номер кредитной карты] не является допустимым номером телефона.

В режиме production React не передает ошибки или отклоненные промисы клиенту. Вместо этого отправляется хэш, представляющий ошибку. Этот хэш можно использовать для группировки одинаковых ошибок и их связи с серверными логами. React заменяет сообщение об ошибке на общее.

В режиме development серверные ошибки по-прежнему отправляются клиенту в виде обычного текста для облегчения отладки.

Важно всегда использовать Next.js в режиме production для рабочих нагрузок. Режим development не оптимизирован для безопасности и производительности.

Пользовательские маршруты и промежуточное ПО (Middleware)

Пользовательские обработчики маршрутов (Custom Route Handlers) и Промежуточное ПО (Middleware) считаются низкоуровневыми механизмами для функций, которые нельзя реализовать с помощью встроенных возможностей. Это также открывает потенциальные проблемы, от которых фреймворк обычно защищает. С большой силой приходит большая ответственность.

Как упоминалось выше, маршруты route.tsx могут реализовывать пользовательские обработчики GET и POST, которые могут быть уязвимы к CSRF, если не настроены правильно.

Промежуточное ПО можно использовать для ограничения доступа к определенным страницам. Обычно лучше использовать белый список, а не черный. Это связано с тем, что сложно учесть все возможные способы доступа к данным, например, при наличии перезаписи или клиентского запроса.

Например, часто учитывается только HTML-страница. Next.js также поддерживает клиентскую навигацию, которая может загружать полезные данные RSC/JSON. В Pages Router это раньше было в пользовательском URL.

Для упрощения написания сопоставителей (matchers) Next.js App Router всегда использует простой URL страницы как для начального HTML, так и для клиентской навигации и серверных действий. Клиентская навигация использует параметр поиска ?_rsc=... в качестве кэш-брейкера.

Серверные действия находятся на странице, где они используются, и наследуют те же правила контроля доступа. Если промежуточное ПО разрешает чтение страницы, можно также вызывать действия на этой странице. Чтобы ограничить доступ к серверным действиям на странице, можно запретить метод POST HTTP для этой страницы.

Аудит

Если вы проводите аудит проекта Next.js App Router, вот несколько моментов, на которые стоит обратить особое внимание:

  • Слой доступа к данным (Data Access Layer). Есть ли устоявшаяся практика изолированного слоя доступа к данным? Проверьте, что пакеты баз данных и переменные окружения не импортируются за пределами слоя доступа к данным.
  • Файлы с "use client". Ожидают ли пропсы компонентов приватные данные? Не слишком ли широки сигнатуры типов?
  • Файлы с "use server". Проверяются ли аргументы действий в самом действии или внутри слоя доступа к данным? Происходит ли повторная авторизация пользователя внутри действия?
  • /[param]/. Папки с квадратными скобками содержат пользовательский ввод. Проверяются ли параметры?
  • middleware.tsx и route.tsx обладают большой мощью. Уделите дополнительное время аудиту этих файлов с использованием традиционных методов. Регулярно проводите тестирование на проникновение (Penetration Testing) или сканирование уязвимостей (Vulnerability Scanning) в соответствии с жизненным циклом разработки вашей команды.