BackVolver al blog

Cómo abordar la seguridad en Next.js

Conozca las protecciones de seguridad integradas en Next.js y consulte una guía para auditar aplicaciones.

Los Componentes de Servidor de React (RSC) en App Router representan un paradigma novedoso que elimina gran parte de la redundancia y riesgos potenciales asociados con métodos convencionales. Dada su novedad, los desarrolladores y equipos de seguridad pueden encontrar desafíos para alinear sus protocolos existentes con este modelo.

Este documento destaca áreas clave a considerar, protecciones integradas e incluye una guía para auditar aplicaciones, enfocándose especialmente en riesgos de exposición accidental de datos.

Elección del modelo de manejo de datos

Los Componentes de Servidor de React difuminan la línea entre servidor y cliente. El manejo de datos es crucial para entender dónde se procesa la información y dónde queda disponible.

Primero debemos elegir el enfoque adecuado para nuestro proyecto:

Recomendamos mantener un enfoque consistente sin mezclar demasiado. Esto clarifica expectativas tanto para desarrolladores como auditores de seguridad. Las excepciones destacan como sospechosas.

APIs HTTP

Para proyectos existentes adoptando Componentes de Servidor, el enfoque recomendado es tratarlos como no confiables por defecto, similar a SSR o en el cliente. No asuma redes internas como zonas de confianza, aplicando el concepto de Confianza Cero. Llame endpoints API como REST o GraphQL usando fetch() desde Componentes de Servidor, igual que en el cliente, pasando cookies correspondientes.

Si tenía getStaticProps/getServerSideProps conectando a bases de datos, considere consolidar moviendo esta lógica a endpoints API para uniformidad.

Cuidado con controles de acceso que asuman que llamadas desde la red interna son seguras.

Este enfoque permite mantener estructuras organizacionales existentes, donde equipos backend especializados pueden aplicar prácticas de seguridad establecidas. Funciona bien incluso con lenguajes distintos a JavaScript.

Aprovecha beneficios de Componentes de Servidor como menos código enviado al cliente y ejecución eficiente de cascadas de datos.

Capa de Acceso a Datos

Para nuevos proyectos, recomendamos crear una Capa de Acceso a Datos separada en su código JavaScript, consolidando todo acceso a datos. Esto asegura consistencia y reduce bugs de autorización, siendo más fácil de mantener al centralizar en una sola librería. Permite mejor cohesión de equipo con un solo lenguaje y ventajas de rendimiento como caché en memoria compartida.

Construya una librería interna que implemente verificaciones antes de devolver datos, similar a endpoints HTTP pero en el mismo modelo de memoria. Cada API debe aceptar el usuario actual y verificar permisos antes de retornar datos. El principio es que un Componente de Servidor solo debe ver datos autorizados para el usuario actual.

data/auth.tsx
import { cache } from 'react';
import { cookies } from 'next/headers';
 
// Métodos cacheados facilitan obtener el mismo valor en múltiples lugares
// sin pasarlo manualmente. Esto evita pasarlo entre Componentes de Servidor
// reduciendo riesgo de enviarlo a un Componente de Cliente.
export const getCurrentUser = cache(async () => {
  const token = cookies().get('AUTH_TOKEN');
  const decodedToken = await decryptAndValidate(token);
  // Evite incluir tokens secretos o información privada en campos públicos.
  // Use clases para evitar pasar accidentalmente el objeto completo al cliente.
  return new User(decodedToken.id);
});
data/user-dto.tsx
import 'server-only';
import { getCurrentUser } from './auth';
 
function canSeeUsername(viewer: User) {
  // Información pública por ahora, pero puede cambiar
  return true;
}
 
function canSeePhoneNumber(viewer: User, team: string) {
  // Reglas de privacidad
  return viewer.isAdmin || team === viewer.team;
}
 
export async function getProfileDTO(slug: string) {
  // Use APIs de base de datos con templates seguros para consultas
  const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`;
  const userData = rows[0];
 
  const currentUser = await getCurrentUser();
 
  // Retorne solo datos relevantes para esta consulta
  // <https://www.w3.org/2001/tag/doc/APIMinimization>
  return {
    username: canSeeUsername(currentUser) ? userData.username : null,
    phonenumber: canSeePhoneNumber(currentUser, userData.team)
      ? userData.phonenumber
      : null,
  };
}

Estos métodos deben exponer objetos seguros para transferir al cliente (Objetos de Transferencia de Datos - DTO). Esto crea capas donde auditorías pueden enfocarse en la Capa de Acceso a Datos mientras la UI itera rápidamente.

Claves secretas deben almacenarse en variables de entorno, pero solo la capa de acceso a datos debe acceder a process.env en este enfoque.

Acceso a Datos a Nivel de Componente

Este enfoque coloca consultas directamente en Componentes de Servidor, siendo apropiado solo para prototipado rápido. En este caso, audite cuidadosamente archivos "use client". Revise funciones exportadas que acepten objetos demasiado amplios como User, o props como token o creditCard. Campos sensibles como phoneNumber requieren escrutinio extra.

import Profile from './components/profile.tsx';
 
export async function Page({ params: { slug } }) {
  const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`;
  const userData = rows[0];
  // EXPUESTO: Esto envía todos los campos al cliente al pasar
  // datos desde un Componente de Servidor a uno de Cliente.
  return <Profile user={userData} />;
}

Siempre use consultas parametrizadas o librerías que lo hagan para evitar inyección SQL.

Solo Servidor

Marque código que solo debe ejecutarse en servidor con:

import 'server-only';

Esto genera errores si un Componente de Cliente importa este módulo, evitando fugas de código sensible.

En Next.js 14 puede usar experimentalmente las APIs React Taint activando taint en next.config.js:

next.config.js
module.exports = {
  experimental: {
    taint: true,
  },
};

Esto marca objetos que no deben pasarse al cliente:

app/data.ts
import { experimental_taintObjectReference } from 'react';
 
export async function getUserData(id) {
  const data = ...;
  experimental_taintObjectReference(
    'No pasar datos de usuario al cliente',
    data
  );
  return data;
}

Para valores únicos como tokens, use taintUniqueValue.

SSR vs RSC

Para carga inicial, Next.js ejecuta Componentes de Servidor y Cliente en el servidor para generar HTML.

Los Componentes de Servidor (RSC) ejecutan en un sistema de módulos separado de los Componentes de Cliente para evitar fugas de información.

Componentes de Cliente renderizados vía SSR deben considerarse bajo la misma política de seguridad que el navegador. No deben acceder a datos privilegiados o APIs privadas. Next.js fallará el build si se importan módulos server-only en Componentes de Cliente.

Lectura

En App Router, la lectura se implementa renderizando páginas con Componentes de Servidor.

Las entradas son searchParams en la URL, parámetros dinámicos y headers. Estos no son confiables y deben revalidarse siempre. No use searchParams para rastrear estados como ?isAdmin=true.

Renderizar un Componente de Servidor nunca debe causar efectos secundarios como mutaciones. Next.js no permite establecer cookies o trigger revalidación durante el renderizado.

Escritura

La forma idiomática de realizar mutaciones en App Router es usando Server Actions.

actions.ts
'use server';
 
export function logout() {
  cookies().delete('AUTH_TOKEN');
}

Las funciones con "use server" deben siempre validar que el usuario actual puede invocar la acción e integridad de argumentos, manualmente o con herramientas como zod.

actions.ts
"use server";
 
export async function deletePost(id: number) {
  if (typeof id !== 'number') {
    // Las anotaciones TypeScript no se aplican en runtime
    throw new Error();
  }
  const user = await getCurrentUser();
  if (!canDeletePost(user, id)) {
    throw new Error();
  }
  ...
}

Closures

Las Acciones de Servidor (Server Actions) también pueden codificarse en closures. Esto permite que la acción se asocie con una instantánea de los datos utilizados en el momento del renderizado, para que puedas usarlos cuando se invoque la acción:

app/page.tsx
export default function Page() {
  const publishVersion = await getLatestVersion();
  async function publish() {
    "use server";
    if (publishVersion !== await getLatestVersion()) {
      throw new Error('La versión ha cambiado desde que se presionó publicar');
    }
    ...
  }
  return <button action={publish}>Publicar</button>;
}
 

La instantánea del closure debe enviarse al cliente y de vuelta cuando se invoque el servidor.

En Next.js 14, las variables capturadas en el closure se cifran con el ID de la acción antes de enviarse al cliente. Por defecto, se genera una clave privada automáticamente durante la construcción de un proyecto Next.js. Cada reconstrucción genera una nueva clave privada, lo que significa que cada Acción de Servidor solo puede invocarse para una compilación específica. Puedes considerar usar Skew Protection para asegurarte de siempre invocar la versión correcta durante los re-despliegues.

Si necesitas una clave que rote con más frecuencia o que persista entre múltiples compilaciones, puedes configurarla manualmente usando la variable de entorno NEXT_SERVER_ACTIONS_ENCRYPTION_KEY.

Al cifrar todas las variables capturadas en el closure, evitas exponer accidentalmente secretos. Al firmarlas, dificultas que un atacante manipule la entrada de la acción.

Otra alternativa a usar closures es emplear la función .bind(...) en JavaScript. Estas NO se cifran. Esto proporciona una opción de exclusión por rendimiento y también es consistente con .bind() en el cliente.

app/page.tsx
async function deletePost(id: number) {
  "use server";
  // verificar el id y que aún se puede eliminar
  ...
}
 
export async function Page({ slug }) {
  const post = await getPost(slug);
  return <button action={deletePost.bind(null, post.id)}>
    Eliminar
  </button>;
}

El principio es que la lista de argumentos de las Acciones de Servidor ("use server") siempre debe tratarse como hostil y la entrada debe verificarse.

CSRF

Todas las Acciones de Servidor pueden invocarse mediante un <form> simple, lo que podría exponerlas a ataques CSRF. Internamente, las Acciones de Servidor siempre se implementan usando POST y solo se permite este método HTTP para invocarlas. Esto por sí solo previene la mayoría de vulnerabilidades CSRF en navegadores modernos, especialmente debido a que las cookies Same-Site son la configuración predeterminada.

Como protección adicional, las Acciones de Servidor en Next.js 14 también comparan la cabecera Origin con la cabecera Host (o X-Forwarded-Host). Si no coinciden, la Acción será rechazada. En otras palabras, las Acciones de Servidor solo pueden invocarse en el mismo host que la página que las aloja. Navegadores muy antiguos, no compatibles y desactualizados que no soportan la cabecera Origin podrían estar en riesgo.

Las Acciones de Servidor no usan tokens CSRF, por lo que la sanitización del HTML es crucial.

Cuando se usan Manejadores de Ruta Personalizados (route.tsx) en su lugar, puede ser necesaria una auditoría adicional, ya que la protección CSRF debe hacerse manualmente allí. Las reglas tradicionales aplican en esos casos.

Manejo de Errores

Los errores ocurren. Cuando se lanzan errores en el Servidor, eventualmente se relanzan en el código del Cliente para manejarlos en la interfaz de usuario. Los mensajes de error y los seguimientos de pila podrían contener información sensible. Por ejemplo, [número de tarjeta de crédito] no es un número de teléfono válido.

En modo producción, React no envía errores o promesas rechazadas al cliente. En su lugar, se envía un hash que representa el error. Este hash puede usarse para asociar múltiples errores iguales y vincular el error con los registros del servidor. React reemplaza el mensaje de error con uno genérico propio.

En modo desarrollo, los errores del servidor aún se envían en texto plano al cliente para facilitar la depuración.

Es importante siempre ejecutar Next.js en modo producción para cargas de trabajo en producción. El modo desarrollo no está optimizado para seguridad y rendimiento.

Rutas Personalizadas y Middleware

Los Manejadores de Ruta Personalizados y el Middleware se consideran mecanismos de escape de bajo nivel para funciones que no pueden implementarse con ninguna otra funcionalidad integrada. Esto también abre posibles riesgos que el marco normalmente protege. Con gran poder viene gran responsabilidad.

Como se mencionó anteriormente, las rutas route.tsx pueden implementar manejadores GET y POST personalizados que podrían sufrir problemas CSRF si no se hacen correctamente.

El Middleware puede usarse para limitar el acceso a ciertas páginas. Por lo general, es mejor hacer esto con una lista de permitidos en lugar de una lista de denegados. Esto se debe a que puede ser complicado conocer todas las formas diferentes de acceder a los datos, como si hay una reescritura o solicitud del cliente.

Por ejemplo, es común pensar solo en la página HTML. Next.js también soporta navegación del cliente que puede cargar cargas útiles RSC/JSON. En el Enrutador de Páginas, esto solía estar en una URL personalizada.

Para facilitar la escritura de coincidencias, el Enrutador de Aplicaciones de Next.js siempre usa la URL simple de la página tanto para el HTML inicial, como para las navegaciones del cliente y las Acciones de Servidor. Las navegaciones del cliente usan el parámetro de búsqueda ?_rsc=... como rompedor de caché.

Las Acciones de Servidor residen en la página donde se usan y, como tal, heredan el mismo control de acceso. Si el Middleware permite leer una página, también puedes invocar acciones en esa página. Para limitar el acceso a las Acciones de Servidor en una página, puedes prohibir el método HTTP POST en esa página.

Auditoría

Si estás realizando una auditoría de un proyecto con el Enrutador de Aplicaciones de Next.js, aquí hay algunas cosas que recomendamos revisar especialmente:

  • Capa de Acceso a Datos. ¿Existe una práctica establecida para una Capa de Acceso a Datos aislada? Verifica que los paquetes de base de datos y las variables de entorno no se importen fuera de la Capa de Acceso a Datos.
  • Archivos "use client". ¿Los props de los Componentes esperan datos privados? ¿Las firmas de tipos son demasiado amplias?
  • Archivos "use server". ¿Los argumentos de las Acciones se validan en la acción o dentro de la Capa de Acceso a Datos? ¿Se reautoriza al usuario dentro de la acción?
  • /[param]/. Las carpetas con corchetes son entrada del usuario. ¿Se validan los parámetros?
  • middleware.tsx y route.tsx tienen mucho poder. Dedica tiempo adicional a auditar estos usando técnicas tradicionales. Realiza Pruebas de Penetración o Escaneo de Vulnerabilidades regularmente o en alineación con el ciclo de vida de desarrollo de software de tu equipo.