Strona główna / Artykuły / Zatrzymaj wyciek pól bazy danych: warstwa dostępu do danych dla aplikacji Next.js

Zatrzymaj wyciek pól bazy danych: warstwa dostępu do danych dla aplikacji Next.js

Dowiedz się, w jaki sposób warstwa dostępu do danych centralizuje sprawdzanie autoryzacji i filtrowanie pól w komponentach serwerowych Next.js, dzięki czemu wrażliwe kolumny nigdy przypadkowo nie trafiają do przeglądarki.

1597 słów

Komponenty serwerowe umożliwiają wyszukiwanie danych w bazie bezpośrednio z komponentu. Problem polega na tym, że każdy obiekt przekazany do komponentu klienckiego jest serializowany i wysyłany do przeglądarki, włączając pola, których wcale nie chcieliśmy pokazać. Warstwa dostępu do danych (DAL) znajduje się pomiędzy komponentami a bazą danych, dzięki czemu autoryzacja, uprawnienia oraz filtrowanie pól odbywają się w jednym miejscu. Poniżej opisano, jak ją strukturyzować, co powinno się w niej znajdować oraz kiedy warto dodać dodatkowe pliki.

Czym jest warstwa dostępu do danych

A DAL to dedykowana folder, który przechowuje każdą zapytanie do bazy danych. Komponenty nigdy nie wywołują bezpośrednio Prismy; zamiast tego używają funkcji takich jak getProfile, które decydują, co może zobaczyć wywołujący i jakie pola mają zostać zwrócone. Można to porównać do punktu kontrolnego w drodze do interfejsu użytkownika: sprawdza on, kto zadaje zapytanie, i usuwa wszystko, czego ekran nie potrzebuje. Dokumentacja dotycząca bezpieczeństwa danych w Next.js zaleca ten podejście dla nowych projektów.

Typowa struktura umieszcza narzędzia do autoryzacji obok modułów zapytań dla każdej domeny:

src/
  data/
    auth.ts     # Authentication helpers
    user.ts     # User queries
    posts.ts    # Post queries

Zasada, która umożliwia to działanie, jest prosta: nic poza katalogiem data/ nie importuje klienta bazy danych.

Jak surowe zapytania wyciekają dane

Zapytanie bezpośrednie zwraca cały wiersz. W poniższym fragmencie komponent pobiera użytkownika i przekazuje wynik bezpośrednio do karty:

// Without DAL - directly in a component
const user = await prisma.user.findUnique({ where: { id } })
return <ProfileCard user={user} />

To obiekt zawiera każdą kolumnę: hasło, adres e-mail, numer telefonu oraz identyfikatory wewnętrzne. Jeśli ProfileCard jest komponentem klienta, cały obiekt jest zserializowany do treści strony, więc każdy, kto ma otwartą kartę sieciową, może go odczytać, nawet jeśli karta wyświetla tylko imię.

Rozwiązaniem jest zwrócenie specjalnie stworzonego obiektu zamiast surowego rekordu. Ta funkcja DAL wybiera trzy pola, których faktycznie używa widok profilu:

// 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
  }
}

Komponent wywołuje getProfile(id) i otrzymuje bezpieczny obiekt; hasło pozostaje na serwerze. Można również filtrować dane w zapytaniu za pomocą select w Prismie, a także należy obsłużyć sytuację, gdy findUnique zwraca null, ponieważ w przeciwnym razie user.name spowoduje błąd.

Dlaczego zasady bezpieczeństwa na poziomie komponentów zawodzą

// 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
  }
}

// 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} />
}

Teraz występują dwa błędy: brak autoryzacji, więc każdy, kto edytuje parametr id, może załadować ustawienia innej osoby, a cała informacja, włączając pola poufne, trafia do interfejsu użytkownika. (Mimo komentarza pages/, jest to strona App Router znajdująca się w katalogu app/). Ponieważ logika bezpieczeństwa jest rozproszona między różnymi stronami, wystarczy jeden zapomniany sprawdzenie, a audyt musi przeanalizować każdy komponent, który ma dostęp do bazy danych.

Umieszczenie autoryzacji i filtrowania w jednym miejscu

Dzięki DAL każdy wywołujący funkcję przechodzi przez te same sprawdzenia. getCurrentUser odczytuje sesję i zwraca użytkownika lub null; requireAuth kieruje na stronę logowania, gdy nikt nie jest zalogowany. Obie funkcje są otoczone mechanizmem cache z React, dzięki czemu powtarzane wywołania w ramach jednej prośby korzystają z pierwszego wyniku:

// 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
})

Pominięto importy: getSession pochodzi z biblioteki autoryzacyjnej, a redirect z next/navigation.

getProfile wymaga teraz zalogowanego użytkownika i udostępnia adres e-mail wyłącznie właścicielowi profilu:

// 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
  }
}

Każdy użytkownik korzystający z funkcji otrzymuje usługi autoryzacji i filtrowania bezpłatnie; drugi programista nie może pominąć wewnętrznych sprawdzeń wbudowanych w funkcję. Należy pamiętać, że requireAuth odpowiada jedynie na pytanie „czy ktoś jest zalogowany?”. To, czy dany użytkownik może edytować ten post, to kwestia uprawnień, która również powinna znajdować się w DAL. Aby dowiedzieć się więcej na temat tego rozdzielenia, zapoznaj się z informacjami o tym, gdzie powinny znajdować się autoryzacja i uprawnienia.

Strony stają się lżejsze – pobierają dane przez DAL i je renderują:

// Any component - simple and secure
export default async function ProfilePage({ params }) {
  const profile = await getProfile(params.id)
  return <Profile profile={profile} />
}

Komponent nie zawiera żadnego kodu bezpieczeństwa, a audyt polega na sprawdzeniu katalogu data/, a nie setek komponentów. W najnowszych wersjach Next.js params jest obiektem typu Promise, więc najpierw należy użyć await.

Pobieranie danych równolegle z funkcji DAL

Panel sterowania często wykonywuje niezależne zapytania jeden po drugim:

// 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} />
}

Każde await czeka na poprzednie, więc trzy zapytania trwające po 100 ms zajmują łącznie około 300 ms. Ponieważ są one niezależne, ta funkcja DAL uruchamia je jednocześnie za pomocą Promise.all i mapuje wyniki na wymagane pola:

// 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 }
  }
}

Całkowity czas spada do poziomu najwolniejszej zapytania, około 100 ms. Korzyść pochodzi od Promise.all, a nie samego DAL, ale funkcja obsługująca dane zapewnia spójność tego wzorca i umożliwia jednoczesne filtrowanie oraz pobieranie danych. Zapytania zależne nadal muszą czekać. Podobnie, ponieważ requireAuth wykorzystuje cache(), wiele komponentów może korzystać z funkcji DAL dostępnych dla zalogowanych użytkowników, przy czym sesja jest odczytywana tylko raz na żądanie.

Ochrona warstwy za pomocą elementów dostępnych tylko na serwerze

Każdy plik w DAL powinien zaczynać się od takiego importu:

import 'server-only'

Pakiet server-only powoduje niepowodzenie kompilacji, gdy moduł go importujący trafia do kodu klienta, więc funkcja DAL błędnie importowana do komponentu klienta powoduje wyraźny błąd jeszcze przed wdrożeniem. Ta konwencja staje się zasadą narzucaną przez narzędzia.

Co powinno znajdować się w DAL

Każda funkcja DAL powinna skupiać się na czterech zadaaniach:

  • Autoryzacja: upewnij się, że jest zalogowany użytkownik, korzystając z zapisanych pomocników, aby sprawdzenie odbywało się tylko raz na żądanie.
  • Uprawnienia: sprawdź, czy dany użytkownik ma dostęp do konkretnej informacji, na przykład czy może przeglądać profil lub edytować wpis.
  • Filtrowanie: zwracaj tylko te pola, które są wyświetlane na ekranie. Jeśli nie jesteś pewien, czy dane pole jest potrzebne, pomiń je.
  • Tajemnice: tylko DAL powinna odczytywać wrażliwe ustawienia, takie jak DATABASE_URL, dzięki czemu szczegóły połączenia nie trafiają do kodu komponentów.

Akcje serwera powinny korzystać z tych samych funkcji, dzięki czemu operacje zapisu przechodzą te same sprawdzenia co operacje odczytu; patrz autoryzację w każdej akcji serwera.

Kiedy DAL jest przydatne

Należy go używać do wszystkiego, co zajmuje się danymi użytkowników, autoryzacją lub informacjami poufnymi; w aplikacji produkcyjnej z kontami jest to standard, a nie dodatek. Można go pominąć tylko w przypadku prototypów tymczasowych lub stron statycznych bez danych użytkowników, a nawet wtedy zwracanie wyraźnych obiektów to tanie nawyki, które warto zachować.

Główne wnioski

  • Cokolwiek dotrze do komponentu klienckiego, trafi do przeglądarki, więc nigdy nie przekazuj surowych wierszy z bazy danych przez tę granicę.
  • Kieruj każde zapytanie przez folder data/, który zajmuje się autoryzacją, uprawnieniami i wyborem pól.
  • Zamknij wyszukiwania sesji w cache React, aby wiele wywołań warstwy dostępu do danych kosztowało jedno odczytanie sesji na żądanie.
  • Użyj Promise.all w funkcjach warstwy dostępu do danych dla niezależnych zapytań.
  • Imporduj tag server-only we wszystkich plikach warstwy dostępu do danych, aby błędny import spowodował porażkę kompilacji zamiast problemów podczas sprawdzania bezpieczeństwa.
  • Komponenty serwerowe ułatwiają dostęp do bazy danych; warstwa dostępu do danych zapewnia, że ta łatwość nie przekształci się potajemnie w wyciek danych.

    Pozycje pokrewne