En la lección anterior identificamos nueve vulnerabilidades en un fragmento de BazarNube y las dejamos registradas como fichas BZN-xxx en el backlog. Encontrar el problema es solo la mitad del trabajo; la otra mitad —la que de verdad reduce el riesgo— es remediarlo bien. En este laboratorio tomamos esas mismas nueve fichas y, una a una, implementamos el control correcto: código corregido y comentado, mapeo al requisito ASVS que satisface, y verificación de que la corrección funciona mediante un re-escaneo con ZAP y pruebas dirigidas. La regla del ejercicio es la que rige el trabajo real de AppSec: un hallazgo no se cierra hasta que se verifica. Reutilizamos sin re-explicar los conceptos ya vistos —consultas parametrizadas (03-03), autorización por objeto (03-01), cabeceras y helmet (03-06), criptografía (03-02), ASVS (M4) y ZAP (M6)—; aquí los aplicamos.
Contenido
- Del hallazgo al control: el flujo de remediación
- Remediación por hallazgo (código corregido)
- Tabla maestra: hallazgo → control → ASVS → verificación
- Verificación: re-escaneo y regresión
- Errores comunes y consejos
- Ejercicios
- Conclusión
Del hallazgo al control: el flujo de remediación
Cada ficha del backlog recorre el mismo ciclo hasta poder marcarse como cerrada:
graph LR
A[Hallazgo BZN abierto] --> B[Elegir el control correcto]
B --> C[Implementar codigo seguro]
C --> D[Mapear a requisito ASVS]
D --> E[Verificar: re-escaneo y prueba]
E --> F{Verificado?}
F -->|Si| G[Cerrar BZN]
F -->|No| C
Dos principios guían la elección del control:
- Corregir la causa, no el síntoma. Filtrar una comilla no arregla una SQLi; parametrizar la consulta sí. Buscamos el control que elimina la clase entera de fallo.
- Defensa en capas. Cuando es barato, se combinan controles (validar entrada y parametrizar y mínimo privilegio) para que un fallo aislado no baste.
Remediación por hallazgo (código corregido)
BZN-134 — SQLi en el buscador (A03 → parametrización)
La causa es concatenar q en la cadena SQL. El control es una consulta parametrizada: los datos viajan aparte de la sentencia y nunca se interpretan como código.
// catalog.js — corregido
router.get('/api/products/search', async (req, res) => {
const q = String(req.query.q ?? '').slice(0, 100); // validacion de tipo y longitud
const sql = 'SELECT id, name, price FROM products WHERE name ILIKE $1';
const { rows } = await db.query(sql, [`%${q}%`]); // el valor va parametrizado
res.json(rows);
});El % se añade al valor, no a la sentencia: el driver de PostgreSQL lo escapa. Además validamos tipo y longitud (defensa en capas). Satisface ASVS V5.3.4 (consultas parametrizadas).
BZN-101 — IDOR en pedidos (A01 → autorización por objeto)
El control es comprobar que el objeto pertenece al usuario autenticado. Se hace en la propia consulta para evitar condiciones de carrera y olvidos.
// orders.js — corregido
router.get('/api/orders/:id', auth, async (req, res) => {
const { rows } = await db.query(
'SELECT * FROM orders WHERE id = $1 AND user_id = $2', // ligado al dueño
[req.params.id, req.user.id]
);
if (rows.length === 0) return res.status(404).json({ error: 'No encontrado' });
res.json(rows[0]);
});Devolvemos 404 (no 403) para no revelar la existencia del pedido ajeno. Satisface ASVS V4.2.1 (autorización a nivel de objeto).
BZN-140 — Path traversal en facturas (A01 → canonización y whitelist)
Nunca se une la entrada del usuario a un path sin normalizar. Se valida el nombre y se comprueba que la ruta resuelta queda dentro del directorio permitido.
// orders.js — corregido
const INVOICES_DIR = '/var/bazarnube/invoices';
router.get('/api/invoices', auth, async (req, res) => {
const name = String(req.query.file ?? '');
if (!/^[0-9]{4}-[0-9]{6}\.pdf$/.test(name)) { // whitelist de formato
return res.status(400).json({ error: 'Nombre invalido' });
}
const full = path.resolve(INVOICES_DIR, name);
if (!full.startsWith(INVOICES_DIR + path.sep)) { // la ruta no escapa del directorio
return res.status(400).json({ error: 'Ruta no permitida' });
}
// Ademas: comprobar que la factura pertenece a req.user antes de servirla
res.sendFile(full);
});Doble control: whitelist del formato y verificación de que la ruta canonizada no escapa. Satisface ASVS V12.3.1 / V12.3.2.
BZN-131 — SSRF en importar producto (A10 → validación de destino)
El control frente a SSRF es no dejar que el usuario dicte a dónde llama el servidor: validar esquema y dominio contra una allowlist y rechazar IPs internas.
// catalog.js — corregido
const ALLOWED_HOSTS = new Set(['images.bazarnube.com', 'cdn.proveedor.com']);
router.post('/api/products/import', auth, async (req, res) => {
let url;
try { url = new URL(req.body.imageUrl); } catch { return res.status(400).end(); }
if (url.protocol !== 'https:' || !ALLOWED_HOSTS.has(url.hostname)) {
return res.status(400).json({ error: 'Origen no permitido' }); // bloquea metadata, localhost, etc.
}
const resp = await fetch(url, { redirect: 'error' }); // no seguir redirecciones a destinos internos
const buffer = Buffer.from(await resp.arrayBuffer());
res.json({ ok: true });
});La allowlist y el bloqueo de redirecciones impiden alcanzar 169.254.169.254 o servicios internos. Satisface ASVS V12.6.1 (protección SSRF).
BZN-087 — XSS almacenado en reseñas (A03 → escapar/sanitizar salida)
El fallo es dangerouslySetInnerHTML con contenido de usuario. La corrección ideal es dejar que React escape el texto; si se necesita formato, se sanitiza con una librería.
// ProductReviews.jsx — corregido
export function ProductReviews({ reviews }) {
return (
<ul>
{reviews.map((r) => (
<li key={r.id}>{r.body}</li> // React escapa por defecto: no hay HTML inyectable
))}
</ul>
);
}Si el negocio exige negrita o enlaces, se usa DOMPurify.sanitize(r.body) con una allowlist de etiquetas, nunca el HTML crudo. Satisface ASVS V5.3.3 (codificación de salida contextual).
BZN-155 — Secreto JWT embebido y débil (A02 → gestión de secretos)
Los secretos salen del código y se cargan del entorno; se exige una longitud mínima.
// config.js — corregido
const jwtSecret = process.env.JWT_SECRET; // inyectado por el gestor de secretos
if (!jwtSecret || jwtSecret.length < 32) {
throw new Error('JWT_SECRET ausente o demasiado corto');
}
module.exports = {
jwtSecret,
jwtAlg: 'HS256',
db: { host: 'db', user: 'app', password: process.env.DB_PASSWORD, ssl: true },
cookie: { httpOnly: true, secure: true, sameSite: 'lax' },
};El secreto ya no vive en Git (lo bloquearía además gitleaks, 07-03) y la conexión a BD usa TLS. Satisface ASVS V6.4.1 / V2.10.4 (gestión de secretos).
BZN-041 y BZN-039 — Cabeceras y cookies (A05 → helmet y flags)
Un solo cambio en el arranque resuelve las dos fichas de configuración: helmet añade CSP y cabeceras, y las cookies se emiten con los tres flags.
// app.js — corregido
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"] } },
}));
// La cookie de sesion se emite con los flags definidos en config.cookie:
// res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'lax' });Satisface ASVS V14.4.x (cabeceras) y V3.4.x (atributos de cookie).
BZN-060 — Fugas por errores verbosos (A05 → manejo de errores)
El cliente recibe un mensaje genérico; el detalle va al log interno (base para la detección de 03-11).
// app.js — corregido
app.use((err, req, res, next) => {
logger.error({ msg: err.message, stack: err.stack, reqId: req.id }); // detalle solo en el log
res.status(500).json({ error: 'Error interno', reqId: req.id }); // nada sensible al cliente
});El reqId permite correlacionar sin exponer nada. Satisface ASVS V7.4.1 (no filtrar información sensible en errores).
Tabla maestra: hallazgo → control → ASVS → verificación
Esta tabla es el entregable del ejercicio: la trazabilidad completa de cada ficha, del problema a la prueba.
| Ficha | Categoría | Control implementado | Requisito ASVS | Cómo se verifica |
|---|---|---|---|---|
BZN-134 |
A03 | Consulta parametrizada + validación | V5.3.4 | ZAP activo: alerta SQLi desaparece; q=' OR 1=1-- no altera resultados |
BZN-101 |
A01 | Filtro user_id en la consulta |
V4.2.1 | Prueba con 2 usuarios: A no ve el pedido de B (404) |
BZN-140 |
A01 | Whitelist + path canonizado | V12.3.1 | ?file=../../etc/passwd devuelve 400; ZAP no reporta traversal |
BZN-131 |
A10 | Allowlist de host + no redirect | V12.6.1 | imageUrl=http://169.254.169.254/... devuelve 400 |
BZN-087 |
A03 | Escapado por defecto de React | V5.3.3 | Reseña <img onerror=...> se muestra como texto, no ejecuta |
BZN-155 |
A02 | Secreto en entorno, longitud mínima | V6.4.1 | gitleaks no encuentra secretos; arranque falla sin JWT_SECRET |
BZN-041 |
A05 | helmet + CSP | V14.4.x | ZAP pasivo: "CSP Header Not Set" desaparece |
BZN-039 |
A05 | Flags HttpOnly/Secure/SameSite | V3.4.x | Inspección Set-Cookie; ZAP no marca cookie insegura |
BZN-060 |
A05 | Error genérico + log interno | V7.4.1 | Provocar 500: respuesta sin stack; el detalle está en el log |
Verificación: re-escaneo y regresión
Cerrar una ficha exige evidencia de que la corrección funciona, no la palabra del desarrollador. Aplicamos dos comprobaciones complementarias:
- Re-escaneo ZAP sobre el staging con el fix desplegado. Reutilizamos el baseline de 06-04: las alertas que antes salían (
SQL Injection,Path Traversal,CSP Header Not Set, cookie insegura, error disclosure) deben desaparecer del informe. Si una persiste, la corrección no llegó a la ruta escaneada. - Pruebas dirigidas para lo que ZAP no ve: el IDOR (
BZN-101) se verifica con dos usuarios reales; el SSRF (BZN-131), lanzando la petición a un destino interno y comprobando el400; el secreto (BZN-155), pasando gitleaks en el pipeline.
Estas pruebas de verificación se convierten en tests de regresión: entran en la suite de CI (07-03) para que el fallo no reaparezca en un cambio futuro. En BazarNube, cada BZN-xxx cerrado deja tras de sí un test que lo vigila. Así el arreglo de hoy no se deshace mañana.
# Re-escaneo baseline en CI, reutilizando la config de 06-04
docker run -t ghcr.io/zaproxy/zaproxy zap-baseline.py \
-t https://staging.bazarnube.com \
-c zap-baseline.conf \
-r reporte-verificacion.html
# El pipeline falla si reaparece cualquier alerta que ya habiamos cerradoErrores Comunes y Consejos
- Arreglar el síntoma. Escapar comillas a mano, poner una WAF delante o filtrar
../con unreplaceson parches frágiles. Ataca la causa: parametrizar, canonizar, autorizar. - Corregir sin verificar. Un fix sin re-escaneo ni prueba no está terminado; muchos "arreglos" no cubren todas las rutas o se despliegan mal.
- Olvidar la defensa en capas. Validar la entrada está bien, pero no sustituye a parametrizar la consulta. Combina controles cuando el coste es bajo.
- No dejar test de regresión. Sin una prueba que lo vigile, el fallo vuelve en el siguiente refactor. Cada cierre debería generar su test.
- Consejo: remedia por clase de fallo, no por instancia. Si
BZN-134era una SQLi por concatenación, busca las demás concatenaciones del código y córrelas todas; probablemente hay más de la misma familia.
Ejercicios
Ejercicio 1. El equipo quiere reforzar el buscador ya parametrizado (BZN-134) con validación de entrada. Escribe la validación (tipo y longitud) y explica por qué es defensa en capas y no un sustituto de la consulta parametrizada.
Ejercicio 2. Para el SSRF (BZN-131), un compañero propone "bloquear las URLs que contengan localhost o 127.0.0.1" con un filtro de texto. Explica por qué esa allowlist-por-negación es insuficiente y qué enfoque es correcto.
Ejercicio 3. Tras desplegar los fixes, el re-escaneo ZAP sigue reportando "CSP Header Not Set" en una ruta concreta. Enumera tres causas posibles y cómo lo investigarías.
Soluciones
Solución 1. Validación:
Es defensa en capas porque reduce la superficie (rechaza entradas absurdas, limita longitud para evitar abusos de rendimiento), pero no es la protección contra SQLi: aunque un atacante enviara un q válido en longitud con contenido malicioso, la parametrización es la que impide que se interprete como SQL. La validación complementa, no reemplaza; confiar solo en ella (por ejemplo, con una blacklist de palabras SQL) sería evadible.
Solución 2. Filtrar por texto localhost/127.0.0.1 es una blacklist trivialmente evadible: existen 0.0.0.0, [::1], 127.0.0.2, la IP decimal 2130706433, DNS que resuelve a interno (DNS rebinding), redirecciones a destinos internos, y el metadata 169.254.169.254. Enumerar lo malo siempre deja huecos. El enfoque correcto es una allowlist de esquema (https) y de hosts permitidos, más redirect: 'error' y, si se puede, resolver la IP y rechazar rangos privados/link-local. Se permite lo conocido bueno, no se persigue lo malo.
Solución 3. Tres causas posibles: (1) el fix no cubre esa ruta —helmet se aplica antes de un router montado aparte que responde sin pasar por el middleware—; (2) una respuesta cacheada o un proxy/CDN intermedio sirve la versión antigua sin la cabecera; (3) esa ruta la sirve otro servicio (el legacy Java) que no lleva helmet. Investigación: curl -I directo al endpoint para ver las cabeceras reales, comprobar el orden de los middlewares en app.js, y verificar si la ruta la atiende Express o el backend legacy.
Conclusión
Hemos cerrado el ciclo completo sobre las nueve fichas: por cada una, el control que ataca la causa, el requisito ASVS que lo respalda y la verificación —re-escaneo ZAP o prueba dirigida— que demuestra que funciona, más el test de regresión que evita que reaparezca. Este es el trabajo real de AppSec: no basta con encontrar, hay que remediar bien y probar la remediación. Los ejercicios 08-01 y 08-02 han recorrido el ciclo defensivo de principio a fin sobre código concreto. Ahora cambiamos de perspectiva: en lugar de prevenir en el laboratorio, en la lección 08-03 investigamos qué pasa cuando una de estas vulnerabilidades no se corrige a tiempo y termina en un incidente real. Analizaremos una fuga de datos en BazarNube: su línea de tiempo, cómo se detectó, la causa raíz y qué control OWASP la habría evitado.
Curso de OWASP: Directrices y Estándares para la Seguridad en Aplicaciones Web
Módulo 1: Introducción a OWASP
Módulo 2: Principales Proyectos de OWASP
- OWASP Top Ten
- OWASP ASVS (Application Security Verification Standard)
- OWASP SAMM (Software Assurance Maturity Model)
- OWASP ZAP (Zed Attack Proxy)
- Otros Proyectos Clave: WSTG, Cheat Sheets y Dependency-Check
Módulo 3: OWASP Top Ten 2021 en Profundidad
- A01:2021 – Pérdida de Control de Acceso
- A02:2021 – Fallos Criptográficos y Exposición de Datos Sensibles
- A03:2021 – Inyección
- Cross-Site Scripting (XSS) en Profundidad
- A04:2021 – Diseño Inseguro
- A05:2021 – Configuración de Seguridad Incorrecta
- Entidades Externas XML (XXE)
- A06:2021 – Componentes Vulnerables y Desactualizados
- A07:2021 – Fallos de Identificación y Autenticación
- A08:2021 – Fallos de Integridad de Software y Datos (Deserialización Insegura)
- A09:2021 – Fallos de Registro y Monitorización
- A10:2021 – Server-Side Request Forgery (SSRF)
Módulo 4: OWASP ASVS (Application Security Verification Standard)
- Introducción a ASVS
- Niveles de Verificación
- Requisitos de Seguridad
- Implementación de ASVS en Proyectos
Módulo 5: OWASP SAMM (Software Assurance Maturity Model)
Módulo 6: OWASP ZAP (Zed Attack Proxy)
- Introducción a ZAP
- Instalación y Configuración
- Escaneo de Vulnerabilidades
- Automatización de Pruebas de Seguridad
Módulo 7: Buenas Prácticas y Recomendaciones
- Ciclo de Vida de Desarrollo Seguro (SDLC)
- Modelado de Amenazas (Threat Modeling)
- Integración de Seguridad en DevOps (DevSecOps)
- Capacitación y Concienciación en Seguridad
- Herramientas y Recursos Adicionales
Módulo 8: Ejercicios Prácticos y Casos de Estudio
- Ejercicio 1: Identificación de Vulnerabilidades
- Ejercicio 2: Implementación de Controles de Seguridad
- Caso de Estudio 1: Análisis de un Incidente de Seguridad
- Caso de Estudio 2: Mejora de la Seguridad en una Aplicación Web
