StackPractices
intermediate Por Mathias Paulenko

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.

Temas: security

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.
Dos caminos para la entrada del usuario — una consulta concatenada permite que la entrada altere el SQL que la base de datos interpreta, mientras una consulta parametrizada vincula la entrada como dato que jamás cambia la estructura de la sentencia

Variantes

EnfoqueSeguridadFlexibilidadMejor para
Consultas parametrizadas directasExcelenteAltaConsultas complejas, reporting
ORMExcelenteMediaAplicaciones CRUD-intensive
Stored proceduresBuenaBajaSistemas heredados, DBAs estrictos
Query builders (Knex, jOOQ)BuenaAltaConstrucción dinámica de consultas

Lo que funciona

  • Nunca concatenes la entrada del usuario en cadenas SQL: ni siquiera para columnas de ORDER BY o 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 TABLE o GRANT.
  • 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 WHERE pero concatenar columnas de ORDER BY o 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 LIKE sin 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

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.