Прекратите утечку полей базы данных: слой доступа к данным для приложений Next.js
Узнайте, как слой доступа к данным централизует проверки авторизации и фильтрацию полей в серверных компонентах Next.js, благодаря чему конфиденциальные столбцы никогда случайно не попадают в браузер.
Компоненты сервера позволяют выполнять запросы к базе данных непосредственно из компонента. Однако проблема в том, что любой объект, передаваемый клиентскому компоненту, сериализуется и отправляется в браузер, включая поля, которые вы не собирались отображать. Слой доступа к данным (DAL) находится между компонентами и базой данных, что позволяет выполнять аутентификацию, авторизацию и фильтрацию полей в одном месте. Здесь объясняется, как его структурировать, что к нему должно входить и когда стоит добавлять дополнительные файлы.
Что такое слой доступа к данным
DAL — это специальная папка, в которой хранятся все запросы к базе данных. Компоненты никогда не обращаются к Prisma напрямую; они вызывают такие функции, как getProfile, которые определяют, что может увидеть вызывающий компонент и какие поля будут возвращены. Можно рассматривать её как контрольную точку на пути к интерфейсу: она проверяет, кто запрашивает данные, и удаляет всё, что не нужно на экране. В документации Next.js по безопасности данных рекомендуется использовать этот подход для новых проектов.
Типичная структура предполагает размещение инструментов аутентификации рядом с модулями запросов для каждой области:
src/
data/
auth.ts # Authentication helpers
user.ts # User queries
posts.ts # Post queries
Правило, обеспечивающее работу этой схемы, простое: ничто за пределами папки data/ не импортирует клиент базы данных.
Как необработанные запросы приводят к утечке данных
Прямой запрос возвращает всю строку. В приведённом ниже фрагменте компонент загружает информацию о пользователе и передаёт результат непосредственно в карточку:
// Without DAL - directly in a component
const user = await prisma.user.findUnique({ where: { id } })
return <ProfileCard user={user} />
Этот объект содержит все столбцы: хэш пароля, электронную почту, номер телефона, внутренние идентификаторы. Если ProfileCard является клиентским компонентом, весь объект сериализуется в данные страницы, и любой, у кого открыта вкладка сети, может его прочитать, даже если карточка отображает только имя.
Решение заключается в возврате специально созданного объекта вместо сырого записи. Эта функция DAL выбирает три поля, которые действительно используются при отображении профиля:
// data/user.ts
export async function getProfile(id: string) {
const user = await prisma.user.findUnique({ where: { id } })
return {
name: user.name,
avatar: user.avatar,
bio: user.bio
}
}
Компонент вызывает getProfile(id) и получает защищенный объект; хэш пароля остается на сервере. Также можно использовать фильтрацию в запросе с помощью select от Prisma, а также необходимо обрабатывать ситуацию, когда findUnique возвращает null, поскольку иначе возникнет ошибка с user.name.
Почему безопасность на уровне компонентов нарушается
Большее риско в постоянно расширяющейся базе кода заключается в том, что каждый компонент реализует собственные правила доступа, в результате чего они начинают отклоняться друг от друга. Представьте двух разработчиков, работающих над одним приложением. Первый создает страницу профиля с использованием функции DAL, предназначенной для проверки авторизации (в приведённом фрагменте повторяется вышеупомянутая отфильтрованная функция, но проверка пока отсутствует; защищённая версия приведена в следующем разделе):
// data/user.ts
export async function getProfile(id: string) {
const user = await prisma.user.findUnique({ where: { id } })
return {
name: user.name,
avatar: user.avatar,
bio: user.bio
}
}
Второй разработчик создаёт страницу настроек и напрямую обращается к Prisma, считывая ID пользователя из URL, при этом никогда не проверяя, вошёл ли пользователь в систему:
// pages/settings/page.tsx - Developer B wrote this
export default async function SettingsPage({ searchParams }) {
// No auth check!
const user = await prisma.user.findUnique({
where: { id: searchParams.id }
})
// Returns everything, including sensitive fields
return <Settings user={user} />
}
Сейчас есть две проблемы: отсутствие аутентификации, из-за чего любой, кто может изменить параметр id, может загрузить настройки другого человека, а также в интерфейс передаются все данные записи, включая конфиденциальные поля. (Несмотря на комментарий pages/, это страница App Router внутри папки app/). Поскольку логика безопасности распределена по разным страницам, достаточно одной забытой проверки, и при аудите необходимо проверить каждый компонент, который взаимодействует с базой данных.
Сосредоточение аутентификации и фильтрации в одном месте
Благодаря DAL каждый вызов проходит через одни и те же проверки. Функция getCurrentUser считывает информацию из сессии и возвращает пользователя или null; функция requireAuth перенаправляет на страницу входа, если никто не авторизован. Обе функции обернуты в механизм cache React, поэтому повторные вызовы в рамках одного запроса используют первый полученный результат:
// data/auth.ts
import { cache } from 'react'
export const getCurrentUser = cache(async () => {
const session = await getSession()
if (!session) return null
return session.user
})
export const requireAuth = cache(async () => {
const user = await getCurrentUser()
if (!user) redirect('/login')
return user
})
Импорты пропущены: getSession взят из библиотеки аутентификации, а redirect — из next/navigation.
getProfile теперь требует входящего пользователя и отображает электронную почту только владельцу профиля:
// data/user.ts
import 'server-only'
import { requireAuth } from './auth'
export async function getProfile(id: string) {
const viewer = await requireAuth()
const user = await prisma.user.findUnique({ where: { id } })
return {
name: user.name,
avatar: user.avatar,
email: viewer.id === user.id ? user.email : null
}
}
Каждый вызывающий получает возможности аутентификации и фильтрации бесплатно; второй разработчик не может пропустить встроенную в функцию проверку. Обратите внимание, что requireAuth отвечает только на вопрос «входит ли кто-то в систему?». Возможность этого пользователя редактировать пост — это уже вопрос авторизации, который также должен решаться в DAL. Для более подробного рассмотрения этого разделения см. где должны находиться механизмы аутентификации и авторизации.
Страницы становятся лаконичными. Они загружают данные через DAL и отображают их:
// Any component - simple and secure
export default async function ProfilePage({ params }) {
const profile = await getProfile(params.id)
return <Profile profile={profile} />
}
В компоненте отсутствует код безопасности, и при аудите достаточно проверить папку data/, вместо того чтобы изучать сотни компонентов. В новых версиях Next.js параметр params представляет собой Promise, поэтому сначала необходимо выполнить операцию await.
Параллельный запрос к функциям DAL
Дашборды часто выполняют независимые запросы один за другим:
// Sequential fetching - slow
export default async function Dashboard() {
const user = await prisma.user.findUnique({ where: { id } })
const posts = await prisma.post.findMany({ where: { authorId: id } })
const stats = await prisma.stats.findFirst({ where: { userId: id } })
return <DashboardUI user={user} posts={posts} stats={stats} />
}
Каждая операция await ожидает завершения предыдущей, поэтому три запроса по 100 мс занимают примерно 300 мс. Поскольку они независимы, эта функция DAL запускает их одновременно с помощью Promise.all и соответствует результаты необходимым полям:
// data/dashboard.ts
export async function getDashboardData(userId: string) {
const [user, posts, stats] = await Promise.all([
prisma.user.findUnique({ where: { id: userId } }),
prisma.post.findMany({ where: { authorId: userId } }),
prisma.stats.findFirst({ where: { userId } })
])
return {
user: { name: user.name, avatar: user.avatar },
posts: posts.map(p => ({ id: p.id, title: p.title })),
stats: { views: stats.views, followers: stats.followers }
}
}
Общее время выполнения сокращается до уровня самого медленного запроса — примерно 100 мс. Положительный эффект достигается благодаря Promise.all, а не самому DAL, но использование функции для обработки данных делает структуру кода последовательной и позволяет выполнять фильтрацию одновременно с получением данных. Зависимые запросы по-прежнему вынуждены ждать. Кроме того, поскольку requireAuth использует cache(), многие компоненты могут вызывать функции DAL с авторизацией, при этом сессия загружается только один раз на запрос.
Защита слоя с помощью модулей только для сервера
Каждый файл в DAL следует начинать с такого импорта:
import 'server-only'
Пакет server-only приводит к сбою сборки, если модуль, импортирующий его, оказывается в коде клиента; поэтому функция DAL, случайно импортированная в компонент клиента, вызывает явную ошибку ещё до запуска в производственной среде. Эта конвенция становится частью правил, налагаемых инструментами разработки.
Что должно находиться внутри DAL
Каждая функция DAL должна выполнять четыре основные задачи:
- Аутентификация: проверка того, что существует входящий пользователь, с использованием кэшированных помощников для того, чтобы проверка выполнялась один раз на запрос.
- Авторизация: подтверждение того, что данный пользователь имеет право на доступ к конкретной записи, например, может ли он просматривать профиль или редактировать пост.
- Фильтрация: возвращение только тех полей, которые отображаются на экране. Если вы не уверены, нужно ли тот или иной поле, просто опустите его.
- Конфиденциальная информация: только DAL должна читать конфиденциальные настройки, такие как
DATABASE_URL, что позволяет избежать размещения деталей подключения в коде компонентов.
Для действий на сервере следует использовать те же функции, чтобы операции записи проходили через те же проверки, что и операции чтения; см. авторизацию внутри каждого действия на сервере.
Когда DAL действительно необходим
Используйте его для всего, что связано с обработкой данных пользователей, аутентификацией или конфиденциальной информацией; в производственном приложении с учетными записями это стандарт, а не дополнение. Откажитесь от него только для временных прототипов или статических сайтов без данных пользователей, но даже в таких случаях привычка возвращать явные объекты — это полезная практика, которую стоит сохранить.
Основные выводы
- Все, что попадает в клиентский компонент, также попадает в браузер, поэтому никогда не передавайте сырые строки из базы данных через этот барьер.
- Проходите каждый запрос через папку
data/, которая отвечает за аутентификацию, авторизацию и выбор полей.
cache React, чтобы несколько вызовов слоя доступа к данным стоили одного чтения сессии на запрос.Promise.all внутри функций слоя доступа к данным для выполнения независимых запросов.server-only в каждом файле слоя доступа к данным, чтобы неправильный импорт приводил к сбою сборки, а не к отклонению на этапе проверки безопасности.Компоненты сервера упрощают доступ к базе данных; слой доступа к данным обеспечивает, что это удобство не приведёт к тайному утечке информации.
Связанные материалы
- Разделение слоёв домена, данных и пользовательского интерфейса в кодбазе Next.js App Router — пример из проекта Pokédex, показывающий, как разделить приложение Next.js App Router на слои домена, данных и представления с использованием Prisma, Zod, аутентификации через куки и кэширования.
- 20 передовых шаблонов Next.js 16 для архитектуры приложений высокого уровня — обзор подходов серверного первенства, кэширования, стриминга, технологии PPR, параллельных и перехватывающих маршрутов, а также других шаблонов для создания масштабируемых приложений на Next.js 16.
- Сравнительный анализ сценариев сбоев: Next.js, Remix и устаревшие данные авторизации — пример того, как ошибка с устаревшими разрешениями влияет на выбор между Next.js и Remix, а также способы оценки фреймворков с точки зрения обработки изменений, сроков действия кэша и удобства отладки.