Prevenir Ataques de Inyección SQL
Cómo escribir queries parametrizadas y usar ORMs para eliminar vulnerabilidades de inyección SQL en Python, JavaScript y Java.
Visión general
La inyección SQL es una de las vulnerabilidades más comunes y peligrosas en aplicaciones web. Ocurre cuando un atacante inyecta código SQL malicioso en consultas de la aplicación a través de la entrada del usuario, llegando a exponer, modificar o eliminar bases de datos enteras. Los ataques de inyección llevan años en el OWASP Top 10 porque son fáciles de explotar y su impacto es devastador.
La causa raíz es casi siempre la misma: concatenar entrada no validada directamente en cadenas SQL. La solución es igual de directa: usar consultas parametrizadas o un ORM que haga el escaping automáticamente. Los ejemplos de abajo cubren Python, JavaScript y Java a nivel de driver; si trabajas concretamente con SQLAlchemy, la receta de inyección con SQLAlchemy profundiza en sus APIs ORM y Core.
Cuándo usarlo
Usa esta receta cuando:
- Escribes cualquier código que ejecute consultas SQL con valores dinámicos
- Migras código heredado que concatena cadenas para construir SQL
- Auditas aplicaciones existentes en busca de vulnerabilidades de inyección
- Entrenas desarrolladores en patrones seguros de acceso a bases de datos
- Configuras checklists de code review para cambios relacionados con bases de datos
Solución
Python
import sqlite3
# VULNERABLE — nunca hagas esto
# query = f"SELECT * FROM users WHERE email = '{user_input}'"
# SEGURO — query parametrizada
conn = sqlite3.connect("app.db")
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM users WHERE email = ? AND active = ?",
(email, True)
)
rows = cursor.fetchall()
JavaScript (Node.js con pg)
const { Pool } = require('pg');
const pool = new Pool();
async function getUserByEmail(email) {
// VULNERABLE — nunca hagas esto
// const query = `SELECT * FROM users WHERE email = '${email}'`;
// SEGURO — consulta parametrizada
const result = await pool.query(
'SELECT * FROM users WHERE email = $1 AND active = $2',
[email, true]
);
return result.rows;
}
Java (JDBC)
import java.sql.*;
// SEGURO — PreparedStatement
String sql = "SELECT * FROM users WHERE email = ? AND active = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, email);
stmt.setBoolean(2, true);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
System.out.println(rs.getString("name"));
}
}
}
Usando un ORM (Python/SQLAlchemy)
from sqlalchemy.orm import Session
from models import User
with Session(engine) as session:
users = session.query(User).filter_by(
email=email,
active=True
).all()
Explicación
- Consultas parametrizadas: el driver de la base de datos trata la entrada del usuario como datos, no como SQL ejecutable. Los placeholders (
?,$1,:name) se sustituyen de forma segura por el driver, sin que nada de lo inyectado se interprete como comandos. - Prepared statements: la base de datos compila el plan de ejecución una vez y lo ejecuta con distintos parámetros, así que obtienes planes cacheados además del beneficio de seguridad.
- ORMs: los mapeadores objeto-relacional como SQLAlchemy, Sequelize y Hibernate parametrizan las consultas automáticamente. Son la opción más segura para la mayoría de aplicaciones porque el SQL en crudo nunca llega a tu código.
- Stored procedures: pueden añadir una capa de abstracción, pero no previenen la inyección si ellos mismos concatenan la entrada dentro de SQL dinámico.
Variantes
| Enfoque | Seguridad | Flexibilidad | Mejor para |
|---|---|---|---|
| Consultas parametrizadas directas | Excelente | Alta | Consultas complejas, reporting |
| ORM | Excelente | Media | Aplicaciones CRUD-intensive |
| Stored procedures | Buena | Baja | Sistemas heredados, DBAs estrictos |
| Query builders (Knex, jOOQ) | Buena | Alta | Construcción dinámica de consultas |
Lo que funciona
- Nunca concatenes la entrada del usuario en cadenas SQL: ni siquiera para columnas de
ORDER BYo nombres de tablas. - Usa un ORM por defecto: SQLAlchemy, Sequelize o Hibernate parametrizan cada consulta sin que tengas que pensarlo.
- Valida la entrada antes de que llegue a la base de datos: la validación de entrada y las consultas parametrizadas son defensas complementarias.
- Usa cuentas de base de datos de menor privilegio: el usuario de la aplicación no debería tener permisos
DROP TABLEoGRANT. - Vigila los logs en busca de sondas de inyección: consultas fallidas llenas de keywords SQL o comillas raras suelen indicar que alguien está probando tus parámetros.
- Actualiza los drivers de base de datos con regularidad: los CVEs de conectores y ORMs incluyen a veces bypasses de inyección.
Errores comunes
- Usar
f-strings o template literals para SQL: es la causa más común de inyección SQL en código moderno. - Parametrización parcial: parametrizar el
WHEREpero concatenar columnas deORDER BYo nombres de tablas. - Confiar en la validación del cliente: los atacantes eluden la validación del frontend por completo. Toda validación debe ocurrir en el servidor.
- Usar
LIKEsin escapar comodines:%y_en la entrada del usuario producen coincidencias inesperadas incluso con consultas parametrizadas. - Asumir que los stored procedures son seguros: los que construyen SQL dinámico internamente siguen siendo vulnerables salvo que usen consultas parametrizadas.
Soluciones Avanzadas
ORDER BY con allowlist (Python)
Los nombres de tablas y columnas no pueden parametrizarse. Usa una lista permitida para manejar el orden dinámico de forma segura:
ALLOWED_SORT_COLUMNS = {
'name': 'users.name',
'email': 'users.email',
'created_at': 'users.created_at',
'updated_at': 'users.updated_at',
}
def get_users(sort_by: str = 'created_at', sort_dir: str = 'desc',
limit: int = 20, offset: int = 0):
"""Obtener usuarios con orden dinámico seguro (sqlite3)."""
# Validar columna de orden contra la lista permitida
column = ALLOWED_SORT_COLUMNS.get(sort_by)
if column is None:
raise ValueError(f'Columna de orden inválida: {sort_by}')
# Validar dirección de orden
direction = 'ASC' if sort_dir.upper() == 'ASC' else 'DESC'
# Limit y offset siguen parametrizados; solo identificadores
# verificados se interpolan en el texto de la consulta
query = f"""
SELECT id, name, email, created_at
FROM users
WHERE active = 1
ORDER BY {column} {direction}
LIMIT ? OFFSET ?
"""
conn = sqlite3.connect("app.db")
cursor = conn.execute(query, (limit, offset))
return cursor.fetchall()
# Uso
users = get_users(sort_by='name', sort_dir='asc', limit=10, offset=0)
# Un atacante que pase sort_by="name; DROP TABLE users--" recibe ValueError
Escaping de wildcards en LIKE (JavaScript)
Incluso con consultas parametrizadas, % y _ en la entrada del usuario producen comportamiento inesperado en LIKE:
const { Pool } = require('pg');
const pool = new Pool();
async function searchUsers(searchTerm) {
// Escapar comodines de LIKE en la entrada del usuario
const escapedTerm = searchTerm
.replace(/\\/g, '\\\\') // Escapar barras invertidas primero
.replace(/%/g, '\\%') // Escapar el signo de porcentaje
.replace(/_/g, '\\_'); // Escapar el guion bajo
const result = await pool.query(
`SELECT id, name, email FROM users
WHERE name LIKE $1 ESCAPE '\\'
ORDER BY name
LIMIT 20`,
[`%${escapedTerm}%`]
);
return result.rows;
}
// Uso: searchTerm = "50%_off" se convierte en "50\%\_off" en el patrón LIKE
// Coincide con la cadena literal "50%_off" en lugar de "50<cualquier><un carácter>off"
Seguridad en raw queries de Sequelize (Node.js)
El método query() de Sequelize admite SQL en crudo parametrizado:
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize(process.env.DATABASE_URL);
// SEGURO: replacements nombrados
const users = await sequelize.query(
'SELECT * FROM users WHERE email = :email AND active = :active',
{
replacements: { email: userEmail, active: true },
type: Sequelize.QueryTypes.SELECT,
}
);
// SEGURO: Parámetros posicionales
const orders = await sequelize.query(
'SELECT * FROM orders WHERE user_id = $1 AND status = $2',
{
bind: [userId, 'completed'],
type: Sequelize.QueryTypes.SELECT,
}
);
// PELIGROSO: Nunca hagas esto
// const result = await sequelize.query(
// `SELECT * FROM users WHERE email = '${userEmail}'`
// );
Queries type-safe con jOOQ (Java)
jOOQ genera SQL con tipos seguros desde tu esquema y elimina la inyección por construcción:
import static org.jooq.impl.DSL.*;
import org.jooq.DSLContext;
import org.jooq.Record;
import org.jooq.Result;
// jOOQ genera clases desde el esquema de tu base de datos
import static com.example.generated.tables.Users.USERS;
import static com.example.generated.tables.Orders.ORDERS;
public class UserRepository {
private final DSLContext ctx;
public UserRepository(DSLContext ctx) {
this.ctx = ctx;
}
public Result<Record> findActiveUsersByEmail(String email) {
return ctx.select()
.from(USERS)
.where(USERS.EMAIL.eq(email))
.and(USERS.ACTIVE.eq(true))
.fetch();
}
public Result<Record> searchUsersByName(String namePattern) {
// LIKE con tipos seguros y patrón escapado
return ctx.select()
.from(USERS)
.where(USERS.NAME.like("%" + namePattern + "%"))
.orderBy(USERS.CREATED_AT.desc())
.limit(20)
.fetch();
}
}
Detección de inyección con SQLMap
Prueba tu aplicación en busca de vulnerabilidades de inyección SQL (solo en entornos propios):
#!/bin/bash
# Probar un solo endpoint
sqlmap -u "https://example.com/api/users?id=1" \
--batch --level=3 --risk=2 \
--random-agent \
--output-dir=/tmp/sqlmap-results
# Probar con cookie de autenticación
sqlmap -u "https://example.com/api/users?id=1" \
--cookie="session=abc123" \
--batch --level=5
# Probar parámetros POST
sqlmap -u "https://example.com/api/login" \
--data="email=test@example.com&password=test" \
--batch --level=3
Ver También
- OWASP SQL Injection — patrones de ataque y hojas de prevención.
- CWE-89: Inyección SQL — la clasificación de debilidad detrás de esta receta.
- Consultas parametrizadas de node-postgres — cómo funcionan los parámetros
$1en el driverpg. - sqlmap — detección automática de inyección para tus propios entornos de staging.
- Repositorio companion — ejemplos ejecutables de consultas parametrizadas para Python, Node.js y Java.
Preguntas frecuentes
¿Es seguro usar string formatting para nombres de tablas o columnas?
No. Los nombres de tablas y columnas son identificadores, no valores, y no pueden parametrizarse. Usa una lista permitida de identificadores y rechaza cualquier otra cosa.
¿Los ORMs previenen completamente la inyección SQL?
Sí, para operaciones estándar. Sin embargo, los métodos de SQL en crudo como sequelize.query() o session.execute() siguen requiriendo parametrización manual.
¿Qué pasa con bases de datos NoSQL como MongoDB?
La inyección NoSQL también existe. Usa consultas parametrizadas o métodos del driver que acepten objetos, no concatenación de cadenas. Nunca pases entrada sin tratar a eval() ni a cláusulas $where.
¿Los prepared statements perjudican el rendimiento?
No. Normalmente mejoran el rendimiento porque la base de datos reutiliza el plan de ejecución. El coste es mínimo comparado con el beneficio de seguridad.
¿Qué es la inyección SQL de segundo orden?
Ocurre cuando la entrada maliciosa se almacena de forma segura —los inserts parametrizados cumplen su función— pero una consulta posterior lee ese valor guardado y lo concatena en una nueva cadena SQL. La parametrización tiene que cubrir toda consulta que toque el dato, no solo la que lo escribe.
¿Debo sanear la entrada o parametrizarla?
Parametriza, siempre. El saneado es una capa extra útil para reglas de negocio (longitud, formato, listas permitidas), pero no puede eliminar SQL hostil de forma fiable: los atacantes llevan años eludiendo filtros. Trata el saneado y la parametrización como defensas complementarias, no como alternativas.
¿Cómo compruebo si mi aplicación es vulnerable?
Apunta un escáner automático a un entorno de staging —sqlmap (mostrado arriba) es la opción estándar— y combínalo con payloads manuales en cada parámetro que llegue a una consulta. Prueba campos GET y POST y cualquier valor que se refleje en una consulta, incluidas cookies y headers.
Recursos Relacionados
Validación de Input: Schemas en 3 Lenguajes
Valida el input de usuarios en el límite de la API con librerías de schemas. Ejemplos con Pydantic, Zod y Jakarta Validation, sanitización y errores estructurados.
RecipeSanitizar Input de Usuario
Cómo sanitizar y validar input de usuario en Python, Java y JavaScript para prevenir ataques de inyección.
RecipePrevenir Cross-Site Scripting (XSS)
Cómo sanitizar input de usuario, escapar output y usar Content Security Policy para prevenir ataques XSS en aplicaciones web.
RecipeProteger APIs con encabezados de seguridad HTTP
Cómo configurar headers de seguridad esenciales como HSTS, CSP y X-Frame-Options para proteger APIs y aplicaciones web de ataques comunes.
RecipePreviene inyeccion SQL con consultas parametrizadas en
Protege aplicaciones Python de inyeccion SQL usando consultas parametrizadas de SQLAlchemy, modelos ORM, validacion de input e inspeccion de queries para acceso seguro a base de datos
RecipeTransacciones de Base de Datos en Python, JS y Java
Cómo usar transacciones ACID para garantizar integridad de datos en Python, JavaScript y Java con ejemplos SQL.