seguridad·16 min de lectura

OWASP Top 10: Guía Práctica para Desarrolladores Web

Guía práctica del OWASP Top 10 en español. Las 10 vulnerabilidades más críticas en aplicaciones web con ejemplos de código, prevención en Next.js y Node.js, y checklist de seguridad.

OWASP Top 10: Guía Práctica para Desarrolladores Web

El OWASP Top 10 es el estándar de la industria para identificar las vulnerabilidades más críticas en aplicaciones web. La mayoría de los desarrolladores sabe que existe, pero pocos han revisado su código contra cada categoría de forma sistemática.

Esta guía cubre las 10 categorías de la versión 2021 (la más reciente) con código vulnerable, código corregido y una lista de prevención para cada una. Todo en TypeScript, con ejemplos para Next.js y Node.js que puedes aplicar directamente en tu proyecto.

Versión actual: OWASP Top 10 2021

El OWASP Top 10 se actualiza cada 3-4 años. La versión vigente es la de 2021, que introdujo cambios importantes respecto a la de 2017: Broken Access Control subió al primer lugar, se agregaron categorías nuevas como Insecure Design y SSRF, y se consolidaron otras. Esta guía cubre la versión 2021 completa.

¿Qué es OWASP?

OWASP (Open Web Application Security Project) es una organización sin fines de lucro dedicada a mejorar la seguridad del software. No es un producto ni una empresa. Es una comunidad global de investigadores, desarrolladores y expertos en seguridad que mantiene proyectos open source, guías y herramientas.

Su proyecto más conocido es el OWASP Top 10: una lista de las 10 categorías de vulnerabilidades más críticas en aplicaciones web, basada en datos reales de cientos de miles de aplicaciones analizadas. No es una lista arbitraria. Cada categoría se ranquea según la frecuencia, la severidad y el impacto de las vulnerabilidades reportadas.

Si desarrollas aplicaciones web, el Top 10 es el mínimo que deberías conocer. Muchos estándares de la industria (PCI DSS, SOC 2, ISO 27001) lo referencian como baseline de seguridad.

Las 10 categorías del OWASP Top 10 (2021)

Aquí tienes el resumen antes de entrar en detalle:

#CategoríaDescripción corta
A01Broken Access ControlUsuarios acceden a datos o funciones que no les corresponden
A02Cryptographic FailuresDatos sensibles sin cifrar o mal cifrados
A03InjectionInput del usuario ejecutado como código
A04Insecure DesignFallas de diseño, no de implementación
A05Security MisconfigurationConfiguraciones por defecto o inseguras en producción
A06Vulnerable and Outdated ComponentsDependencias con vulnerabilidades conocidas
A07Identification and Authentication FailuresAutenticación débil o mal implementada
A08Software and Data Integrity FailuresCódigo o datos sin verificación de integridad
A09Security Logging and Monitoring FailuresSin logs o monitoreo de eventos de seguridad
A10Server-Side Request Forgery (SSRF)El servidor hace requests a URLs controladas por el usuario

A01: Broken Access Control

Broken Access Control es la categoría número uno del OWASP Top 10 desde 2021. Ocurre cuando un usuario puede acceder a datos, funciones o recursos que no le corresponden. Es la vulnerabilidad más común en aplicaciones web modernas.

Ejemplo real: Un usuario autenticado accede a /api/users/123 para ver su perfil. Cambia el 123 por 456 en la URL y ve los datos de otro usuario. El servidor no verifica que el usuario autenticado sea el dueño del recurso solicitado.

Código vulnerable:

typescript
// app/api/users/[id]/route.ts -- VULNERABLE
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
 
export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
 
  // No verifica que el usuario autenticado sea el dueño
  const user = await db.user.findUnique({
    where: { id },
  });
 
  return NextResponse.json(user);
}

Código corregido:

typescript
// app/api/users/[id]/route.ts -- SEGURO
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
 
export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
 
  if (!session?.user) {
    return NextResponse.json({ error: "No autenticado" }, { status: 401 });
  }
 
  const { id } = await params;
 
  // Verifica que el usuario solo acceda a sus propios datos
  if (session.user.id !== id) {
    return NextResponse.json({ error: "No autorizado" }, { status: 403 });
  }
 
  const user = await db.user.findUnique({
    where: { id },
    select: {
      id: true,
      name: true,
      email: true,
      // No exponer campos sensibles como passwordHash
    },
  });
 
  return NextResponse.json(user);
}
IDOR es el ataque más común en esta categoría

IDOR (Insecure Direct Object Reference) ocurre cuando usas IDs predecibles o secuenciales en tus URLs. Un atacante simplemente itera sobre los IDs para extraer datos. Siempre verifica que el usuario autenticado tiene permiso para acceder al recurso específico, no solo que esté autenticado.

Prevención:

  • Verifica autorización en cada endpoint, no solo autenticación
  • Usa middleware para proteger rutas que requieren roles específicos
  • No expongas IDs secuenciales en URLs; usa UUIDs o slugs
  • Aplica el principio de menor privilegio: deny by default, allow explícitamente

A02: Cryptographic Failures

Antes conocida como "Sensitive Data Exposure", esta categoría cubre cualquier falla relacionada con criptografía: datos sensibles transmitidos en texto plano, passwords almacenados sin hash, algoritmos de cifrado obsoletos o uso de HTTP en lugar de HTTPS.

Ejemplo real: Una aplicación almacena passwords en texto plano en la base de datos. Cuando la base de datos es comprometida (y eventualmente lo será), el atacante obtiene todos los passwords directamente.

Código vulnerable:

typescript
// lib/auth.ts -- VULNERABLE
import { db } from "@/lib/db";
 
export async function registrarUsuario(email: string, password: string) {
  // Guarda el password en texto plano
  const user = await db.user.create({
    data: {
      email,
      password, // Esto es una bomba de tiempo
    },
  });
 
  return user;
}
 
export async function login(email: string, password: string) {
  const user = await db.user.findUnique({ where: { email } });
 
  // Compara en texto plano
  if (user?.password === password) {
    return user;
  }
 
  return null;
}

Código corregido:

typescript
// lib/auth.ts -- SEGURO
import { db } from "@/lib/db";
import bcrypt from "bcryptjs";
 
const SALT_ROUNDS = 12;
 
export async function registrarUsuario(email: string, password: string) {
  const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
 
  const user = await db.user.create({
    data: {
      email,
      passwordHash,
    },
  });
 
  return { id: user.id, email: user.email };
}
 
export async function login(email: string, password: string) {
  const user = await db.user.findUnique({ where: { email } });
 
  if (!user?.passwordHash) {
    // Previene timing attacks: siempre ejecuta la comparación
    await bcrypt.hash(password, SALT_ROUNDS);
    return null;
  }
 
  const passwordValido = await bcrypt.compare(password, user.passwordHash);
 
  if (!passwordValido) {
    return null;
  }
 
  return { id: user.id, email: user.email };
}

Prevención:

  • Usa bcrypt, scrypt o Argon2 para hashear passwords (nunca MD5 o SHA-1)
  • Fuerza HTTPS en toda tu aplicación con HSTS headers
  • No transmitas datos sensibles en query strings (quedan en logs del servidor)
  • Clasifica los datos que maneja tu aplicación y cifra los sensibles en reposo

Si quieres profundizar en headers de seguridad como HSTS, revisa la guía de headers de seguridad.


A03: Injection

Injection ocurre cuando datos del usuario se envían a un intérprete como parte de un comando o query sin validación ni escapeo. SQL injection es el ejemplo clásico, pero también aplica a NoSQL, LDAP, OS commands y XSS.

Ejemplo real: Un formulario de búsqueda concatena el input del usuario directamente en una query SQL. El atacante escribe ' OR '1'='1 y obtiene todos los registros de la tabla.

Código vulnerable:

typescript
// app/api/buscar/route.ts -- VULNERABLE
import { NextResponse } from "next/server";
import { sql } from "@/lib/db";
 
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const termino = searchParams.get("q") ?? "";
 
  // Concatenación directa = SQL injection garantizado
  const resultados = await sql`
    SELECT * FROM productos WHERE nombre LIKE '%${termino}%'
  `;
 
  return NextResponse.json(resultados);
}

Código corregido:

typescript
// app/api/buscar/route.ts -- SEGURO
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { z } from "zod";
 
const busquedaSchema = z.object({
  q: z.string().min(1).max(100).trim(),
});
 
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const parsed = busquedaSchema.safeParse({ q: searchParams.get("q") });
 
  if (!parsed.success) {
    return NextResponse.json(
      { error: "Término de búsqueda inválido" },
      { status: 400 }
    );
  }
 
  // Prisma usa queries parametrizadas internamente
  const resultados = await db.producto.findMany({
    where: {
      nombre: {
        contains: parsed.data.q,
        mode: "insensitive",
      },
    },
    take: 50,
  });
 
  return NextResponse.json(resultados);
}
NoSQL injection también existe

Si usas MongoDB con queries dinámicas, eres vulnerable a NoSQL injection. Un atacante puede enviar { "$gt": "" } como valor de un campo y obtener todos los registros. Usa siempre un ORM o valida los tipos de los inputs con Zod antes de pasarlos a la query.

NoSQL injection ejemplo:

typescript
// VULNERABLE -- MongoDB sin validación
const user = await db.collection("users").findOne({
  email: req.body.email,
  password: req.body.password, // Si envían { "$gt": "" }, match con cualquier password
});
 
// SEGURO -- Valida tipos con Zod
const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});
 
const { email, password } = loginSchema.parse(req.body);
const user = await db.collection("users").findOne({ email });
// Luego compara password con bcrypt

Prevención:

  • Usa queries parametrizadas o un ORM como Prisma o Drizzle
  • Valida todos los inputs con Zod en el servidor
  • Nunca concatenes input del usuario en queries, comandos o templates
  • Aplica el principio de menor privilegio en la base de datos (el usuario de la app no necesita DROP TABLE)

A04: Insecure Design

Esta categoría es diferente a las demás porque no se trata de errores de implementación, sino de fallas de diseño. Un diseño inseguro no se arregla con mejor código; se arregla rediseñando la funcionalidad.

Ejemplo real: Un formulario de login no tiene rate limiting ni bloqueo de cuenta. Un atacante puede hacer millones de intentos de brute force sin ninguna restricción.

Código vulnerable:

typescript
// app/api/auth/login/route.ts -- VULNERABLE
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import bcrypt from "bcryptjs";
 
export async function POST(request: Request) {
  const { email, password } = await request.json();
 
  const user = await db.user.findUnique({ where: { email } });
 
  if (!user) {
    return NextResponse.json({ error: "Credenciales inválidas" }, { status: 401 });
  }
 
  const valid = await bcrypt.compare(password, user.passwordHash);
 
  if (!valid) {
    // Sin rate limiting, sin bloqueo, sin registro de intentos fallidos
    return NextResponse.json({ error: "Credenciales inválidas" }, { status: 401 });
  }
 
  return NextResponse.json({ token: "..." });
}

Código corregido:

typescript
// app/api/auth/login/route.ts -- SEGURO
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { rateLimit } from "@/lib/rate-limit";
import bcrypt from "bcryptjs";
 
const MAX_INTENTOS_FALLIDOS = 5;
const BLOQUEO_MINUTOS = 15;
 
export async function POST(request: Request) {
  const ip = request.headers.get("x-forwarded-for") ?? "unknown";
 
  // Rate limiting por IP
  const { allowed } = rateLimit(ip, 10, 60_000);
  if (!allowed) {
    return NextResponse.json(
      { error: "Demasiados intentos. Intenta más tarde." },
      { status: 429 }
    );
  }
 
  const { email, password } = await request.json();
 
  const user = await db.user.findUnique({ where: { email } });
 
  if (!user) {
    await bcrypt.hash(password, 12); // Previene timing attacks
    return NextResponse.json({ error: "Credenciales inválidas" }, { status: 401 });
  }
 
  // Verificar bloqueo por intentos fallidos
  if (
    user.intentosFallidos >= MAX_INTENTOS_FALLIDOS &&
    user.ultimoIntentoFallido &&
    Date.now() - user.ultimoIntentoFallido.getTime() < BLOQUEO_MINUTOS * 60_000
  ) {
    return NextResponse.json(
      { error: `Cuenta bloqueada. Intenta en ${BLOQUEO_MINUTOS} minutos.` },
      { status: 423 }
    );
  }
 
  const valid = await bcrypt.compare(password, user.passwordHash);
 
  if (!valid) {
    await db.user.update({
      where: { id: user.id },
      data: {
        intentosFallidos: { increment: 1 },
        ultimoIntentoFallido: new Date(),
      },
    });
    return NextResponse.json({ error: "Credenciales inválidas" }, { status: 401 });
  }
 
  // Reset de intentos fallidos tras login exitoso
  await db.user.update({
    where: { id: user.id },
    data: { intentosFallidos: 0, ultimoIntentoFallido: null },
  });
 
  return NextResponse.json({ token: "..." });
}

Para una implementación completa de rate limiting, revisa la guía de rate limiting en Next.js.

Prevención:

  • Implementa rate limiting en endpoints sensibles (login, registro, recuperación de password)
  • Diseña flujos con límites: máximo de intentos, tiempos de espera, bloqueos temporales
  • Usa threat modeling antes de implementar funcionalidades críticas
  • No asumas que los usuarios se van a comportar bien; diseña para el caso adversario

A05: Security Misconfiguration

Security Misconfiguration es cuando tu aplicación tiene configuraciones por defecto, innecesarias o inseguras en producción. Incluye debug mode habilitado, stack traces en respuestas de error, credenciales por defecto, y headers de seguridad ausentes.

Ejemplo real: Una aplicación Next.js en producción muestra stack traces completos cuando ocurre un error. El atacante obtiene información sobre la estructura interna, rutas de archivos, versiones de dependencias y nombres de tablas en la base de datos.

Código vulnerable:

typescript
// app/api/datos/route.ts -- VULNERABLE
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
 
export async function GET() {
  try {
    const datos = await db.producto.findMany();
    return NextResponse.json(datos);
  } catch (error) {
    // Expone detalles internos al cliente
    return NextResponse.json(
      {
        error: "Error en la base de datos",
        detalle: (error as Error).message,
        stack: (error as Error).stack,
      },
      { status: 500 }
    );
  }
}

Código corregido:

typescript
// app/api/datos/route.ts -- SEGURO
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { logError } from "@/lib/logger";
 
export async function GET() {
  try {
    const datos = await db.producto.findMany();
    return NextResponse.json(datos);
  } catch (error) {
    // Log completo en el servidor
    logError("GET /api/datos", error);
 
    // Respuesta genérica al cliente
    return NextResponse.json(
      { error: "Error interno del servidor" },
      { status: 500 }
    );
  }
}
typescript
// lib/logger.ts
export function logError(context: string, error: unknown) {
  const timestamp = new Date().toISOString();
  const message = error instanceof Error ? error.message : String(error);
  const stack = error instanceof Error ? error.stack : undefined;
 
  console.error(JSON.stringify({
    timestamp,
    context,
    message,
    stack,
    level: "error",
  }));
}
Revisa tu next.config.ts antes de deployar

Verifica que reactStrictMode esté habilitado, que no estés exponiendo headers innecesarios con poweredByHeader: false, y que tus headers de seguridad estén configurados. Revisa la guía de headers de seguridad para una configuración completa.

Prevención:

  • Nunca expongas stack traces, mensajes de error internos o información de debug al cliente
  • Configura headers de seguridad: CSP, HSTS, X-Frame-Options, X-Content-Type-Options
  • Deshabilita features que no uses (directory listing, métodos HTTP innecesarios)
  • Revisa el checklist de seguridad antes de deploy con cada release

A06: Vulnerable and Outdated Components

Usar dependencias con vulnerabilidades conocidas (CVEs) es como dejar una puerta abierta con un letrero que dice "entra por aquí". Esta categoría cubre librerías, frameworks y componentes desactualizados o con fallas de seguridad publicadas.

Ejemplo real: Tu proyecto usa una versión de lodash con un prototype pollution vulnerability (CVE-2019-10744). Un atacante puede modificar el prototype de Object y escalar a ejecución de código arbitrario.

Workflow de auditoría:

bash
# Verificar vulnerabilidades conocidas en tus dependencias
npm audit
 
# Ver solo las vulnerabilidades altas y críticas
npm audit --audit-level=high
 
# Intentar arreglar automáticamente
npm audit fix
 
# Si hay fixes que requieren major version bumps
npm audit fix --force  # Cuidado: puede romper cosas

Automatización con GitHub Actions:

yaml
# .github/workflows/security-audit.yml
name: Security Audit
on:
  schedule:
    - cron: "0 9 * * 1" # Cada lunes a las 9am
  push:
    paths:
      - "package-lock.json"
 
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 20
 
      - run: npm ci
 
      - name: Ejecutar audit
        run: npm audit --audit-level=high
 
      - name: Verificar dependencias outdated
        run: npx npm-check-updates --target minor

Prevención:

  • Ejecuta npm audit en tu CI/CD pipeline y bloquea deploys con vulnerabilidades altas
  • Configura Dependabot o Renovate para recibir PRs automáticos de actualizaciones de seguridad
  • No uses dependencias que no se mantienen activamente (revisa el último commit y los issues abiertos)
  • Mantén tu versión de Node.js en una versión LTS con soporte activo

A07: Identification and Authentication Failures

Esta categoría cubre todo lo relacionado con autenticación débil o mal implementada: passwords débiles permitidos, sesiones que no expiran, tokens almacenados de forma insegura, falta de MFA en operaciones sensibles.

Ejemplo real: Una aplicación almacena el token de sesión en localStorage. Un ataque XSS roba el token y el atacante tiene acceso completo a la cuenta del usuario.

Código vulnerable:

typescript
// lib/session.ts -- VULNERABLE
export function guardarSesion(token: string) {
  // localStorage es accesible desde cualquier script en la página
  localStorage.setItem("token", token);
}
 
export function obtenerSesion(): string | null {
  return localStorage.getItem("token");
}
 
// En cada request
fetch("/api/datos", {
  headers: {
    Authorization: `Bearer ${obtenerSesion()}`,
  },
});

Código corregido:

typescript
// app/api/auth/login/route.ts -- SEGURO
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { SignJWT } from "jose";
 
export async function POST(request: Request) {
  // ... validación de credenciales ...
 
  const token = await new SignJWT({ userId: user.id, role: user.role })
    .setProtectedHeader({ alg: "HS256" })
    .setExpirationTime("1h")
    .setIssuedAt()
    .sign(new TextEncoder().encode(process.env.JWT_SECRET));
 
  const cookieStore = await cookies();
 
  cookieStore.set("session", token, {
    httpOnly: true,     // No accesible desde JavaScript del cliente
    secure: true,       // Solo se envía por HTTPS
    sameSite: "strict", // Previene CSRF
    maxAge: 3600,       // 1 hora
    path: "/",
  });
 
  return NextResponse.json({ success: true });
}
typescript
// middleware.ts -- Verificar sesión en cada request
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { jwtVerify } from "jose";
 
const rutasProtegidas = ["/dashboard", "/api/datos", "/api/users"];
 
export async function middleware(request: NextRequest) {
  const isProtected = rutasProtegidas.some((ruta) =>
    request.nextUrl.pathname.startsWith(ruta)
  );
 
  if (!isProtected) return NextResponse.next();
 
  const token = request.cookies.get("session")?.value;
 
  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
 
  try {
    await jwtVerify(
      token,
      new TextEncoder().encode(process.env.JWT_SECRET)
    );
    return NextResponse.next();
  } catch {
    return NextResponse.redirect(new URL("/login", request.url));
  }
}
 
export const config = {
  matcher: ["/dashboard/:path*", "/api/datos/:path*", "/api/users/:path*"],
};

Para una implementación completa con Auth.js v5, revisa la guía de autenticación en Next.js.

Prevención:

  • Usa cookies httpOnly + Secure + SameSite para sesiones; nunca localStorage
  • Implementa expiración de sesiones y rotación de tokens
  • Exige passwords de mínimo 8 caracteres, pero no pongas reglas absurdas que frustren al usuario
  • Considera MFA para operaciones sensibles (cambio de password, pagos, borrado de cuenta)

A08: Software and Data Integrity Failures

Esta categoría cubre situaciones donde el código o los datos no tienen verificación de integridad. Incluye CI/CD pipelines comprometidos, dependencias instaladas sin verificar su autenticidad, y actualizaciones automáticas sin validación de firma.

Ejemplo real: Un atacante compromete un paquete popular de npm (supply chain attack). Tu CI/CD instala la versión comprometida automáticamente y la despliega a producción.

Verificación de integridad en dependencias:

bash
# Verificar que package-lock.json coincide con package.json
npm ci  # Falla si hay discrepancias (a diferencia de npm install)
 
# Verificar firmas de paquetes (npm 8+)
npm audit signatures

Lockfile en CI/CD:

yaml
# .github/workflows/deploy.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 20
 
      # SIEMPRE usa npm ci, no npm install
      - run: npm ci
 
      # Verificar integridad
      - run: npm audit signatures
 
      - run: npm run build
      - run: npm run deploy

Subresource Integrity (SRI) para scripts externos:

tsx
// Si cargas scripts de CDN, usa SRI
<script
  src="https://cdn.ejemplo.com/libreria.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
  crossOrigin="anonymous"
/>
npm ci vs npm install

En tu CI/CD, siempre usa npm ci. A diferencia de npm install, npm ci instala exactamente las versiones definidas en package-lock.json y falla si hay inconsistencias. Esto previene que una versión inesperada se instale en producción.

Prevención:

  • Usa npm ci en CI/CD, nunca npm install
  • Commitea tu package-lock.json y revisa los diffs en cada PR
  • Configura npm audit signatures como paso obligatorio en tu pipeline
  • Pin versions en dependencias críticas de seguridad (no uses ^ ni ~)

A09: Security Logging and Monitoring Failures

Si no tienes logs de eventos de seguridad, no vas a saber cuándo te están atacando. Esta categoría cubre la falta de logging en intentos de login fallidos, accesos no autorizados, errores de validación sospechosos y otros eventos que deberían generar alertas.

Ejemplo real: Un atacante hace 10,000 intentos de login en una hora. Sin logs, nadie se entera hasta que las cuentas de los usuarios ya están comprometidas.

Código vulnerable:

typescript
// app/api/auth/login/route.ts -- SIN LOGGING
export async function POST(request: Request) {
  const { email, password } = await request.json();
  const user = await db.user.findUnique({ where: { email } });
 
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    // No se registra el intento fallido
    return NextResponse.json({ error: "Credenciales inválidas" }, { status: 401 });
  }
 
  // No se registra el login exitoso
  return NextResponse.json({ token: "..." });
}

Código corregido:

typescript
// lib/security-logger.ts
type SecurityEvent = {
  event: string;
  ip: string;
  email?: string;
  userId?: string;
  details?: Record<string, unknown>;
};
 
export function logSecurityEvent(data: SecurityEvent) {
  const entry = {
    timestamp: new Date().toISOString(),
    level: "security",
    ...data,
  };
 
  // En producción, envía a un servicio de logging externo
  // (Datadog, Sentry, Axiom, Better Stack, etc.)
  console.log(JSON.stringify(entry));
}
typescript
// app/api/auth/login/route.ts -- CON LOGGING
import { logSecurityEvent } from "@/lib/security-logger";
 
export async function POST(request: Request) {
  const ip = request.headers.get("x-forwarded-for") ?? "unknown";
  const { email, password } = await request.json();
 
  const user = await db.user.findUnique({ where: { email } });
 
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    logSecurityEvent({
      event: "LOGIN_FAILED",
      ip,
      email,
      details: { reason: user ? "wrong_password" : "user_not_found" },
    });
    return NextResponse.json({ error: "Credenciales inválidas" }, { status: 401 });
  }
 
  logSecurityEvent({
    event: "LOGIN_SUCCESS",
    ip,
    email,
    userId: user.id,
  });
 
  return NextResponse.json({ token: "..." });
}

Eventos que deberías registrar:

typescript
// Eventos mínimos de seguridad
const SECURITY_EVENTS = [
  "LOGIN_SUCCESS",
  "LOGIN_FAILED",
  "LOGOUT",
  "PASSWORD_CHANGED",
  "PASSWORD_RESET_REQUESTED",
  "ACCOUNT_LOCKED",
  "UNAUTHORIZED_ACCESS",   // 403
  "RATE_LIMIT_EXCEEDED",   // 429
  "VALIDATION_FAILED",     // Input sospechoso
  "TOKEN_EXPIRED",
  "TOKEN_INVALID",
  "ADMIN_ACTION",          // Cualquier acción de admin
] as const;

Prevención:

  • Registra todos los intentos de login (exitosos y fallidos) con IP y timestamp
  • Registra accesos no autorizados (403), rate limits excedidos (429) y errores de validación
  • Envía logs a un servicio externo (no solo console.log en producción)
  • Configura alertas para patrones sospechosos (muchos login fallidos desde una IP, accesos a rutas de admin)

A10: Server-Side Request Forgery (SSRF)

SSRF ocurre cuando tu servidor hace requests HTTP a URLs controladas por el usuario sin validación. Un atacante puede usar tu servidor como proxy para acceder a servicios internos, metadata de cloud providers, o redes internas que no son accesibles desde internet.

Ejemplo real: Tu aplicación tiene una funcionalidad de "preview de URL" que genera una vista previa de cualquier link. El atacante envía http://169.254.169.254/latest/meta-data/ y obtiene las credenciales de IAM de tu instancia en AWS.

Código vulnerable:

typescript
// app/api/preview/route.ts -- VULNERABLE
import { NextResponse } from "next/server";
 
export async function POST(request: Request) {
  const { url } = await request.json();
 
  // Hace fetch a cualquier URL que el usuario envíe
  const response = await fetch(url);
  const html = await response.text();
 
  return NextResponse.json({
    title: extraerTitulo(html),
    description: extraerDescripcion(html),
  });
}

Código corregido:

typescript
// lib/url-validator.ts
const BLOCKED_HOSTS = [
  "169.254.169.254",   // AWS metadata
  "metadata.google",    // GCP metadata
  "100.100.100.200",   // Alibaba metadata
  "localhost",
  "127.0.0.1",
  "0.0.0.0",
  "::1",
];
 
const BLOCKED_PROTOCOLS = ["file:", "ftp:", "gopher:", "data:"];
 
const PRIVATE_IP_RANGES = [
  /^10\./,
  /^172\.(1[6-9]|2\d|3[01])\./,
  /^192\.168\./,
  /^127\./,
  /^0\./,
];
 
export function validarUrl(input: string): { valid: boolean; error?: string } {
  let url: URL;
 
  try {
    url = new URL(input);
  } catch {
    return { valid: false, error: "URL inválida" };
  }
 
  // Solo HTTP y HTTPS
  if (!["http:", "https:"].includes(url.protocol)) {
    return { valid: false, error: "Protocolo no permitido" };
  }
 
  // Bloquear hosts conocidos de metadata
  if (BLOCKED_HOSTS.some((host) => url.hostname.includes(host))) {
    return { valid: false, error: "Host no permitido" };
  }
 
  // Bloquear IPs privadas
  if (PRIVATE_IP_RANGES.some((range) => range.test(url.hostname))) {
    return { valid: false, error: "IP privada no permitida" };
  }
 
  return { valid: true };
}
typescript
// app/api/preview/route.ts -- SEGURO
import { NextResponse } from "next/server";
import { validarUrl } from "@/lib/url-validator";
import { z } from "zod";
 
const previewSchema = z.object({
  url: z.string().url().max(2048),
});
 
export async function POST(request: Request) {
  const body = await request.json();
  const parsed = previewSchema.safeParse(body);
 
  if (!parsed.success) {
    return NextResponse.json({ error: "URL inválida" }, { status: 400 });
  }
 
  const validacion = validarUrl(parsed.data.url);
 
  if (!validacion.valid) {
    return NextResponse.json({ error: validacion.error }, { status: 400 });
  }
 
  try {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 5000);
 
    const response = await fetch(parsed.data.url, {
      signal: controller.signal,
      redirect: "error", // No seguir redirects
    });
 
    clearTimeout(timeout);
 
    const html = await response.text();
 
    return NextResponse.json({
      title: extraerTitulo(html),
      description: extraerDescripcion(html),
    });
  } catch {
    return NextResponse.json(
      { error: "No se pudo obtener la URL" },
      { status: 502 }
    );
  }
}
SSRF puede escalar a Remote Code Execution

En entornos cloud, un SSRF a la IP de metadata (169.254.169.254 en AWS) puede exponer credenciales de IAM con permisos amplios. Con esas credenciales, un atacante puede acceder a S3 buckets, bases de datos, secretos y potencialmente ejecutar código en otras instancias. Siempre valida y restringe las URLs que tu servidor va a resolver.

Prevención:

  • Valida y sanitiza todas las URLs antes de hacer fetch desde el servidor
  • Bloquea IPs privadas, localhost y endpoints de metadata de cloud providers
  • Usa allowlists de dominios cuando sea posible en lugar de blocklists
  • No sigas redirects automáticamente; valida cada URL en la cadena de redirects

Checklist rápido

Usa esta tabla como referencia rápida para verificar tu aplicación contra cada categoría:

CategoríaVerificación claveEstado
A01: Broken Access ControlCada endpoint verifica autorización, no solo autenticación--
A02: Cryptographic FailuresPasswords hasheados con bcrypt/Argon2, HTTPS forzado con HSTS--
A03: InjectionTodos los inputs validados con Zod, queries parametrizadas--
A04: Insecure DesignRate limiting en login y endpoints sensibles, bloqueo de cuenta--
A05: Security MisconfigurationSin stack traces en producción, headers de seguridad configurados--
A06: Vulnerable Componentsnpm audit en CI/CD, Dependabot o Renovate habilitado--
A07: Authentication FailuresSesiones en httpOnly cookies, expiración configurada--
A08: Integrity Failuresnpm ci en CI/CD, lockfile commiteado--
A09: Logging FailuresLogin fallidos registrados con IP, alertas configuradas--
A10: SSRFURLs validadas antes de fetch, IPs privadas bloqueadas--

Evalúa tu aplicación

Revisar cada categoría manualmente toma tiempo. Un buen punto de partida es una autoevaluación rápida que identifique qué categorías necesitan atención inmediata.

Auditoría OWASP Top 10 en 2 minutos

Auditoría OWASP gratuita -- 10 preguntas (una por categoría), un grade A-F por cada una y recomendaciones específicas para tu aplicación. Sin registro.

Recursos adicionales

Si quieres profundizar en temas específicos de seguridad para tu stack de Next.js, estos artículos cubren implementaciones detalladas:

Preguntas frecuentes

¿Qué es OWASP y quién lo mantiene?

OWASP (Open Web Application Security Project) es una organización sin fines de lucro dedicada a mejorar la seguridad del software. Es mantenida por una comunidad global de investigadores, desarrolladores y expertos en seguridad. Su proyecto más conocido es el OWASP Top 10, que se actualiza cada 3-4 años basándose en datos reales de vulnerabilidades reportadas.

¿El OWASP Top 10 aplica solo a aplicaciones web?

El OWASP Top 10 principal está enfocado en aplicaciones web, pero OWASP también tiene listas específicas para APIs (OWASP API Security Top 10), aplicaciones móviles, y otros contextos. Para la mayoría de desarrolladores web, el Top 10 principal es el punto de partida correcto.

¿Cómo evalúo si mi aplicación cumple con OWASP?

Puedes hacer una autoevaluación revisando cada categoría del Top 10 contra tu código y configuración. Las herramientas automatizadas cubren algunas categorías (como inyección y misconfiguration), pero otras requieren revisión manual (como insecure design). Lo ideal es combinar ambos enfoques.

¿Cuál es la vulnerabilidad más común del OWASP Top 10?

Broken Access Control (A01) es la categoría número uno desde 2021. Esto incluye cualquier situación donde un usuario puede acceder a datos o funciones que no le corresponden: ver datos de otro usuario, acceder a rutas de admin sin serlo, o manipular IDs en la URL para ver recursos ajenos.

¿Necesito cumplir con todas las categorías del OWASP Top 10?

Idealmente sí, pero la prioridad depende de tu aplicación. Si manejas datos sensibles o pagos, todas son críticas. Para un blog estático, algunas categorías como Cryptographic Failures o SSRF son menos relevantes. Pero Broken Access Control, Injection y Security Misconfiguration aplican a prácticamente cualquier aplicación.

#seguridad#owasp#vulnerabilidades#nextjs#web

Preguntas frecuentes

¿Qué es OWASP y quién lo mantiene?

OWASP (Open Web Application Security Project) es una organización sin fines de lucro dedicada a mejorar la seguridad del software. Es mantenida por una comunidad global de investigadores, desarrolladores y expertos en seguridad. Su proyecto más conocido es el OWASP Top 10, que se actualiza cada 3-4 años basándose en datos reales de vulnerabilidades reportadas.

¿El OWASP Top 10 aplica solo a aplicaciones web?

El OWASP Top 10 principal está enfocado en aplicaciones web, pero OWASP también tiene listas específicas para APIs (OWASP API Security Top 10), aplicaciones móviles, y otros contextos. Para la mayoría de desarrolladores web, el Top 10 principal es el punto de partida correcto.

¿Cómo evalúo si mi aplicación cumple con OWASP?

Puedes hacer una autoevaluación revisando cada categoría del Top 10 contra tu código y configuración. Las herramientas automatizadas cubren algunas categorías (como inyección y misconfiguration), pero otras requieren revisión manual (como insecure design). Lo ideal es combinar ambos enfoques.

¿Cuál es la vulnerabilidad más común del OWASP Top 10?

Broken Access Control (A01) es la categoría número uno desde 2021. Esto incluye cualquier situación donde un usuario puede acceder a datos o funciones que no le corresponden: ver datos de otro usuario, acceder a rutas de admin sin serlo, o manipular IDs en la URL para ver recursos ajenos.

¿Necesito cumplir con todas las categorías del OWASP Top 10?

Idealmente sí, pero la prioridad depende de tu aplicación. Si manejas datos sensibles o pagos, todas son críticas. Para un blog estático, algunas categorías como Cryptographic Failures o SSRF son menos relevantes. Pero Broken Access Control, Injection y Security Misconfiguration aplican a prácticamente cualquier aplicación.