Главная / Статьи / Прекратите утечку полей базы данных: слой доступа к данным для приложений Next.js

Прекратите утечку полей базы данных: слой доступа к данным для приложений Next.js

Узнайте, как слой доступа к данным централизует проверки авторизации и фильтрацию полей в серверных компонентах Next.js, благодаря чему конфиденциальные столбцы никогда случайно не попадают в браузер.

1597 слов

Компоненты сервера позволяют выполнять запросы к базе данных непосредственно из компонента. Однако проблема в том, что любой объект, передаваемый клиентскому компоненту, сериализуется и отправляется в браузер, включая поля, которые вы не собирались отображать. Слой доступа к данным (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 в каждом файле слоя доступа к данным, чтобы неправильный импорт приводил к сбою сборки, а не к отклонению на этапе проверки безопасности.
  • Компоненты сервера упрощают доступ к базе данных; слой доступа к данным обеспечивает, что это удобство не приведёт к тайному утечке информации.

    Связанные материалы