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

  1. Del hallazgo al control: el flujo de remediación
  2. Remediación por hallazgo (código corregido)
  3. Tabla maestra: hallazgo → control → ASVS → verificación
  4. Verificación: re-escaneo y regresión
  5. Errores comunes y consejos
  6. Ejercicios
  7. 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:

  1. 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.
  2. 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 el 400; 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 cerrado

Errores Comunes y Consejos

  • Arreglar el síntoma. Escapar comillas a mano, poner una WAF delante o filtrar ../ con un replace son 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-134 era 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:

const q = String(req.query.q ?? '').trim().slice(0, 100);
if (q.length === 0) return res.json([]);

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

Módulo 3: OWASP Top Ten 2021 en Profundidad

Módulo 4: OWASP ASVS (Application Security Verification Standard)

Módulo 5: OWASP SAMM (Software Assurance Maturity Model)

Módulo 6: OWASP ZAP (Zed Attack Proxy)

Módulo 7: Buenas Prácticas y Recomendaciones

Módulo 8: Ejercicios Prácticos y Casos de Estudio

Módulo 9: Evaluación y Certificación

© Copyright 2026. Todos los derechos reservados