Cómo prevenir la inyección SQL en Node.js, Python y Go
Fragmentos SQL vulnerables y corregidos para pg, Prisma, psycopg, SQLAlchemy y database/sql de Go, además de ORDER BY dinámico y listas IN seguras. CWE-89.
· 7 min de lectura · Lina Source LLC
La inyección SQL (CWE-89) es uno de los bugs más antiguos de la web y sigue siendo uno de los más dañinos. La causa no ha cambiado nunca: la entrada del usuario se pega dentro del texto de una consulta, así que la base de datos no puede distinguir los datos del código. La solución tampoco ha cambiado: envía la consulta y los valores por separado, y deja que el driver los vincule.
Lo que sí ha cambiado es dónde vive el bug. La mayoría de los equipos usan un ORM para las consultas del día a día, así que la inyección aparece ahora en las vías de escape: la consulta cruda escrita para un informe, el endpoint de búsqueda con orden dinámico, el script de migración, la herramienta de administración. Esta guía recorre la versión vulnerable y la corregida en tres ecosistemas, y después cubre los dos casos que los parámetros por sí solos no resuelven.
El impacto rara vez se limita a una tabla. Una única consulta inyectable suele ejecutarse con todos los privilegios de base de datos de la aplicación, así que puede leer los datos de todos los clientes, incluidos los hashes de contraseñas y los tokens de API, modificar filas y, en algunas bases de datos, llegar al sistema de archivos o a otros servidores. Las técnicas ciegas extraen datos bit a bit aunque el resultado de la consulta nunca se muestre al usuario, de modo que un endpoint que solo devuelve verdadero o falso sigue siendo explotable.
Por qué funcionan los parámetros y el escapado no
Con una consulta parametrizada, el driver envía el texto SQL con marcadores de posición y los valores viajan como datos aparte. La base de datos analiza la sentencia antes de ver siquiera el valor, así que una comilla o un punto y coma en la entrada no es más que un carácter dentro de una cadena. El escapado manual intenta que un valor sea seguro de pegar dentro del texto SQL, y falla con las codificaciones, los contextos numéricos, los identificadores y todos los casos límite en los que no pensaste. No escapes; vincula.
Node.js: pg y Prisma
Con node-postgres, el patrón vulnerable es un template literal pasado a query. La corrección es un marcador $1 y un array de valores. Prisma es seguro por defecto, pero tiene dos API crudas y solo una de ellas es segura con interpolación.
import { Pool } from "pg";
import { PrismaClient } from "@prisma/client";
const pool = new Pool();
const prisma = new PrismaClient();
// Vulnerable: la entrada pasa a formar parte del texto SQL
await pool.query(`SELECT id, email FROM users WHERE email = '${email}'`);
// Corregido: el valor se vincula como parámetro
await pool.query("SELECT id, email FROM users WHERE email = $1", [email]);
// Prisma: $queryRaw es un tagged template, las interpolaciones se vuelven parámetros
await prisma.$queryRaw`SELECT id, email FROM users WHERE email = ${email}`;
// Prisma: $queryRawUnsafe con concatenación es inyectable
await prisma.$queryRawUnsafe(
"SELECT id, email FROM users WHERE email = '" + email + "'"
);
// Si tienes que usar $queryRawUnsafe, pasa los valores como argumentos
await prisma.$queryRawUnsafe(
"SELECT id, email FROM users WHERE email = $1",
email
);Una trampa sutil de Prisma: construir primero la cadena de la consulta y pasársela a $queryRaw pierde la protección. La seguridad viene de la sintaxis de tagged template. Si necesitas componer fragmentos, usa Prisma.sql y Prisma.join, que mantienen los valores como parámetros. La misma regla se aplica a otras librerías de tagged template como postgres.js y slonik: el tag es lo que hace segura la interpolación, así que una cadena de consulta montada de antemano y pasada como un valor normal se la salta.
Python: psycopg y SQLAlchemy
En Python las herramientas peligrosas son las f-strings, el operador % y str.format aplicados a SQL. psycopg usa marcadores %s, que parecen formateo de cadenas pero no lo son: los valores van en el segundo argumento de execute, nunca a través del operador %. El constructor text() de SQLAlchemy es seguro cuando usas parámetros de vinculación con nombre e inseguro cuando formateas valores dentro de la cadena.
Hay un detalle de psycopg que pilla a mucha gente: el marcador es %s (o %(name)s para valores con nombre) sea cual sea el tipo de la columna, y nunca se le ponen comillas alrededor. Escribir '%s' con comillas convierte el parámetro de nuevo en parte de un literal de cadena y rompe la consulta. Lo mismo ocurre con los parámetros con nombre de SQLAlchemy: :status, no ':status'.
from sqlalchemy import text
# psycopg: vulnerable
cur.execute(f"SELECT id, email FROM users WHERE email = '{email}'")
# psycopg: corregido, los valores se pasan por separado
cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))
# SQLAlchemy text(): vulnerable
conn.execute(text(f"SELECT id FROM orders WHERE status = '{status}'"))
# SQLAlchemy text(): corregido con un parámetro de vinculación
conn.execute(
text("SELECT id FROM orders WHERE status = :status"),
{"status": status},
)Go: database/sql
El paquete database/sql de Go admite marcadores de posición de forma nativa, pero su sintaxis depende del driver: $1 para drivers de PostgreSQL como pgx, ? para MySQL y SQLite. El patrón vulnerable es usar fmt.Sprintf o concatenación de cadenas para construir la cláusula WHERE. Como Go es de tipado estático, resulta tentador suponer que un parámetro int no puede ser peligroso. Eso vale para el valor en sí, pero la costumbre de construir consultas con Sprintf se contagia a los parámetros de cadena que tiene al lado, así que mantén todas las consultas sobre marcadores.
// Vulnerable: concatenación de cadenas
q := "SELECT id, email FROM users WHERE email = '" + email + "'"
rows, err := db.QueryContext(ctx, q)
// Corregido: marcador más argumento (sintaxis de PostgreSQL; MySQL usa ?)
var u User
err = db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE email = $1", email,
).Scan(&u.ID, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
// no encontrado
}Las vías de escape del ORM
Los ORM protegen el query builder, no todos los métodos del ORM. Estos son los sitios donde mirar primero en cualquier base de código:
- Prisma: $queryRawUnsafe y $executeRawUnsafe, y cualquier llamada a $queryRaw que reciba una cadena ya construida en lugar de un tagged template.
- Sequelize y TypeORM: sequelize.query, llamadas a .where() del query builder con una cadena concatenada, y cláusulas order o group crudas.
- Knex: knex.raw y whereRaw con valores interpolados en lugar de vinculaciones con ?.
- SQLAlchemy: text() con f-strings, y literal_column() o nombres de columna tomados de la entrada.
- Django: .raw(), .extra() y cursor.execute con cadenas formateadas.
- GORM: Where, Order y Raw llamados con la salida de fmt.Sprintf en lugar de con argumentos.
ORDER BY dinámico: los parámetros no pueden ayudar
Los marcadores vinculan valores, no identificadores. No puedes escribir ORDER BY $1 y pasar un nombre de columna; la base de datos ordenaría por una constante. Así que una tabla ordenable tiende a empujar a quien la programa de vuelta a construir cadenas, y ahí es donde vuelve la inyección. La misma limitación se aplica a los nombres de tabla, los nombres de esquema y las palabras clave SQL como ASC y DESC.
El patrón seguro es una allowlist que asocie las claves de ordenación visibles para el usuario con nombres de columna conocidos. La entrada selecciona una de las entradas; nunca se convierte en SQL. La dirección recibe el mismo trato: se mapea a un ASC o un DESC fijos.
var sortColumns = map[string]string{
"created": "created_at",
"name": "name",
"price": "price_cents",
}
col, ok := sortColumns[r.URL.Query().Get("sort")]
if !ok {
col = "created_at"
}
dir := "ASC"
if r.URL.Query().Get("dir") == "desc" {
dir = "DESC"
}
// Seguro: col y dir solo pueden tomar valores del código anterior
query := fmt.Sprintf(
"SELECT id, name, price_cents FROM products ORDER BY %s %s LIMIT $1",
col, dir,
)
rows, err := db.QueryContext(ctx, query, limit)Si realmente necesitas un identificador dinámico que no se puede poner en una allowlist, usa el entrecomillado de identificadores del driver en lugar del tuyo. En psycopg eso es sql.SQL(...).format(sql.Identifier(name)); en pgx es pgx.Identifier{name}.Sanitize(). Aun así, una allowlist sigue siendo mejor, porque el entrecomillado hace que el nombre sea seguro, pero no necesariamente válido ni permitido. Un identificador entrecomillado sigue permitiendo a quien llama ordenar o filtrar por cualquier columna de la tabla, incluidas las que nunca pensaste exponer, como un hash de contraseña o una puntuación interna. Ordenar por una columna oculta puede filtrar sus valores a través del orden de los resultados, una comparación cada vez.
Listas IN sin construir cadenas
Filtrar por una lista de ID es la otra excusa habitual para concatenar. Todos los stacks importantes tienen una forma segura de hacerlo:
- PostgreSQL con pg o pgx: pasa la lista entera como un único parámetro de tipo array y escribe WHERE id = ANY($1). El driver la envía como un array de Postgres.
- Prisma: Prisma.sql`SELECT * FROM users WHERE id IN (${Prisma.join(ids)})` se expande a un marcador por elemento.
- SQLAlchemy: text("... WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)), y después pasa una lista.
- Go con MySQL: genera la cadena de marcadores a partir de la longitud de la lista (strings.Repeat("?,", n) recortado) y pasa los valores como argumentos. Solo se generan los marcadores, nunca los valores.
- Rechaza las listas vacías antes de ejecutar la consulta y limita el tamaño de la lista para que el endpoint no se pueda usar para construir sentencias enormes.
Defensa en profundidad
La parametrización es la corrección. Estas medidas reducen el radio de impacto cuando una consulta se cuela:
- Conéctate con un rol de base de datos que tenga solo los privilegios que necesita la aplicación. Una aplicación web rara vez necesita DROP, ALTER ni acceso a otros esquemas.
- No devuelvas errores crudos de la base de datos a los clientes. La inyección basada en errores depende de poder verlos.
- Valida los tipos en la frontera: un ID que debería ser un entero o un UUID tiene que parsearse como tal antes de llegar a la capa de datos.
- Configura un statement timeout, que limita por igual la inyección ciega basada en tiempo y las consultas desbocadas.
Encontrar las que ya tienes
Empieza buscando las vías de escape de la lista anterior y después palabras clave SQL cerca de formateo de cadenas: SELECT o WHERE dentro de template literals, f-strings, Sprintf o concatenación con +. Cada coincidencia está correctamente vinculada, construida a partir de una allowlist, o es un bug. No te saltes las rutas de código que parecen internas: los importadores de CSV, los cron jobs y los paneles de administración suelen recibir entradas que en su origen venían de un usuario, solo que un paso más atrás. Los valores que lees de vuelta de tu propia base de datos pueden llevar un payload que se guardó de forma segura antes y se pegó en una consulta después, lo que se conoce como inyección de segundo orden. CodeAuditAgent hace ese rastreo entre archivos para repositorios públicos de GitHub y código pegado, y reporta hallazgos CWE-89 con la consulta citada, cómo llega la entrada hasta ella y una versión corregida.
La regla con la que quedarse es corta: los valores van en parámetros, los identificadores salen de una allowlist y nada de la petición se pega jamás dentro del texto SQL.