SQL Injection
SQL Injection (SQLi) es una vulnerabilidad que permite a un atacante interferir en las consultas SQL que una aplicación realiza a su base de datos. Al inyectar código SQL malicioso a través de entradas del usuario, el atacante puede:
- Leer datos sensibles que no debería ver (contraseñas, datos personales, tarjetas…).
- Modificar o eliminar datos.
- Saltarse la autenticación.
- En algunos casos, ejecutar comandos en el sistema operativo del servidor.
Es una de las vulnerabilidades más antiguas y más explotadas. Forma parte del OWASP Top 10 desde su primera edición.
Cómo funciona
La causa raíz es siempre la misma: concatenar directamente la entrada del usuario en una consulta SQL.
Ejemplo básico: bypass de login
// Código vulnerable
app.post('/login', async (req, res) => {
const { usuario, password } = req.body;
const query = `SELECT * FROM usuarios
WHERE usuario = '${usuario}'
AND password = '${password}'`;
const resultado = await db.query(query);
if (resultado.length > 0) {
res.json({ ok: true, token: generarToken(resultado[0]) });
} else {
res.status(401).json({ error: 'Credenciales incorrectas' });
}
});
Si un atacante introduce en el campo usuario el valor:
' OR '1'='1' --
La consulta resultante sería:
SELECT * FROM usuarios
WHERE usuario = '' OR '1'='1' --' AND password = 'lo_que_sea'
OR '1'='1'siempre es verdadero y devuelve todos los usuarios.--comenta el resto de la consulta (incluida la verificación de contraseña).- El atacante entra como el primer usuario de la tabla (normalmente el administrador).
Tipos de SQL Injection
In-band SQLi
Es el más común. El resultado de la inyección se devuelve directamente en la respuesta de la aplicación.
Se puede dividir en dos tipos:
-
Error-based: el atacante provoca errores SQL que revelan información sobre la estructura de la base de datos.
' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT version()))) -- -
Union-based: se usa
UNION SELECTpara extraer datos de otras tablas.-- Input malicioso en el campo id:
-- 1 UNION SELECT usuario, password, null FROM usuarios --
SELECT nombre, descripcion, precio
FROM productos
WHERE id = 1 UNION SELECT usuario, password, null FROM usuarios --
Blind SQLi
La aplicación no devuelve el resultado de la consulta directamente, pero el atacante puede inferir información mediante respuestas de tipo sí/no o midiendo tiempos de respuesta.
Se puede dividir en dos tipos:
-
Boolean-based:
' AND 1=1 -- → Respuesta normal (condición verdadera)
' AND 1=2 -- → Respuesta vacía o diferente (condición falsa) -
Time-based:
-- Si la base de datos tarda 5 segundos en responder, la condición es verdadera
'; IF (1=1) WAITFOR DELAY '0:0:5' --
Out-of-band SQLi
El atacante extrae datos a través de canales alternativos (peticiones DNS, HTTP) en lugar de la respuesta directa. Menos común, pero posible en configuraciones específicas.
Impacto real: extracción de una tabla completa
Con UNION SELECT, un atacante podría:
-
Descubrir el número de columnas:
' ORDER BY 1-- ' ORDER BY 2-- ' ORDER BY 3-- (hasta que falle) -
Identificar qué columnas muestran datos:
' UNION SELECT null, null, null -- -
Extraer el nombre de las tablas:
' UNION SELECT table_name, null, null FROM information_schema.tables -- -
Extraer columnas de una tabla concreta:
' UNION SELECT column_name, null, null
FROM information_schema.columns
WHERE table_name = 'usuarios' -- -
Extraer los datos:
' UNION SELECT usuario, password, email FROM usuarios --
Cómo prevenir SQL Injection
Consultas parametrizadas (Prepared Statements)
Es la defensa principal y más efectiva. Los parámetros se envían al motor SQL separados de la consulta, por lo que nunca se interpretan como código SQL.
Con mysql2:
// Seguro: parámetros separados de la consulta
const [rows] = await connection.execute(
'SELECT * FROM usuarios WHERE usuario = ? AND password = ?',
[usuario, password] // Los ? se sustituyen de forma segura
);
Con pg (PostgreSQL):
const result = await client.query(
'SELECT * FROM usuarios WHERE usuario = $1 AND password = $2',
[usuario, password]
);
Con Sequelize:
// Un ORM usa consultas parametrizadas internamente
const usuario = await Usuario.findOne({
where: { usuario: req.body.usuario, password: req.body.password }
});
Con Knex.js:
const usuario = await knex('usuarios')
.where({ usuario: req.body.usuario, password: req.body.password })
.first();
ORM / Query Builders
Usar un ORM (Sequelize, Prisma, TypeORM) o un query builder (Knex) reduce drásticamente el riesgo porque abstraen la construcción de SQL y usan parámetros automáticamente.
// Con Prisma
const usuario = await prisma.usuario.findFirst({
where: {
email: req.body.email
}
});
Incluso con un ORM, si usas consultas SQL en crudo ($queryRaw, sequelize.query…), debes seguir usando parámetros.
Validación y saneamiento de entradas
Complementa las consultas parametrizadas. Rechaza entradas que no tienen el formato esperado:
import validator from 'express-validator'
const { body, validationResult } = validator
app.post('/login', [
body('usuario').isEmail().normalizeEmail(),
body('password').isLength({ min: 8, max: 128 })
], (req, res) => {
const errores = validationResult(req);
if (!errores.isEmpty()) {
return res.status(400).json({ errores: errores.array() });
}
// ...
});
Principio de mínimo privilegio en la base de datos
El usuario de base de datos que usa la aplicación no debería tener permisos innecesarios.
Nunca se debe usar root o un usuario con todos los privilegios. Se debe crear un usuario con solo los permisos necesarios.
-- Crear un usuario con solo los permisos necesarios
CREATE USER 'app_usuario'@'localhost' IDENTIFIED BY 'contraseña_segura';
-- Solo SELECT, INSERT, UPDATE, DELETE en las tablas necesarias
GRANT SELECT, INSERT, UPDATE, DELETE ON mi_app.* TO 'app_usuario'@'localhost';
-- Sin DROP, ALTER, CREATE, FILE...
FLUSH PRIVILEGES;
No exponer errores de SQL al cliente
try {
const resultado = await db.query(...);
} catch (err) {
// Nunca enviar el error SQL al cliente
// res.status(500).json({ error: err.message });
// Log interno + mensaje genérico
console.error('Error DB:', err);
res.status(500).json({ error: 'Error interno del servidor' });
}