OWASP A04 Diseño Inseguro
OWASP A04:2021 Diseño Inseguro no se trata de código con bugs — son fallas de arquitectura y lógica de negocio. Aprende a detectarlas con ejemplos de código reales.
Rod
Founder & Developer
OWASP A04:2021 Diseño Inseguro es la única categoría del OWASP Top 10 donde parchear el código no soluciona el problema. El diseño mismo es la vulnerabilidad. Entró al Top 10 por primera vez en 2021 — no porque sea nueva, sino porque la industria finalmente la reconoció como distinta de los bugs de implementación. Entender la diferencia cambia cómo piensas en seguridad desde antes de escribir la primera línea de código.
Esta guía cubre cómo se ve el diseño inseguro en la práctica, con código que muestra los patrones vulnerables y las alternativas seguras. Sin teoría abstracta — solo escenarios reales que probablemente ya has shiipeado o revisado.
Fallas de Diseño vs. Bugs de Implementación — Por Qué Importa la Diferencia
Un ejemplo concreto. Construyes un sistema de login. Hasheas contraseñas con bcrypt. Usas queries parametrizadas. Validas los inputs. La implementación está limpia.
Pero no agregas rate limiting en el endpoint de restablecimiento de contraseña.
Un atacante envía 10,000 requests de restablecimiento al mismo correo en un minuto. Tu proveedor de email te cobra 10,000 envíos. El inbox del usuario se inunda. Tu servidor se enlentece. Ninguna de estas cosas es un bug en tu código — cada request individual se maneja correctamente. El diseño simplemente no consideró cómo se abusaría del sistema.
Esa es la esencia del diseño inseguro: el sistema funciona tal como fue diseñado, y ese es el problema.
Los bugs de implementación — inyección SQL, XSS, secrets hardcodeados — son peligrosos porque una línea de código hace algo no deseado. Las fallas de diseño inseguro son peligrosas porque el sistema hace exactamente lo que fue construido para hacer, solo que sin considerar el uso adversarial.
La consecuencia práctica: no puedes salir del diseño inseguro con un parche. Tienes que cambiar la arquitectura.
El diseño inseguro es lo que pasa cuando construyes la funcionalidad antes de preguntarte: "¿cómo alguien va a abusar de esto?"
Patrones Comunes de Diseño Inseguro (Con Código Real)
Sin Rate Limiting en Flujos Sensibles
Restablecimiento de contraseña. Verificación de OTP. Intentos de login. Estos endpoints existen para recuperar o verificar acceso — y son exactamente los flujos que los atacantes atacan con fuerza bruta y enumeración.
El patrón: Un endpoint que acepta una credencial (OTP, token de reset, contraseña) y la verifica contra un valor almacenado, sin límite de cuántas veces puedes intentarlo.
// MAL: Sin rate limiting — un atacante puede probar todos los OTP de 6 dígitos
// (1,000,000 combinaciones) en minutos con requests paralelas
export async function POST(req: Request) {
const { phone, otp } = await req.json();
const stored = await db.otpCodes.findOne({ phone });
if (stored.code === otp) {
return Response.json({ success: true, token: generateToken(phone) });
}
return Response.json({ error: "Código inválido" }, { status: 400 });
}// BIEN: Rate limit por IP y por teléfono, códigos con expiración, límite de intentos
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") ?? "unknown";
// 5 intentos por teléfono cada 10 minutos
const attempts = await rateLimit.check(`otp:${phone}`, { limit: 5, window: 600 });
if (!attempts.ok) {
return Response.json({ error: "Demasiados intentos" }, { status: 429 });
}
const stored = await db.otpCodes.findOne({ phone, expiresAt: { gt: new Date() } });
if (!stored || stored.code !== otp || stored.attempts >= 5) {
await db.otpCodes.increment({ phone }, "attempts");
return Response.json({ error: "Código inválido o expirado" }, { status: 400 });
}
await db.otpCodes.delete({ phone });
return Response.json({ success: true, token: generateToken(phone) });
}Consecuencia real: En 2016, investigadores demostraron que la verificación OTP de conductores de Uber no tenía rate limiting. Un atacante podía enumerar códigos OTP por fuerza bruta, tomar control de cuentas de conductores y acceder a datos de pasajeros. La falla no estaba en cómo se generaban o almacenaban los OTPs — estaba en el diseño que no limitaba los intentos.
Cálculo de Precio del Lado del Cliente
Este patrón aparece constantemente en e-commerce, flujos de checkout de SaaS y cualquier aplicación donde los usuarios seleccionan cantidades o aplican descuentos. El patrón es tentador porque hace que el frontend se sienta rápido y responsivo.
// MAL: Precio calculado en el cliente, total enviado al servidor
// Cualquier usuario puede abrir DevTools y cambiar los precios antes de hacer submit
const checkout = async () => {
const total = items.reduce((sum, item) => sum + item.price * item.qty, 0);
await fetch("/api/checkout", {
method: "POST",
body: JSON.stringify({ items, total }), // Nunca confíes en este total
});
};// BIEN: El servidor recibe solo IDs de productos y cantidades
// Los precios se obtienen de la base de datos — nunca del body de la request
export async function POST(req: Request) {
const { items } = await req.json(); // Solo IDs y cantidades
// Obtener precios actuales de la DB — los valores de precio de la request se ignoran
const products = await db.products.findMany({
where: { id: { in: items.map((i) => i.id) } },
});
const total = items.reduce((sum, item) => {
const product = products.find((p) => p.id === item.id);
if (!product) throw new Error(`Producto desconocido: ${item.id}`);
return sum + product.price * item.qty; // Precio de la DB, no del cliente
}, 0);
return stripe.paymentIntents.create({ amount: total, currency: "mxn" });
}La versión segura ignora cualquier valor de precio que envíe el cliente. Usa IDs de productos para buscar los precios actuales en la base de datos, luego calcula el total del lado del servidor. Esta es la única forma correcta — no existe un "precio seguro del lado del cliente" porque el cliente lo controla el usuario.
Puedes ver más detalles sobre manipulación de precios y los vectores de ataque específicos.
Condiciones de Carrera en Pagos e Inventario
Las condiciones de carrera en pagos son un patrón clásico de diseño inseguro. El diseño asume que las requests llegan una a la vez. En la realidad, un usuario (o script) puede disparar múltiples requests simultáneas.
// MAL: Verificar-luego-actuar sin bloqueo — dos requests simultáneas
// ambas pasan la verificación de saldo antes de que ninguna deducción confirme
export async function POST(req: Request) {
const user = await db.users.findOne({ id: userId });
if (user.credits < itemCost) {
return Response.json({ error: "Créditos insuficientes" }, { status: 402 });
}
// Una segunda request puede pasar la verificación de arriba antes de que esta línea corra
await db.users.update({ id: userId }, { credits: user.credits - itemCost });
await fulfillOrder(userId, itemId);
}// BIEN: Decremento atómico que falla si el resultado quedaría por debajo de cero
// La base de datos impone la restricción — ninguna condición de carrera es posible
export async function POST(req: Request) {
const result = await db.$executeRaw`
UPDATE users
SET credits = credits - ${itemCost}
WHERE id = ${userId} AND credits >= ${itemCost}
RETURNING credits
`;
if (result.rowCount === 0) {
return Response.json({ error: "Créditos insuficientes" }, { status: 402 });
}
await fulfillOrder(userId, itemId);
}El fix baja la restricción a la base de datos, donde puede aplicarse de forma atómica. Ninguna cantidad de requests concurrentes puede bypass a una operación SQL atómica correctamente escrita. Lee más sobre condiciones de carrera en pagos y lo que le cuestan a negocios reales.
GraphQL Sin Límite de Profundidad de Queries
Las APIs REST tienen protección natural contra el abuso de queries — cada endpoint retorna una forma fija de datos. La flexibilidad de GraphQL también es su superficie de ataque. Sin un límite de profundidad, una sola request puede disparar queries de base de datos anidadas exponencialmente.
# MAL: Una query de aspecto legítimo que genera carga exponencial en la DB
# amigos -> amigos -> amigos -> ... 10 niveles = miles de queries a la DB
{
user(id: "1") {
friends {
friends {
friends {
friends {
name
email
}
}
}
}
}
}// BIEN: Aplicar límite de profundidad de query a nivel de GraphQL
import depthLimit from "graphql-depth-limit";
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(5), // Rechazar queries con más de 5 niveles de anidamiento
],
});Un límite de profundidad de 5 es razonable para la mayoría de las APIs. Muy bajo y queries legítimas fallan. Muy alto (o sin límite) y estás a un curl de un DoS autoinfligido. Aprende más sobre ataques con GraphQL sin límite de profundidad y cómo escalan.
Paginación Faltante — El Data Dump
Un endpoint que retorna todos los registros sin paginación está a una sola cláusula LIMIT de distancia de entregar una exportación completa de la base de datos a cualquiera que lo llame. Este es un patrón de diseño inseguro porque la decisión de paginar (o no) es arquitectónica, no un bug en una sola query.
// MAL: Retorna todos los registros — una request descarga toda la tabla
export async function GET(req: Request) {
const users = await db.users.findMany(); // Sin límite, sin offset
return Response.json(users);
}// BIEN: Aplicar paginación — limitar el tamaño máximo de página
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1"));
const limit = Math.min(100, parseInt(searchParams.get("limit") ?? "20")); // Máximo 100
const [items, total] = await Promise.all([
db.users.findMany({ skip: (page - 1) * limit, take: limit }),
db.users.count(),
]);
return Response.json({ items, total, page, limit });
}El breach de Parler en 2021 es el ejemplo de libro de texto. Su API no tenía autenticación en los endpoints de datos de usuarios y retornaba registros con IDs numéricos secuenciales. Sin rate limiting y sin auth, cualquiera podía enumerar cada usuario, post y post borrado simplemente incrementando un número. El código funcionaba exactamente como fue diseñado. El diseño era el problema.
Lee más sobre cómo la paginación faltante habilita data dumps en la práctica.
Upload de Archivos Sin Validación
Permitir uploads de archivos sin validar tipo, tamaño y contenido es una brecha de diseño. Verificar solo la extensión del archivo no es suficiente — los MIME types pueden suplantarse en los headers de la request. Necesitas validar el contenido real del archivo.
// MAL: Verifica el header Content-Type (controlado por el usuario) pero no el contenido real
// Un atacante envía un shell PHP con Content-Type: image/jpeg
export async function POST(req: Request) {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file.type.startsWith("image/")) {
return Response.json({ error: "Solo imágenes" }, { status: 400 });
}
await storage.upload(file); // Almacena lo que realmente se envió
}// BIEN: Validar extensión, MIME type, tamaño Y magic bytes del archivo
import { fileTypeFromBuffer } from "file-type";
export async function POST(req: Request) {
const formData = await req.formData();
const file = formData.get("file") as File;
// 1. Verificar tamaño primero (rápido y barato)
if (file.size > 5 * 1024 * 1024) {
return Response.json({ error: "Archivo muy grande (máx 5MB)" }, { status: 400 });
}
// 2. Verificar magic bytes — el contenido real del archivo, no el header
const buffer = Buffer.from(await file.arrayBuffer());
const detected = await fileTypeFromBuffer(buffer);
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
if (!detected || !allowedTypes.includes(detected.mime)) {
return Response.json({ error: "Tipo de archivo inválido" }, { status: 400 });
}
// 3. Generar un nombre de archivo aleatorio — nunca confíes en el nombre original
const filename = `${crypto.randomUUID()}.${detected.ext}`;
await storage.upload(buffer, filename);
}La diferencia clave: los magic bytes son los primeros bytes del contenido real del archivo que identifican el formato. Un JPEG empieza con FF D8 FF. Un PNG empieza con 89 50 4E 47. Estos bytes no se pueden falsificar fácilmente porque son parte de la estructura real del archivo. Lee más sobre validación de uploads de archivos y qué pasa cuando la omites.
Cómo Prevenir el Diseño Inseguro en la Práctica
El diseño inseguro no lo puede detectar un linter. No se soluciona con un parche de seguridad. Tiene que abordarse antes de escribir la primera línea de código para una funcionalidad. Así se ve eso en la práctica.
Threat Modeling — Antes de Codear
Para cada funcionalidad significativa, hazte estas preguntas antes de implementar:
- ¿Quién podría abusar de esto, y cómo? — No solo atacantes externos. Usuarios maliciosos, scrapers, tus propios empleados.
- ¿Cuál es el peor escenario si esto falla? — ¿Pérdida financiera, exposición de datos, interrupción del servicio?
- ¿Qué suposiciones está haciendo este diseño? — "Los usuarios solo van a enviar una request a la vez." "El cliente no va a modificar el precio." Esas suposiciones son tu superficie de ataque.
- ¿Dónde están los límites de confianza? — ¿Qué datos cruzan de no confiable (cliente) a confiable (servidor)? Cada cruce necesita validación.
El Threat Modeling Cheat Sheet de OWASP es un buen punto de partida si nunca lo has hecho de manera formal.
Casos de Abuso Junto a Casos de Uso
Cuando escribes "El usuario puede restablecer su contraseña", también escribe "Un atacante puede spamear el endpoint de restablecimiento para molestar a un usuario" y "Un atacante puede enumerar qué correos tienen cuenta midiendo el tiempo de respuesta". Esos son tus casos de abuso. Cada uno implica un requisito de diseño: rate limiting, respuestas de tiempo constante, políticas de bloqueo.
El ASVS de OWASP (Application Security Verification Standard) provee un conjunto estructurado de requisitos de seguridad organizados por área de funcionalidad. Vale la pena revisar las secciones relevantes para tu aplicación.
Validación del Lado del Servidor para Toda la Lógica de Negocio
Esta es la regla de mayor impacto: nunca confíes en el cliente para ningún cálculo crítico del negocio. Totales de precios, cálculos de descuentos, conteos de inventario, verificaciones de permisos — todo debe calcularse del lado del servidor con datos que tú controlas.
La validación del lado del cliente está bien para la UX. No es seguridad. Cualquier cosa que solo exista en el navegador puede ser modificada por la persona que usa el navegador.
Defensa en Profundidad
Diseña cada capa como si la capa superior ya hubiera fallado. Tu base de datos debe aplicar restricciones incluso si tu capa de API no las verifica. Tu API debe validar inputs incluso si tu frontend ya los validó. Tu procesador de pagos debe tener reglas de fraude incluso si tu backend ya verificó el pedido.
Un solo check faltante rara vez causa un breach. Pero "solo lo verificamos en el frontend" es una frase que aparece en los post-mortems más de lo que nadie quiere admitir.
Escanea tu Aplicación Buscando Patrones de Diseño Inseguro
Las herramientas de análisis estático son mejores detectando bugs de implementación que fallas de diseño. Pero algunos patrones de diseño inseguro sí dejan rastros en el código — middleware de rate limiting faltante, handlers de upload que omiten la verificación de magic bytes, esquemas de GraphQL sin reglas de profundidad.
Cuando escaneamos repos en Data Hogo, la falta de rate limiting en endpoints de auth es uno de los hallazgos más frecuentes — no porque sea difícil de agregar, sino porque es fácil de olvidar cuando vas rápido. Aparece como hallazgo de severidad Alta porque la superficie de ataque es obvia y el fix tarda unos 10 minutos.
Escanea tu repo gratis → y ve qué hallazgos de nivel de diseño aparecen en tu proyecto. El escaneo tarda menos de 60 segundos y no requiere tarjeta de crédito.
También puedes revisar las entradas específicas del enciclopedia para los patrones cubiertos en este post:
- Diseño Inseguro — descripción y ejemplos
- Condición de Carrera en Pagos
- Manipulación de Precios vía Cálculo del Lado del Cliente
- GraphQL Sin Límite de Profundidad de Query
- Paginación Faltante — riesgo de data dump
- Upload de Archivos Sin Validación
Preguntas Frecuentes
¿Qué es OWASP A04:2021 Diseño Inseguro?
OWASP A04:2021 Diseño Inseguro cubre fallas de seguridad que provienen de decisiones de diseño ausentes o inadecuadas — no de bugs en el código. Incluye la falta de modelado de amenazas, ausencia de requisitos de seguridad, fallas en la lógica de negocio y brechas arquitectónicas como la falta de rate limiting. La diferencia con otras categorías OWASP es que no puedes solucionarlo con un parche de código; el diseño mismo necesita cambiar.
¿Cuál es la diferencia entre diseño inseguro e implementación insegura?
Implementación insegura ocurre cuando un diseño correcto se ejecuta con un bug — como inyección SQL en una capa de queries bien diseñada. Diseño inseguro ocurre cuando el diseño mismo está mal — como calcular totales de pedidos en el cliente. Incluso código perfecto y sin bugs puede implementar un diseño inseguro. Ambos necesitan corrección, pero el diseño inseguro requiere cambios arquitectónicos, no solo parches de código.
¿Cómo prevengo la manipulación de precios del lado del cliente?
Nunca confíes en los precios enviados desde el cliente. Siempre recalcula el total del lado del servidor usando precios obtenidos directamente de tu base de datos. Tu API debe recibir IDs de productos y cantidades, obtener el precio actual de cada producto de la base de datos, calcular el total y luego cobrar esa cantidad. Cualquier valor de precio en el body de la request debe ignorarse por completo.
¿Qué es una condición de carrera en pagos y cómo se previene?
Una condición de carrera en pagos ocurre cuando un usuario envía múltiples requests simultáneas a un endpoint que verifica un saldo antes de descontarlo. Ambas requests pasan la verificación al mismo tiempo, antes de que ninguna deducción se confirme en la base de datos. La prevención requiere operaciones atómicas en la base de datos (UPDATE ... WHERE credits >= cost), claves de idempotencia en los payment intents, o bloqueo pesimista (SELECT FOR UPDATE) para serializar el acceso.
¿Pueden las herramientas de análisis estático detectar fallas de diseño inseguro?
No con confiabilidad. La mayoría de las herramientas SAST son buenas detectando bugs de implementación — secrets hardcodeados, inyección SQL, XSS. Pero las fallas de diseño inseguro viven a nivel arquitectónico, no en líneas de código individuales. Herramientas como Data Hogo pueden detectar patrones que indican problemas de diseño (falta de rate limiting en endpoints de auth, uploads sin validación), pero el modelado de amenazas completo requiere juicio humano aplicado antes del primer commit.
Posts Relacionados
OWASP A03 Ataques de Inyección
OWASP A03:2021 Inyección cubre SQL, NoSQL, XSS, inyección de comandos y más. Código vulnerable vs. seguro en Node.js, un breach real, y cinco estrategias de prevención.
OWASP A05 Configuración Insegura
El 90% de las aplicaciones tiene alguna configuración insegura. Aprende qué cubre OWASP A05:2021, ve el código vulnerable vs. seguro en Next.js, y corrígelo ya.
OWASP A01 Control de Acceso Roto
El control de acceso roto es el riesgo #1 del OWASP Top 10. Esta guía explica IDOR, JWT tampering y rutas sin auth — con ejemplos reales en Next.js y cómo corregirlos.