Inicio / Artículos / Detenga las fugas de campos de la base de datos: una capa de acceso a datos para aplicaciones Next.js

Detenga las fugas de campos de la base de datos: una capa de acceso a datos para aplicaciones Next.js

Aprenda cómo una capa de acceso a datos centraliza las verificaciones de autenticación y el filtrado de campos en los componentes server-side de Next.js, para que las columnas sensibles nunca lleguen al navegador por error.

1597 palabras

Los componentes de servidor permiten consultar la base de datos directamente desde un componente. El problema es que cualquier objeto que se le pase a un componente de cliente se serializa y se envía al navegador, incluidos campos que nunca se pretendió mostrar. Una capa de acceso a datos (DAL) se sitúa entre los componentes y la base de datos para que la autenticación, autorización y el filtrado de campos tengan lugar en un solo lugar. A continuación se explica cómo estructurar una, qué elementos debe contener y cuándo vale la pena crear archivos adicionales.

Qué es una capa de acceso a datos

Un DAL es una carpeta dedicada que alberga todas las consultas a la base de datos. Los componentes nunca llaman directamente a Prisma; en su lugar, invocan funciones como getProfile, las cuales deciden qué puede ver quien realiza la solicitud y qué campos se devuelven. Considérelo como un punto de control en el camino hacia la interfaz de usuario: verifica quién está haciendo la solicitud y elimina todo lo que la pantalla no necesita. La documentación de seguridad de datos de Next.js recomienda este enfoque para nuevos proyectos.

Un diseño típico coloca los auxiliares de autenticación junto a los módulos de consulta para cada dominio:

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

La regla que hace que esto funcione es sencilla: nada fuera de data/ importa el cliente de la base de datos.

Cómo las consultas directas filtran datos

Una consulta directa devuelve toda la fila. En el fragmento a continuación, el componente obtiene información de un usuario y pasa el resultado directamente a una tarjeta:

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

Ese objeto contiene cada columna: hash de la contraseña, correo electrónico, número de teléfono e IDs internos. Si ProfileCard es un componente de cliente, todo el objeto se serializa en la carga de la página, y cualquier persona con la pestaña de red abierta puede leerlo, incluso si la tarjeta solo muestra el nombre.

La solución es devolver un objeto diseñado específicamente en lugar del registro sin procesar. Esta función DAL selecciona los tres campos que realmente utiliza la vista del perfil:

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

El componente llama a getProfile(id) y obtiene un objeto seguro; el hash de la contraseña permanece en el servidor. También se podría filtrar en la consulta con select de Prisma, y se debe manejar el caso en que findUnique devuelva null, ya que de lo contrario user.name generaría un error.

Por qué falla la seguridad por componente

El mayor riesgo en una base de código en crecimiento es que cada componente implemente sus propias reglas de acceso, lo que provoca desajustes entre ellos. Imagine a dos desarrolladores trabajando en una misma aplicación: el primero crea la página de perfil utilizando una función DAL destinada a realizar verificaciones de autenticación (el fragmento muestra nuevamente la función filtrada anterior sin incluir aún ninguna verificación; la versión protegida se presenta en la siguiente sección):

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

El segundo desarrollador crea una página de configuración y consulta directamente a Prisma, leyendo el ID del usuario desde la URL sin verificar nunca quién está conectado:

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

Ahora hay dos errores: no hay autenticación, por lo que cualquiera que edite el parámetro id puede cargar la configuración de otra persona, y el registro completo, incluidos los campos sensibles, llega a la interfaz de usuario. (A pesar del comentario pages/, se trata de una página de App Router dentro de app/). Con la lógica de seguridad distribuida en varias páginas, basta con olvidar una verificación para que surjan problemas, por lo que una auditoría debe examinar cada componente que interactúa con la base de datos.

Colocar la autenticación y el filtrado en un mismo lugar

Con una DAL, todos los usuarios pasan por las mismas verificaciones. getCurrentUser lee la sesión y devuelve al usuario o null; requireAuth redirige a la página de inicio de sesión cuando no hay usuario conectado. Ambas funciones están envueltas en cache de React, de modo que las llamadas repetidas dentro de una misma solicitud reutilizan el primer resultado:

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

Se han omitido las importaciones: getSession proviene de tu biblioteca de autenticación y redirect de next/navigation.

getProfile ahora requiere que el usuario esté conectado y solo muestra el correo electrónico al propietario del perfil:

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

Cada llamante recibe la autenticación y el filtrado de forma gratuita; el segundo desarrollador no puede omitir las verificaciones integradas en la función. Tenga en cuenta que requireAuth solo responde a “¿alguien está conectado?”. Si este usuario puede editar esta publicación es una cuestión de autorización, y eso también debería estar en el DAL. Para un análisis más profundo de esta separación, consulte dónde pertenecen cada una la autenticación y la autorización.

Las páginas se vuelven simples. Ellas obtienen los datos a través del DAL y los renderizan:

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

El componente no contiene código de seguridad, y una auditoría implica revisar la carpeta data/ en lugar de cientos de componentes. En las versiones recientes de Next.js, params es un Promise, por lo que primero se debe usar await con él.

Obtención de datos en paralelo desde funciones DAL

Los paneles de control suelen ejecutar consultas independientes una tras otra:

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

Cada await espera al anterior, por lo que tres consultas de 100 ms cada una tardan aproximadamente 300 ms en total. Dado que son independientes, esta función DAL las inicia todas al mismo tiempo con Promise.all y asigna los resultados a los campos necesarios:

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

El tiempo total disminuye al del query más lento, aproximadamente 100 ms. La mejora proviene de Promise.all y no del propio DAL, pero una función de datos hace que el patrón sea consistente y mantiene el filtrado junto con la obtención de datos. Las consultas dependientes siguen teniendo que esperar. De igual manera, como requireAuth utiliza cache(), muchos componentes pueden llamar a funciones DAL autenticadas mientras la sesión se lee una sola vez por solicitud.

Protegiendo la capa con “server-only”

Comience cada archivo del DAL con esta importación:

import 'server-only'

El paquete server-only hace que la compilación falle cuando un módulo que lo importa termina en código del cliente, por lo que una función DAL importada por error en un Componente de Cliente genera un error claro antes de llegar a producción. La convención se convierte en algo que las herramientas hacen cumplir.

Qué pertenece dentro del DAL

Haga que cada función DAL se enfoque en cuatro tareas:

  • Autenticación: confirmar que existe un usuario conectado, utilizando los auxiliares en caché para que la verificación se ejecute una sola vez por solicitud.
  • Autorización: confirmar que este usuario puede acceder a dicho registro en particular, por ejemplo, si puede ver un perfil o editar una publicación.
  • Filtrado: devolver únicamente los campos que muestra la pantalla. Si no está seguro de si se necesita un campo, ómbrelo.
  • Secretos: solo la DAL debe leer configuraciones sensibles como DATABASE_URL, lo que evita que los detalles de conexión aparezcan en el código de los componentes.

Las acciones del servidor deben utilizar las mismas funciones, de modo que las escrituras reciban las mismas verificaciones que las lecturas; consulte la autorización dentro de cada acción del servidor.

Cuándo vale la pena usar un DAL

Úselo para todo lo que maneje datos de usuarios, autenticación o información sensible; en una aplicación en producción con cuentas es lo básico, no algo adicional. Úselo solo para prototipos temporales o sitios estáticos sin datos de usuarios, y aun allí, devolver objetos explícitos es un hábito económico que merece mantenerse.

Puntos clave

  • Todo lo que llega a un componente del cliente llega al navegador, por lo que nunca transmita filas de base de datos en bruto más allá de ese límite.
  • Dirija cada consulta a través de una carpeta data/ que se encargue de la autenticación, autorización y selección de campos.
  • Abriga las búsquedas de sesión en cache de React para que muchas llamadas a la capa DAL costen una sola lectura de sesión por solicitud.
  • Utiliza Promise.all dentro de las funciones de DAL para consultas independientes.
  • Importa server-only en cada archivo de DAL para que una importación incorrecta provoque el fracaso de la compilación en lugar de afectar la revisión de seguridad.
  • Los componentes del servidor facilitan el acceso a la base de datos; una capa de acceso a datos se asegura de que esa facilidad no se convierta silenciosamente en una fuga de datos.

    Lecturas relacionadas