Accueil / Articles / Arrêtez les fuites de champs de base de données : une couche d’accès aux données pour les applications Next.js

Arrêtez les fuites de champs de base de données : une couche d’accès aux données pour les applications Next.js

Découvrez comment une couche d’accès aux données centralise les vérifications d’authentification et le filtrage des champs dans les composants serveur de Next.js, afin que les colonnes sensibles n’atteignent jamais par erreur le navigateur.

1597 mots

Les composants serveur vous permettent de interroger la base de données directement depuis un composant. Le problème : tout objet que vous transmettez à un composant client est serialisé et envoyé au navigateur, y compris les champs que vous ne souhaitiez pas afficher. Une couche d’accès aux données (DAL) se situe entre les composants et la base de données afin que l’authentification, l’autorisation et le filtrage des champs aient lieu en un seul endroit. Voici comment la structurer, quels éléments elle doit contenir, et quand il vaut la peine d’ajouter des fichiers supplémentaires.

Qu’est-ce qu’une couche d’accès aux données ?

Un DAL est un dossier dédié qui contient toutes les requêtes de base de données. Les composants n’appellent jamais Prisma directement ; ils appellent des fonctions telles que getProfile, qui déterminent ce que l’appelant peut voir et quels champs seront retournés. Pensez-y comme à un point de contrôle sur le chemin menant à l’interface utilisateur : il vérifie qui pose la demande et supprime tout ce dont l’écran n’a pas besoin. Les documents de sécurité des données de Next.js recommandent cette approche pour les nouveaux projets.

Un schéma typique place les outils d’authentification à côté des modules de requête pour chaque domaine :

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

La règle qui rend cela possible est simple : rien en dehors de data/ ne charge le client de base de données.

Comment les requêtes brutes fuitent des données

Une requête directe renvoie toute la ligne. Dans l’exemple ci-dessous, le composant récupère un utilisateur et transmet directement le résultat à une carte :

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

Cet objet contient chaque colonne : le hachage du mot de passe, l’adresse e-mail, le numéro de téléphone, les IDs internes. Si ProfileCard est un composant client, l’ensemble de cet objet est serialisé dans le chargement de la page, et quiconque a l’onglet réseau ouvert peut le lire, même si la carte n’affiche que le nom.

La solution consiste à retourner un objet spécialement conçu à cet effet plutôt que le enregistrement brut. Cette fonction DAL sélectionne les trois champs réellement utilisés par l’affichage du profil :

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

Le composant appelle getProfile(id) et obtient un objet sécurisé ; le hachage du mot de passe reste sur le serveur. On pourrait également filtrer dans la requête à l’aide de select de Prisma, et il faudrait gérer le cas où findUnique retourne null, car cela provoquerait une erreur avec user.name.

Pourquoi la sécurité par composant échoue

Le risque majeur dans une base de code en expansion est que chaque composant mette en œuvre ses propres règles d’accès, ce qui entraîne des divergences. Imaginez deux développeurs travaillant sur une même application : le premier crée la page de profil à l’aide d’une fonction DAL conçue pour effectuer des vérifications d’authentification (le fragment reproduit ci-dessus utilise cette fonction filtrée sans pour l’instant effectuer de vérification ; la version protégée est présentée dans la section suivante) :

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

Le second développeur crée une page de paramètres et interroge directement Prisma, en lisant l’ID de l’utilisateur depuis l’URL sans jamais vérifier qui est connecté :

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

Il y a maintenant deux bugs : pas d’authentification, ce qui permet à quiconque modifie le paramètre id de charger les paramètres d’une autre personne, et l’ensemble des données, y compris les champs sensibles, est affiché dans l’interface utilisateur. (Malgré le commentaire pages/, il s’agit d’une page du App Router située sous app/). Avec une logique de sécurité répartie sur plusieurs pages, un contrôle oublié suffit, et une vérification approfondie doit être effectuée sur chaque composant qui interagit avec la base de données.

Centraliser l’authentification et le filtrage

Avec un DAL, tous les appels passent par les mêmes contrôles. getCurrentUser lit la session et renvoie l’utilisateur ou null ; requireAuth redirige vers la page de connexion lorsque personne n’est connecté. Ces deux fonctions sont enveloppées dans cache de React, ce qui permet aux appels répétés au sein d’une même requête d’utiliser à nouveau le premier résultat obtenu :

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

Les importations sont omises : getSession provient de votre bibliothèque d’authentification et redirect de next/navigation.

getProfile exige désormais un utilisateur connecté et ne montre l’adresse e-mail qu’au propriétaire du profil :

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

Chaque appelant bénéficie gratuitement de l’authentification et du filtrage ; le deuxième développeur ne peut pas contourner la vérification intégrée à la fonction. Notez que requireAuth ne répond qu’à la question « y a-t-il quelqu’un connecté ? ». Le fait que cet utilisateur puisse modifier cette publication relève de l’autorisation, et cela doit également être géré par le DAL. Pour en savoir plus sur cette séparation, consultez où se situent respectivement l’authentification et l’autorisation dans votre code.

Les pages deviennent légères. Elles récupèrent les données via le DAL puis les affichent :

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

Le composant ne contient aucun code de sécurité, et un audit consiste à examiner le répertoire data/ plutôt que des centaines de composants. Dans les dernières versions de Next.js, params est une Promise, il faut donc d’abord l’utiliser avec await.

Récupération en parallèle depuis les fonctions DAL

Les tableaux de bord exécutent souvent des requêtes indépendantes les unes après les autres :

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

Chaque await attend le précédent, de sorte que trois requêtes de 100 ms durent environ 300 ms. Comme elles sont indépendantes, cette fonction DAL les lance simultanément à l’aide de Promise.all et affecte les résultats aux champs nécessaires :

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

Le temps total diminue pour atteindre celui de la requête la plus lente, soit environ 100 ms. L’amélioration provient de Promise.all et non pas du DAL lui-même, mais une fonction de traitement des données permet d’unifier ce schéma et de conserver le filtrage en même temps que la récupération des données. Les requêtes dépendantes doivent néanmoins attendre. De même, comme requireAuth utilise cache(), de nombreux composants peuvent appeler des fonctions DAL authentifiées tandis que la session est lue une seule fois par requête.

Protéger la couche avec des éléments réservés au serveur

Commencez chaque fichier du DAL par cette importation :

import 'server-only'

Le paquet server-only fait échouer la compilation lorsque un module l’important se retrouve dans du code client ; ainsi, une fonction DAL importée par erreur dans un composant client génère une erreur claire avant même la mise en production. Cette convention devient alors quelque chose que les outils imposent.

Qu’est-ce qui doit se trouver à l’intérieur du DAL

Assurez-vous que chaque fonction DAL se concentre sur quatre tâches :

  • Authentification : vérifier qu’un utilisateur est connecté, en utilisant des outils mémorisés afin que la vérification ne s’exécute qu’une fois par requête.
  • Autorisation : s’assurer que cet utilisateur a le droit d’accéder à ce document en particulier, par exemple s’il peut consulter un profil ou modifier une publication.
  • Filtrage : ne retourner que les champs affichés par l’écran. Si vous n’êtes pas sûr qu’un champ soit nécessaire, omettez-le.
  • Sécrets : seuls les DAL doivent lire des configurations sensibles telles que DATABASE_URL, ce qui permet de garder les détails de connexion hors du code des composants.

Les actions serveur doivent utiliser les mêmes fonctions, de sorte que les écritures subissent les mêmes vérifications que les lectures ; consultez l’autorisation à l’intérieur de chaque action serveur.

Quand un DAL est utile

Utilisez-en un pour tout ce qui traite des données utilisateur, de l’authentification ou d’informations sensibles ; dans une application en production avec des comptes, c’est la norme, pas un supplément. Évitez-le uniquement pour des prototypes temporaires ou des sites statiques sans données utilisateur, et même dans ce cas, renvoyer des objets explicites est une habitude peu coûteuse à conserver.

Points clés

  • Tout ce qui atteint un composant client parvient au navigateur, il ne faut donc jamais transmettre de lignes de base de données brutes au-delà de cette frontière.
  • Routez chaque requête à travers un dossier data/ qui gère l’authentification, l’autorisation et la sélection des champs.
  • Encadrez les recherches de session dans le cache de React afin que de nombreuses appels à la couche DAL coûtent une seule lecture de session par requête.
  • Utilisez Promise.all à l’intérieur des fonctions DAL pour les requêtes indépendantes.
  • Importez server-only dans chaque fichier DAL afin qu’un import mal placé provoque l’échec de la compilation plutôt que d’entraver l’examen de sécurité.
  • Les composants serveur facilitent l’accès à la base de données ; une couche d’accès aux données s’assure que cette facilité ne se transforme pas discrètement en fuite de données.

    Lectures complémentaires