← Auditorías

17 fallos en mi propio código

Construí una plataforma de facturación electrónica con IA. Antes de lanzarla, la audité.

31 de julio de 2026

Control de accesoRow Level SecuritySupabaseNext.jsCódigo generado por IA

El contexto

Soy desarrollador y estudiante de ciberseguridad en Panamá. Durante los últimos meses construí FactureP, una plataforma de facturación electrónica para pymes panameñas, integrada con la DGI. Next.js, Supabase, arquitectura multi-tenant.

La construí con asistencia de IA. No parcialmente: la mayor parte del código de aplicación salió de un asistente, revisado por mí.

Antes de lanzarla, decidí auditarla.

No porque sospechara algo. Porque el producto maneja datos fiscales de terceros y me pareció que lanzarlo sin revisarlo sería incoherente con lo que estudio.

Encontré diecisiete problemas. Tres críticos. Empecé buscando cuatro.

Nada de esto lo detectó un escáner automático. Los escáneres buscan CVEs conocidos y patrones de sintaxis. Lo que encontré son fallos de arquitectura de permisos, y esos no tienen firma.

Patrón 1 — El service role como salida de emergencia

Supabase implementa Row Level Security: políticas escritas en la base de datos que determinan qué filas puede ver o modificar cada usuario. Bien usadas, hacen imposible que un cliente acceda a datos de otro, incluso si el código de aplicación tiene un bug.

Mis políticas estaban bien escritas. Esta, por ejemplo, aislaba correctamente las facturas por tenant:

qual: (tenant_id IN (
  SELECT tenants.id FROM tenants WHERE tenants.owner_id = auth.uid()
))

El problema no estaba ahí. Estaba en que cuatro rutas de mi API importaban el cliente de service role, que bypasea RLS por completo.

Una de ellas:

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const tenant_id = searchParams.get("tenant_id");

  const supabase = getServiceClient();   // ignora RLS

  const { data } = await supabase
    .from("pac_companies")
    .select("*")
    .eq("tenant_id", tenant_id);

  return NextResponse.json({ data });
}

Sin verificación de sesión. Sin comprobar que ese tenant_id pertenezca a quien pregunta. El identificador viene del query string, o sea, del atacante.

#1

SIN SESIÓN.

Crítica

La ruta de emisión usaba el cliente de service role sin verificar quién llamaba. El tenant_id venía del query string, es decir, del atacante.

#2

SIN DUEÑO.

Crítica

El endpoint de empresas devolvía el RUC y el nombre legal de cualquier tenant registrado. En Panamá, el RUC de una persona natural es su número de cédula.

Cualquiera podía consultar el RUC y el nombre legal de cualquier empresa registrada. En Panamá el RUC de una persona natural es su número de cédula, así que esto era exposición de datos personales.

El mismo patrón apareció en la base de datos. Encontré esta política:

tabla política roles cmd with_check
profiles Service role can insert profiles {public} INSERT true

El rol public en Postgres incluye a usuarios anónimos. with_check = true no valida nada. Cualquiera podía insertar filas en la tabla de perfiles.

Y lo revelador: esa política nunca debió existir. El service role bypasea RLS, no necesita política. El modelo, al ver que el insert fallaba, "resolvió" el problema abriendo la puerta a todo el mundo en lugar de entender que el service role no pasa por ahí.

#3

ESCRIBE CUALQUIERA.

Alta

Una política de Row Level Security sobre profiles con rol public y with_check en true. Nunca debió existir: el service role no pasa por ahí.

El mecanismo

Es siempre el mismo bucle:

  1. El modelo escribe una consulta
  2. RLS la bloquea porque no hay sesión de usuario
  3. El modelo alcanza la credencial que ignora la restricción
  4. La consulta funciona
  5. La verificación que RLS estaba haciendo nunca se reemplaza

Ante una restricción, el modelo la quita en vez de satisfacerla. Porque quitarla hace que el código funcione, y funcionar es lo que optimiza.

El arreglo

No fue añadir validaciones. Fue dejar de saltarme la protección que ya tenía:

export async function POST(request) {
  const auth = await requireUser();
  if (auth.error) {
    return NextResponse.json({ error: auth.error }, { status: auth.status });
  }

  const { invoice_id } = await request.json();

  // Cliente con sesión: RLS filtra por tenant automáticamente
  const { data: invoice } = await auth.supabase
    .from("invoices")
    .select("*")
    .eq("id", invoice_id)
    .single();

  // ...
}

Una factura de otro tenant simplemente no existe para ese usuario. Sin if, sin lógica adicional. La seguridad ya estaba escrita; solo había que dejar de rodearla.

El mismo patrón, en el correo

La ruta que enviaba notificaciones tenía la versión más grave del problema:

const { user_email, user_name, tenant_name } = await request.json();
// sin autenticación
await resend.emails.send({
  from: "FactureP <notificaciones@midominio.com>",
  to: user_email,
  html: `<h2>Hola ${user_name}</h2>
         <p>El negocio "${tenant_name}"...</p>`
});

Sin sesión, con destinatario y contenido controlados por quien llama, e interpolación directa en HTML sin escapar. Cualquiera podía enviar correos arbitrarios desde mi dominio, con enlaces arbitrarios, firmados con mi SPF y mi DKIM.

Un relay de phishing con mi marca. Trivial de explotar.

#4

CUALQUIER REMITENTE.

Crítica

Una ruta de correo sin autenticación, con destinatario y contenido controlados por quien llamaba. Un relay de phishing firmado con mi SPF y mi DKIM.

#5

HTML SIN ESCAPAR.

Alta

Datos de usuario interpolados directamente en las plantillas de correo, sin escapar.

Patrón 2 — Código que existe y no se ejecuta

Este es el más difícil de detectar porque no produce ningún síntoma.

Un middleware que el framework ignora. Escribí middleware.js para refrescar sesiones. Next.js 16 renombró esa convención a proxy.js. Un middleware.js que quede sin renombrar se ignora sin error alguno: su matcher y su lógica nunca corren. El archivo estaba en el repositorio, se veía correcto, y no existía para el framework.

#6

NUNCA EXISTIÓ.

Alta

Next 16 renombró la convención a proxy.js. Un middleware.js sin renombrar se ignora sin error alguno: el archivo estaba en el repositorio y no existía para el framework.

Un cron job que nunca se programó. La ruta /api/cron/check-expiring existía, con su lógica completa y su guard de autorización. Pero no había vercel.json, que es donde se declara el schedule. La tarea nunca se ejecutó ni una sola vez. No hay error posible: nadie llamaba.

#7

NUNCA CORRIÓ.

Alta

La ruta del cron estaba completa, con su guard de autorización. Faltaba el archivo que declara el schedule. No se ejecutó ni una sola vez.

Una función SQL fallando en silencio. El borrado permanente de tenants expirados corría por pg_cron a las 3 AM. Al revisar los logs:

failed | DELETE FROM public.alanube_offices | 2026-07-30 03:00:00
failed | DELETE FROM public.alanube_offices | 2026-07-29 03:00:00
failed | DELETE FROM public.alanube_offices | 2026-07-28 03:00:00
failed | DELETE FROM public.alanube_offices | 2026-07-27 03:00:00
failed | DELETE FROM public.alanube_offices | 2026-07-26 03:00:00

Meses antes había renombrado las tablas alanube_* a pac_* para no atarme a un proveedor. El renombrado no alcanza el cuerpo de las funciones almacenadas. Postgres no avisa. La función llevaba cinco noches muriendo en la primera línea.

Aclaración importante sobre la severidad: ningún dato quedó retenido indebidamente, porque ningún tenant había cumplido aún el plazo de 30 días. Era una bomba desactivada antes de detonar, no un daño consumado.

#8

TABLAS FANTASMA.

Alta

Renombré las tablas meses antes y el cambio no alcanzó el cuerpo de la función almacenada. Cinco noches fallando a las 3 AM sin una sola alerta.

El mismo cron tenía dos problemas más, de otro orden: su guard comparaba el token recibido contra una variable de entorno que no existía en producción, y las ventanas de fecha estaban invertidas, de modo que el aviso de expiración se reenviaba indefinidamente.

#9

BEARER UNDEFINED.

Alta

El guard del cron comparaba el token recibido contra una variable de entorno que no existía en producción.

#10

AVISO ETERNO.

Media

El cron de expiración reenviaba el mismo aviso cada noche, con las ventanas de fecha invertidas.

Todos estos casos comparten propiedad: el código existe, se ve correcto en revisión, y no corre. No lo detectas leyendo. Lo detectas verificando si algo realmente se ejecutó.

Patrón 3 — Teatro de seguridad

Había un archivo llamado lib/security.js, con nombres de función correctos y constantes razonables. Y ningún control real.

Rate limiting en localStorage:

const attempts = JSON.parse(localStorage.getItem(ATTEMPT_KEY) || "[]");
if (recent.length >= MAX_ATTEMPTS) { /* bloquear */ }

Se evadía con localStorage.clear(), con una ventana de incógnito, o simplemente llamando a la API con curl sin pasar por el formulario. El límite de 5 intentos no existía para nadie que no usara el navegador de la forma prevista.

CSRF generado y validado en el cliente:

export function generateCsrfToken() {
  const token = crypto.randomUUID();
  sessionStorage.setItem("csrf", token);
  return token;
}
export function validateCsrfToken(token) {
  return token === sessionStorage.getItem("csrf");
}

Esto era conceptualmente lo más grave del archivo. CSRF requiere que el servidor valide. Un token que el cliente se pregunta a sí mismo no protege de nada: el atacante controla ambos lados.

El arreglo no fue reescribirlo. Fue borrarlo. Separé lo único legítimo que contenía —las reglas de contraseña y el medidor de fuerza, que son UX de cliente por diseño— y eliminé el resto. Un control que no protege es peor que ningún control, porque ocupa el lugar mental del que sí debería existir.

#11

TEATRO DE SEGURIDAD.

Media

Un archivo con límite de intentos en localStorage y tokens CSRF generados y validados en el mismo cliente. Nombres correctos, constantes razonables, cero protección real.

Higiene: secretos y dependencias

Dos hallazgos sobre secretos, uno de ellos vergonzoso.

Tenía copias de respaldo del archivo de variables de entorno guardadas dentro del propio directorio del proyecto. Nunca llegaron al repositorio, pero estaban a un .gitignore mal escrito de hacerlo.

#12

COPIA DE SEGURIDAD.

Alta

Respaldos del archivo de variables de entorno guardados dentro del propio directorio del proyecto.

Y el segundo ocurrió durante la propia auditoría: filtré un secreto en una captura de pantalla mientras documentaba un hallazgo. Lo roté con openssl rand -hex 32 y el expuesto nunca llegó a producción. Lo incluyo porque el punto de este documento es que nadie está fuera del patrón, y menos quien está buscándolo.

#13

EN LA CAPTURA.

Alta

Filtré un secreto en una captura de pantalla mientras hacía la auditoría. Rotado antes de que llegara a producción.

En dependencias, tres cosas: dos librerías de autenticación conviviendo en el mismo proyecto, una de ellas deprecada por su propio autor, y un conjunto de paquetes con vulnerabilidades conocidas.

#14

DOS PUERTAS.

Media

Dos librerías de autenticación conviviendo en el mismo proyecto, cada una con su propia idea de dónde vive la sesión.

#15

SIN MANTENIMIENTO.

Baja

Una dependencia de autenticación deprecada por su propio autor, todavía instalada.

#16

CADENA VULNERABLE.

Media

Dependencias con vulnerabilidades conocidas, incluido un CVE de bypass de proxy en Next.js con Turbopack.

Lo que aprendí del proceso

La IA optimiza para que la petición responda, no para que la niegue. La autorización es lógica de negación, y la negación no está en el camino feliz. Por eso los fallos no son bugs visibles: son ausencias.

El código generado falla exitosamente. Devuelve 200, la interfaz se ve bien, y la parte crítica es un comentario:

// TODO: Llamar a la API del PAC para emitir factura

return NextResponse.json({
  message: "Factura emitida correctamente (simulado)",
  invoice_id,
});

Léelo otra vez. La ruta responde que emitió una factura fiscal sin haber llamado a nadie. Devuelve éxito por un acto jurídico que no ocurrió. Un modelo optimiza para que el flujo complete, y "completar" incluye devolver éxito sin haber hecho el trabajo.

No audites con el mismo modelo que escribió el código. Comparte los supuestos que produjeron el fallo. El punto ciego se hereda. Mis hallazgos aparecieron cuando me senté a leer, no cuando le pedí al asistente que revisara.

Las herramientas dan falsos positivos y hay que verificarlos. Busqué una validación de firma con grep sobre tres nombres de función habituales. Devolvió cero, y concluí que no existía. Leyendo el archivo resultó que sí estaba, escrita con otro nombre. El patrón falló; la lectura lo corrigió.

No cambies dos cosas a la vez. En un punto tenía un 404 inexplicable. Maté unos procesos duplicados y desactivé el middleware en el mismo paso. El 404 desapareció y no supe cuál lo había causado. Tuve que rehacerlo aislando una variable.

El remedio automático puede ser peor que el problema. npm audit fix --force proponía "arreglar" vulnerabilidades de PostCSS instalando Next.js 9.3.3, una versión de 2020. La herramienta optimiza el árbol de dependencias, no tu proyecto.

Y la defensa en profundidad se paga sola. Durante la auditoría apareció un CVE de bypass de proxy en Next.js con Turbopack — exactamente mi configuración. No me comprometía, porque mi autorización no vive en el proxy sino en cada ruta. Si hubiera seguido el patrón de la mitad de los tutoriales —poner la verificación de auth en el middleware— ese CVE me habría saltado toda la protección de un golpe.

Checklist: revisa esto en tu proyecto hoy

  1. Credenciales de servicio. Busca dónde usas llaves que bypasean tus políticas de base de datos. Cada uso debe justificar por qué no puede hacerse con el cliente de sesión.

  2. Identificadores que vienen del cliente. Si una ruta recibe un tenant_id, org_id o similar por query string o body, verifica que pertenezca a quien llama. Nunca confíes en él.

  3. Políticas permisivas. En Postgres:

    select tablename, policyname, roles, cmd, qual, with_check
    from pg_policies where schemaname = 'public';
    

    Busca roles con public o anon, y with_check en true o nulo.

  4. Rutas sin verificación de sesión. Inventaría tus handlers y marca los que no llaman a getUser() o equivalente. Algunos serán públicos a propósito. Los demás son hallazgos.

  5. Interpolación en HTML de correos. Cualquier dato de usuario que entre en una plantilla de email sin escapar es inyección. Y si además el destinatario viene del body, es un relay abierto.

  6. Variables de entorno de producción. Compara lo que tu código lee con lo que existe en tu proveedor. Un guard que compara contra una variable indefinida no protege nada.

  7. Verifica que tus tareas programadas se ejecuten. No que estén escritas: que corran. Revisa logs de ejecución, no código.

  8. Migraciones de nombres. Si renombraste tablas, revisa funciones almacenadas, triggers y vistas. El renombrado no llega ahí.

Estado final

De diecisiete hallazgos, cerré dieciséis. Uno sigue abierto y no lo describo hasta remediarlo: está bloqueado por una dependencia externa. Se publica cuando esté cerrado.

Nada de esto estuvo expuesto a usuarios reales: FactureP todavía no ha lanzado. Esa fue la única decisión que tomé bien desde el principio — auditar antes, no después.