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ía | Descripción corta |
|---|---|---|
| A01 | Broken Access Control | Usuarios acceden a datos o funciones que no les corresponden |
| A02 | Cryptographic Failures | Datos sensibles sin cifrar o mal cifrados |
| A03 | Injection | Input del usuario ejecutado como código |
| A04 | Insecure Design | Fallas de diseño, no de implementación |
| A05 | Security Misconfiguration | Configuraciones por defecto o inseguras en producción |
| A06 | Vulnerable and Outdated Components | Dependencias con vulnerabilidades conocidas |
| A07 | Identification and Authentication Failures | Autenticación débil o mal implementada |
| A08 | Software and Data Integrity Failures | Código o datos sin verificación de integridad |
| A09 | Security Logging and Monitoring Failures | Sin logs o monitoreo de eventos de seguridad |
| A10 | Server-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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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 bcryptPrevenció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:
// 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:
// 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:
// 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:
// 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 }
);
}
}// 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:
# 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 cosasAutomatización con GitHub Actions:
# .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 minorPrevención:
- Ejecuta
npm auditen 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:
// 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:
// 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 });
}// 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:
# 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 signaturesLockfile en CI/CD:
# .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 deploySubresource Integrity (SRI) para scripts externos:
// 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 cien CI/CD, nuncanpm install - Commitea tu
package-lock.jsony revisa los diffs en cada PR - Configura
npm audit signaturescomo 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:
// 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:
// 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));
}// 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:
// 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:
// 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:
// 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 };
}// 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ía | Verificación clave | Estado |
|---|---|---|
| A01: Broken Access Control | Cada endpoint verifica autorización, no solo autenticación | -- |
| A02: Cryptographic Failures | Passwords hasheados con bcrypt/Argon2, HTTPS forzado con HSTS | -- |
| A03: Injection | Todos los inputs validados con Zod, queries parametrizadas | -- |
| A04: Insecure Design | Rate limiting en login y endpoints sensibles, bloqueo de cuenta | -- |
| A05: Security Misconfiguration | Sin stack traces en producción, headers de seguridad configurados | -- |
| A06: Vulnerable Components | npm audit en CI/CD, Dependabot o Renovate habilitado | -- |
| A07: Authentication Failures | Sesiones en httpOnly cookies, expiración configurada | -- |
| A08: Integrity Failures | npm ci en CI/CD, lockfile commiteado | -- |
| A09: Logging Failures | Login fallidos registrados con IP, alertas configuradas | -- |
| A10: SSRF | URLs 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:
- Seguridad en aplicaciones Next.js: guía completa -- Cubre XSS, CSRF, SQL injection, middleware y CSP con código para Next.js
- Headers de seguridad para aplicaciones web -- Configuración detallada de CSP, HSTS, X-Frame-Options y más
- Rate limiting en Next.js -- Implementación completa de rate limiting por IP con Redis y en memoria
- Checklist de seguridad antes de deploy -- Verificación rápida de variables de entorno, headers, RLS y dependencias
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.
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.
Articulos relacionados
Row Level Security en Supabase: Errores Comunes que Dejan tu Base de Datos Abierta
Los 5 errores más comunes de Row Level Security en Supabase que dejan tu base de datos expuesta. USING(true), tablas sin RLS, service_role en el cliente y cómo corregirlos.
Archivos .env Expuestos: Cómo Verificar si tu Sitio Filtra Secretos
Guía para detectar si tu sitio web expone archivos .env, .git y configuraciones sensibles. Verificación manual, protección en Next.js y Vercel, y remediación.
Security Headers: Cómo Verificar y Configurar los Headers de Seguridad de tu Sitio
Guía práctica para verificar y configurar security headers en tu sitio web. HSTS, CSP, X-Frame-Options y más con ejemplos para Next.js y Vercel.