Auditoría de seguridad por fases: refuerzo de una aplicación Next.js
Las auditorías de seguridad de aplicaciones web suelen encontrar las mismas categorías de problemas con independencia del stack: fallos de autorización, falta de validación de entradas, encabezados HTTP de seguridad insuficientes y vulnerabilidades de temporización en los flujos de autenticación. La manifestación concreta cambia según el framework, pero los patrones subyacentes son consistentes. Lo que varía es el grado de sistematización de la auditoría: una revisión sin estructura probablemente detectará los problemas evidentes y pasará por alto los sutiles.
Realizamos una auditoría por fases de una aplicación Next.js en producción que utilizaba Supabase como backend. La aplicación gestionaba la autenticación de usuarios, almacenaba registros vinculados a ellos y exponía varias rutas de API utilizadas tanto por el frontend como por webhooks externos. Ningún hallazgo era catastrófico, pero varios podían convertirse en incidentes graves si los descubría alguien con malas intenciones. A continuación explicamos qué abarcó la auditoría, qué encontró y qué cambió como resultado.
Primera fase: autenticación y autorización
La primera fase se centró en la capa de autenticación y las políticas de seguridad a nivel de fila (RLS) que regulan el acceso a los datos. Son los controles más críticos de cualquier aplicación Supabase: si la autenticación falla o RLS está mal configurado, ninguna validación de entradas ni límite de solicitudes lo compensa.
La auditoría comenzó con un inventario completo de las tablas de Supabase y su estado de RLS. Todas las tablas de un esquema expuesto deberían tener RLS habilitado. Con los permisos predeterminados de Supabase, cualquiera que tenga la clave anon puede leer y escribir en cualquier tabla de un esquema expuesto, como public, que no tenga RLS habilitado; esta clave es pública por diseño y está incluida en el paquete de código del cliente. En esta aplicación, una tabla tenía RLS deshabilitado. Era una tabla de consulta que se consideraba información de referencia y que, al crearse, solo contenía valores estáticos. Con el tiempo se habían añadido filas con metadatos vinculados a usuarios que debían protegerse. La solución fue sencilla: habilitar RLS y añadir una política. Sin embargo, el fallo de proceso subyacente era significativo: las nuevas tablas deberían tener RLS habilitado por defecto, no como medida posterior.
Después se revisó la corrección de las políticas RLS de las tablas protegidas. Un error habitual es comprobar correctamente el identificador de usuario, pero en la columna equivocada. Por ejemplo, una política en una tabla documents que comprueba auth.uid() = created_by restringe correctamente el acceso al creador; sin embargo, si la aplicación también admite documentos compartidos, una fila de colaborador en una tabla de asociación document_collaborators necesita su propia política: la comprobación en la tabla principal no se extiende automáticamente a los datos relacionados.
Una política contenía un error lógico que había superado la revisión de código: utilizaba USING donde debía utilizar tanto USING como WITH CHECK. La cláusula USING determina qué filas existentes puede ver, actualizar o eliminar un comando; WITH CHECK determina qué filas nuevas o modificadas se pueden escribir. Cuando una política que abarca escrituras (FOR ALL o FOR UPDATE) omite WITH CHECK, PostgreSQL reutiliza la expresión USING para las escrituras. Por eso, una cláusula USING formulada como regla de lectura permitía que cualquier usuario autenticado escribiera filas que podía leer, lo que no era el comportamiento previsto. Es un detalle de RLS de PostgreSQL fácil de pasar por alto.
Segunda fase: validación de entradas
La segunda fase revisó todos los puntos por los que entran datos suministrados por usuarios: los manejadores de rutas de API en app/api/, las acciones de servidor y los envíos de formularios. El objetivo era confirmar que todas las entradas se validan antes de usarlas y que los fallos de validación generan errores informativos en lugar de excepciones sin manejar.
La aplicación utilizaba Zod para validar algunas entradas, pero de forma inconsistente. Algunas rutas tenían esquemas completos con schema.parse() al principio del manejador. Otras leían directamente los campos de req.body sin validación, confiando en aserciones de tipo de TypeScript que no ofrecen seguridad en tiempo de ejecución:
// Unsafe: TypeScript type assertion, no runtime validation
const { userId, documentId } = req.body as { userId: string; documentId: string };
// Safe: Zod parse, throws on invalid input
const schema = z.object({
userId: z.string().uuid(),
documentId: z.string().uuid(),
});
const { userId, documentId } = schema.parse(req.body);
La corrección consistió en añadir esquemas Zod a cada ruta de API sin validación y establecer un middleware compartido de validación por el que pasan todas las rutas. Este patrón es importante: garantiza que una ruta de código añadida sin conocer el requisito de validación no pueda eludir esa capa.
Una ruta aceptaba un parámetro redirectUrl que se utilizaba en una llamada a res.redirect(). Esto constituye una vulnerabilidad de redirección abierta: un atacante puede preparar un enlace a la aplicación que redirija al usuario a un sitio de phishing tras una interacción aparentemente legítima. La solución es validar el destino de la redirección frente a una lista de rutas permitidas:
const ALLOWED_REDIRECT_PATHS = ['/dashboard', '/settings', '/profile'];
const redirectPath = schema.parse(req.query).redirectUrl;
if (!ALLOWED_REDIRECT_PATHS.includes(redirectPath)) {
return res.redirect('/dashboard');
}
return res.redirect(redirectPath);
Tercera fase: encabezados HTTP de seguridad y CSP
La aplicación carecía por completo de encabezados Content Security Policy. Sin una CSP, una inyección XSS exitosa —a través de contenido generado por usuarios o de un script de terceros comprometido— puede ejecutar JavaScript arbitrario en el contexto de la aplicación, con acceso a tokens de autenticación y datos de usuarios.
Añadir una CSP a una aplicación Next.js requiere cuidado porque Next.js incorpora algunos scripts en línea por defecto. El enfoque recomendado utiliza nonces: un valor aleatorio criptográficamente seguro, generado por solicitud, que se añade al encabezado CSP y se aplica a cada script en línea. Next.js aplica el nonce a sus propios scripts leyendo la política de los encabezados de la solicitud, por lo que el middleware de producción también debe pasar la CSP (y normalmente un encabezado x-nonce) mediante NextResponse.next({ request: { headers } }), como muestra la guía de CSP de Next.js; el siguiente fragmento solo establece los encabezados de respuesta. Además, importa el módulo crypto de Node, que el entorno de ejecución Edge no ofrece; allí debe usarse la API Web Crypto global (por ejemplo, crypto.randomUUID()):
// middleware.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';
export function middleware(request: Request) {
const nonce = crypto.randomBytes(16).toString('base64');
const cspHeader = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`connect-src 'self' https://api.example.com`,
`frame-ancestors 'none'`,
].join('; ');
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', cspHeader);
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
return response;
}
El despliegue inicial de CSP utilizó el modo Content-Security-Policy-Report-Only, que informa de las infracciones sin bloquearlas. Así se identificaron varios scripts en línea legítimos y orígenes de terceros que debían incluirse en la lista permitida antes de activar el bloqueo. Ejecutar el modo de solo informe durante una semana antes de aplicar la política es un enfoque común; omitir este paso suele romper funciones que no aparecieron en las pruebas.
La auditoría también encontró que el balanceador de carga establecía el encabezado Strict-Transport-Security, pero la aplicación no lo hacía. Esto es aceptable si el balanceador es el único punto de entrada, aunque conviene confirmarlo explícitamente. La defensa en profundidad implica que la aplicación establezca sus propios encabezados de seguridad incluso cuando la infraestructura ya los proporciona.
Cuarta fase: límites de solicitudes
Los endpoints de autenticación —inicio de sesión, registro y restablecimiento de contraseña— no tenían límites de solicitudes. Sin ellos, estos endpoints aceptan solicitudes ilimitadas, lo que permite ataques de fuerza bruta contra las contraseñas y la enumeración de direcciones de correo electrónico válidas a partir de diferencias en las respuestas.
Los límites de solicitudes en rutas de API de Next.js pueden implementarse mediante un contador respaldado por Redis; el siguiente ejemplo utiliza una ventana fija, más sencilla que una deslizante. Como ejecuta INCR y EXPIRE en llamadas separadas, un fallo entre ambas puede dejar una clave sin vencimiento, por lo que el código de producción debería ejecutar ambas operaciones de forma atómica (por ejemplo, en una transacción o un script Lua). La clave suele ser la dirección IP o, en endpoints autenticados, el identificador de usuario. Para los endpoints de autenticación, limitar por IP resulta apropiado porque el usuario aún no está autenticado:
// lib/rateLimit.ts
import { Redis } from '@upstash/redis';
const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPSTASH_TOKEN! });
export async function checkRateLimit(identifier: string, limit: number, windowSeconds: number) {
const key = `rate_limit:${identifier}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, windowSeconds);
}
return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}
Los límites aplicados fueron conservadores: los intentos de inicio de sesión se limitan por IP en ventanas de quince minutos y las solicitudes de restablecimiento de contraseña, por dirección de correo electrónico y por hora. Son suficientemente permisivos para que los usuarios legítimos rara vez los alcancen en un uso normal y suficientemente restrictivos para hacer inviables en la práctica los ataques automatizados de fuerza bruta.
Quinta fase: protección frente a ataques de temporización
Los ataques de temporización contra endpoints de autenticación permiten inferir si una dirección de correo electrónico está registrada midiendo la diferencia de tiempo de respuesta entre «correo electrónico no encontrado» y «correo electrónico encontrado, contraseña incorrecta». Una búsqueda que encuentra al usuario tarda algo más que una que no lo encuentra, porque la comparación de contraseñas mediante bcrypt o Argon2 solo se ejecuta cuando existe el usuario. Esta diferencia, habitualmente de 50–200 ms, puede medirse con suficientes solicitudes.
La mitigación habitual consiste en ejecutar siempre la comparación del hash de la contraseña, utilizando un hash ficticio cuando el usuario no existe:
const DUMMY_HASH = '$2b$12$dummyhashvaluethatisvalidbutnevermatchesanypassword';
export async function verifyLogin(email: string, password: string) {
const user = await db.user.findUnique({ where: { email } });
// Always run bcrypt comparison to normalize response time
const hashToCompare = user?.passwordHash ?? DUMMY_HASH;
const isValid = await bcrypt.compare(password, hashToCompare);
if (!user || !isValid) {
return null; // Same code path for both failure cases
}
return user;
}
El valor DUMMY_HASH anterior es un marcador de posición: no es un hash bcrypt válido de 60 caracteres, y bibliotecas como bcryptjs devuelven false de inmediato ante hashes mal formados, lo que anularía la normalización del tiempo de respuesta. En la práctica, debe generarse un hash real una sola vez al iniciar (por ejemplo, con bcrypt.hash() sobre una cadena aleatoria), con el mismo factor de costo que los hashes almacenados. El problema no afectaba al endpoint de inicio de sesión integrado de Supabase; apareció en una ruta de autenticación personalizada que la aplicación había añadido para una integración heredada. Toda lógica de autenticación personalizada que no utilice las funciones de autenticación de Supabase debe implementar explícitamente la normalización del tiempo de respuesta.
Qué cambió y qué se mantuvo
Todos los hallazgos de las cinco fases se resolvieron antes de dar por terminada la auditoría. La priorización fue clara: la configuración incorrecta de RLS y la redirección abierta se trataron como críticas y se corrigieron inmediatamente. El despliegue de CSP y los límites de solicitudes se consideraron de alta prioridad y se implementaron en el mismo sprint. La normalización de los tiempos de respuesta en la ruta heredada tuvo prioridad media y se desplegó en el sprint siguiente.
La auditoría también produjo cambios de proceso: las nuevas tablas requieren ahora una revisión de políticas RLS antes de integrar la solicitud de cambios; las rutas de API tienen una capa compartida de validación Zod que forma parte de su plantilla; y el encabezado CSP se genera ahora desde una configuración centralizada, en lugar de cadenas en línea.
El resultado más relevante no fue una corrección concreta, sino el inventario. Saber exactamente qué datos contiene cada tabla, qué política RLS la regula y qué rutas de API acceden a ella es la base para tomar decisiones de seguridad correctas a medida que evoluciona la aplicación. Sin ese inventario, la seguridad es reactiva: se corrige lo que se encuentra. Con él, la seguridad se convierte en una propiedad sobre la que se puede razonar deliberadamente.
Artículos relacionados
- Recuperación ante desastres para servicios autoalojados — la capa de copias de seguridad y recuperación que complementa el refuerzo de la seguridad: qué ocurre cuando los controles fallan pese a todos los esfuerzos.
- Desarrollo de una app de preparación de exámenes con miles de preguntas — políticas RLS de Supabase en una aplicación en producción, incluido el patrón de acceso multiinquilino.