Inyección SQL
Una validación de login es una pregunta para la base de datos. ¿Existe una fila donde el nombre de usuario y la contraseña coinciden con lo que se escribió?
const query = `
SELECT * FROM users
WHERE username = '${username}' AND password = '${password}'
`Si regresa una fila, las credenciales eran correctas. Si no regresa ninguna, no lo eran.
Eso se lee como una pregunta. Para la base de datos es una oración, y quien la visita logró escribir parte de ella.
Una consulta construida pegando strings deja que quien la envía decida qué significa la consulta.
El bug
SQL es un lenguaje, y esta consulta es un programa escrito de cero en cada solicitud. La mayor parte viene de ti. Dos piezas vienen de quien esté frente al teclado.
La base de datos nunca ve esa costura. Cuando la consulta llega, es un solo bloque continuo de texto, y la base de datos no tiene forma de saber cuáles caracteres escribiste tú y cuáles un desconocido. Analiza todo el texto y hace lo que dice.
Eso es la inyección SQL: datos que terminan leyéndose como parte de la consulta en vez de como un valor dentro de ella. Las consecuencias dependen de todo lo que SQL puede expresar, que es bastante:
- Leer filas que quien hace la solicitud nunca debería ver.
- Cambiar o borrar datos.
- Ejecutar comandos administrativos contra el sistema de base de datos.
- En algunas configuraciones, llegar hasta el sistema operativo subyacente.
Lo que en realidad estás haciendo es entregarle a la base de datos una oración terminada, con la esperanza de que las palabras que puso alguien más sean solo palabras.
El ataque
Deja la contraseña en paz. Escribe esto como nombre de usuario:
' OR 1=1 --Sustituido en la plantilla, la base de datos recibe:
SELECT * FROM users
WHERE username = '' OR 1=1 --' AND password = ''Tres caracteres hicieron todo el trabajo:
- La
'de apertura cierra el string del username antes de tiempo, así que todo lo que viene después se lee como sintaxis de la consulta y no como un nombre. OR 1=1es una condición siempre verdadera, así que toda la cláusulaWHEREse cumple para cada fila.--inicia un comentario de SQL, así que la verificación de contraseña que viene después queda como texto que la base de datos ignora.
Cualquier usuario aparece como resultado. La aplicación toma la primera fila como prueba de un login exitoso y deja entrar al atacante como si fuera otra persona.
Prueba esto solo contra tu propia base de datos
Estos payloads modifican y destruyen datos. Eso los vuelve peligrosos en cualquier lugar con registros reales, incluida una copia de staging de producción. Usa una base de datos local que puedas borrar y reconstruir, o un sistema para el que tengas permiso escrito de hacer pruebas.
Cierra la comilla para pasar de escribir texto a escribir consulta. Agrega una condición que siempre sea verdadera. Comenta todo lo que no quieras que se procese.
La solución
Deja de construir la oración. Describe su estructura una sola vez, y luego entrega los valores por separado:
const query = 'SELECT * FROM users WHERE username = ? AND password = ?'
const [rows] = await db.execute(query, [username, password])Las marcas ? son placeholders. La sintaxis exacta varía según la base de datos y la librería, $1 y $2 en algunas, parámetros con nombre en otras, pero la idea es la misma en todos los casos: el texto de la consulta queda fijo antes de que llegue cualquier valor del usuario.
Esto se llama sentencia preparada con enlace de variables, o más comúnmente, consulta parametrizada.
Aquí la forma queda definida primero. Los valores llegan después, como valores, y nada en ellos puede cambiar una decisión que ya se tomó.
Por qué funciona la solución
Aquí nadie inspecciona el dato de entrada ni le quita nada. ' OR 1=1 -- sigue llegando exactamente como se escribió.
Lo que cambió es dónde termina ese valor. La base de datos ya sabe que está buscando un username y un password, así que el payload se compara como un username, carácter por carácter, contra una columna de nombres.
Ninguna cuenta se llama ' OR 1=1 --. No regresa ninguna fila, el login falla, y esa es la respuesta correcta.
Esa es la defensa a nivel de la base de datos, y se suma a las demás que esta sección ha ido construyendo:
| Capa | Qué decide |
|---|---|
| Validación en el navegador | Si el formulario se envía, solo para usuarios honestos |
| Validación de esquema en el servidor | Si la solicitud tiene la forma correcta para siquiera aceptarla |
| Salida segura | Si un valor guardado puede convertirse en código dentro de una página |
| Consultas parametrizadas | Si un valor puede convertirse en parte de una consulta |
Vale la pena quedarse con esta idea: las correcciones más seguras suelen cambiar cómo se trata un valor, en vez de intentar averiguar si se ve peligroso.
Ponlo a prueba
Un formulario de registro inserta un nombre y un correo:
const query = `
INSERT INTO users (name, email)
VALUES ('${name}', '${email}')
`Ponte en el lugar del atacante. Diseña un valor para el campo de correo que borre por completo la tabla users.
Hay tres cosas que resolver: cómo dejar de estar dentro del valor entre comillas, cómo dejar de estar dentro de los paréntesis, y cómo terminar una sentencia para que pueda empezar una segunda.
Compara tus respuestas
El valor del correo es:
'); DROP TABLE users; --Que produce:
INSERT INTO users (name, email)
VALUES ('Daniela', ''); DROP TABLE users; --')Cuatro movimientos, que corresponden a las tres preguntas más un ajuste final:
'cierra el string dentro del que estaba el correo.)cierra el paréntesis deVALUES, completando elINSERTcomo una sentencia válida.;termina esa sentencia, así que lo que sigue se lee como una nueva.--comenta el')que quedó suelto al final, que de otro modo sería un error de sintaxis y haría que todo el bloque fuera rechazado.
El último movimiento es el que la mayoría pasa por alto. Sin él la sentencia queda malformada, la base de datos rechaza todo el bloque, y el ataque falla por razones que no tienen nada que ver con tus defensas.
Vale la pena saber esto: muchos drivers rechazan por defecto múltiples sentencias en una sola llamada, así que este payload exacto no siempre funciona. Eso es una configuración que te protege, no una corrección real. El mismo hueco de seguridad sigue permitiendo leer cualquier dato que un atacante quiera mediante un UNION SELECT, que ni siquiera necesita una segunda sentencia.
Parametriza la consulta y todo el string se convierte simplemente en el correo poco común de alguien, guardado tal como se escribió.
Hacia dónde va esto
Tres capítulos, tres bugs, una sola causa. Nada verificó lo que llegaba antes de que la aplicación actuara sobre ello, así que un valor elegido por un desconocido terminó decidiendo qué hacía el código.
Cada corrección hasta ahora ha estado en el punto de uso: la propiedad correcta para renderizar, un límite de tamaño, un placeholder en una consulta. Son necesarias, y también llegan tarde: hay que repetirlas en cada destino, y es fácil olvidarlas una sola vez.
Zod fundamentals empieza la otra mitad del trabajo: describir la forma que esperas una sola vez, en el límite de entrada, y rechazar todo lo que no coincida.

