Volver al blog
Node.jsNext.jsInyección SQLInyección NoSQLPrismaMongoDBSeguridad de Bases de DatosOWASP

Prevención de Inyección SQL y NoSQL en Node.js: Seguridad de ORMs y Consultas Parametrizadas [2026]

Por Qué la Inyección Sigue Siendo el Riesgo Web #1

OWASP ha clasificado la inyección en o cerca de la cima del Top 10 durante dos décadas. No es porque los desarrolladores no la conozcan — es porque el modo de fallo es tan fácil de introducir por accidente. Un template literal en una consulta, un objeto controlado por el usuario pasado a un filtro, una llamada raw() con una cadena concatenada, y toda tu base de datos es legible, escribible o eliminable.

Las apuestas son concretas: la brecha de TalkTalk (2015, ~157.000 registros de clientes) y la brecha de Marriott/Starwood (2018, ~500 millones de registros de huéspedes) se remontan ambas a inyección SQL. Para una startup, un solo hallazgo de inyección durante una auditoría de seguridad puede matar un acuerdo empresarial — y la solución cuesta una fracción del coste de la remediación.

La verdad incómoda sobre la que trata este artículo: tu ORM no es una frontera de seguridad. Prisma, Knex y Sequelize te protegen por defecto — y te dan APIs afiladas y sin proteger para cuando necesitas SQL en bruto. Cada inyección en código Node.js de producción que he auditado entró por una de tres puertas: concatenación de cadenas en SQL en bruto, inyección de orderBy/identificadores o inyección de operadores NoSQL.

Cómo Ocurre Realmente la Inyección SQL en Node.js

El patrón vulnerable es siempre el mismo: entrada controlada por el atacante interpolada en una cadena SQL.

js
// ❌ NUNCA hagas esto — esta es la vulnerabilidad, en su forma más pura
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

app.post("/api/login", async (req, res) => {
  const { email, password } = req.body;

  const query = `
    SELECT * FROM users
    WHERE email = '${email}' AND password = '${password}'
  `;
  const result = await pool.query(query);
  // ...
});

Un atacante envía:

POST /api/login
{"email": "' OR '1'='1' --", "password": "anything"}

La consulta se convierte en:

sql
SELECT * FROM users WHERE email = '' OR '1'='1' --' AND password = 'anything'

'1'='1' es siempre verdadero, y -- comenta el resto. La consulta devuelve la primera fila de la tabla de usuarios — a menudo la del admin. Sin necesidad de contraseña.

El mismo bug aparece con mysql2:

js
// ❌ Inseguro
const [rows] = await connection.query(
  `SELECT * FROM users WHERE email = '${email}'`
);

Consultas Parametrizadas: La Única Solución Real

La solución no es el escaping — es la parametrización. La sentencia SQL y los datos viajan a la base de datos por separado. El motor de la base de datos compila la sentencia con marcadores de posición $1/? y enlaza los valores como datos, nunca como código. Los trucos de comillas dentro del valor son inertes.

js
// ✅ Seguro — estilo sentencia preparada de pg
const query = `
  SELECT * FROM users
  WHERE email = $1 AND password_hash = $2
`;
const result = await pool.query(query, [email, passwordHash]);
js
// ✅ Seguro — mysql2 .execute() usa sentencias preparadas reales
const [rows] = await connection.execute(
  "SELECT * FROM users WHERE email = ? AND tenant_id = ?",
  [email, tenantId]
);

Una nota sobre mysql2: connection.query() realiza escaping en el lado del cliente — interpola los valores escapados en la cadena. Es más seguro que la concatenación manual, pero connection.execute() envía una sentencia preparada real al servidor. Prefiere execute() para cualquier cosa sensible a la seguridad (y por la ventaja de rendimiento del cacheo de sentencias).

Regla general: si una cadena SQL contiene un marcador de posición $1, ? o :name, los datos no pueden convertirse en SQL. Si contiene un '${...}', sí pueden.

ORMs: Seguros por Defecto, Peligrosos por Diseño

Los ORMs son la mayor victoria individual para la resistencia a la inyección — el query builder parametriza todo lo que expresas a través de su API. El riesgo se desplaza a las válvulas de escape: métodos de consulta en bruto, identificadores dinámicos y composición de fragmentos.

Prisma

ts
// ✅ Seguro — el query builder lo parametriza todo
const users = await prisma.user.findMany({
  where: { email, tenantId },
});

// ✅ Seguro — tagged template: ${values} se enlazan como parámetros
const rows = await prisma.$queryRaw`
  SELECT id, email FROM users
  WHERE tenant_id = ${tenantId} AND email = ${email}
`;

La forma de tagged template ($queryRaw) es segura porque es un tagged template — Prisma parsea las interpolaciones y las enlaza como parámetros. El peligro es la variante sin tag:

ts
// ❌ Inseguro — interpolación de cadena plana, nada se enlaza
const rows = await prisma.$queryRawUnsafe(
  `SELECT * FROM users WHERE email = '${email}'`
);

$queryRawUnsafe es exactamente la misma vulnerabilidad que pg en bruto, con gafas de colores de ORM. Existe para la construcción dinámica de consultas — y es precisamente entonces cuando la mayoría de la gente la usa mal.

Knex

js
// ✅ Seguro
const users = await knex("users").where({ email, tenant_id: tenantId });

// ✅ Seguro — los marcadores ? se enlazan
const rows = await knex.raw("SELECT * FROM users WHERE email = ?", [email]);

// ❌ Inseguro — concatenación
const rows = await knex.raw(`SELECT * FROM users WHERE email = '${email}'`);

Sequelize

js
// ✅ Seguro
const users = await User.findAll({ where: { email, tenantId } });

// ✅ Seguro — los reemplazos :name están parametrizados
const rows = await sequelize.query(
  "SELECT * FROM users WHERE email = :email",
  { replacements: { email }, type: QueryTypes.SELECT }
);

// ❌ Inseguro — interpolación
const rows = await sequelize.query(`SELECT * FROM users WHERE email = '${email}'`);

La Inyección de Identificadores que Olvidaste: ORDER BY, GROUP BY y Nombres de Columna

Los marcadores de posición funcionan para valores. No funcionan para identificadores — nombres de tabla, nombres de columna, direcciones de ordenación. No puedes escribir ORDER BY ? en la mayoría de las bases de datos (el ORDER BY $1 de PostgreSQL enlaza un valor, no una columna — y ordenará felizmente por una constante). Así que los desarrolladores recurren a la interpolación:

js
// ❌ Inseguro — inyección clásica de orderBy
const users = await knex("posts")
  .orderBy(req.query.sort, req.query.direction);

req.query.direction = "asc); DROP TABLE posts;--" — Knex construye ORDER BY created_at asc); DROP TABLE posts;--, y si el driver permite consultas multi-sentencia, acabas de perder la tabla. Incluso sin multi-sentencias, un atacante a menudo puede extraer datos mediante mensajes de error o inyección ciega basada en tiempo a través de la columna de ordenación.

La solución es una lista blanca, siempre:

js
// ✅ Seguro — los identificadores provienen de TU código, nunca del cliente
const SORT_COLUMNS = new Set(["created_at", "title", "likes", "views"]);
const SORT_DIRECTIONS = new Set(["asc", "desc"]);

const sort = SORT_COLUMNS.has(req.query.sort) ? req.query.sort : "created_at";
const direction = SORT_DIRECTIONS.has(req.query.direction) ? req.query.direction : "desc";

const posts = await knex("posts").orderBy(sort, direction);

Esta regla se extiende a Prisma.raw() de Prisma (usado para ORDER BY dinámico en $queryRaw), nombres de tabla dinámicos en knex.raw(), y cualquier columna de GROUP BY/SELECT construida a partir de entrada. Si es un identificador, debe venir de una lista blanca hardcodeada.

Comodines LIKE: La Inyección Silenciosa

Menos dramático, pero explotable: la entrada del usuario dentro de un patrón LIKE puede inyectar comodines y romper consultas (divulgación de información mediante coincidencia de patrones) o algo peor cuando se combina con un driver vulnerable.

js
// ❌ Más o menos inseguro — % y _ en la entrada del usuario cambian el significado de la consulta
const rows = await pool.query(
  "SELECT * FROM products WHERE name LIKE $1",
  [`%${search}%`]
);

Escapa los comodines antes de enlazar:

js
function escapeLike(input) {
  return input.replace(/[\\%_]/g, (ch) => `\\${ch}`);
}

const rows = await pool.query(
  `SELECT * FROM products WHERE name LIKE $1 ESCAPE '\\'`,
  [`%${escapeLike(search)}%`]
);

Inyección NoSQL: MongoDB No Es Inmune

"La inyección SQL no aplica, usamos MongoDB" es un mito. Las bases de datos NoSQL evalúan objetos de operadores y, en el caso de MongoDB, JavaScript ($where). Si tu API acepta un objeto de filtro del cliente y lo pasa a la consulta, has desplegado una inyección.

Inyección de Operadores

js
// ❌ Vulnerable — req.body ES el filtro
const user = await db.collection("users").findOne({
  email: req.body.email,
  password: req.body.password,
});

Un atacante envía:

json
{"email": {"$ne": null}, "password": {"$ne": null}}

MongoDB evalúa { $ne: null } como "no igual a null" — que coincide con el primer documento con cualquier email y cualquier contraseña. Eso es un bypass de login en una sola petición. El mismo truco funciona en find(), updateOne() y deleteMany(): {"$ne": null} en un filtro de borrado elimina documentos, {"$gt": ""} en un filtro de ID salta las comprobaciones de autorización.

$where — JavaScript Arbitrario

js
// ❌ NUNCA pases entrada del usuario a $where — se ejecuta como JavaScript en el servidor
const docs = await db.collection("users").find({
  $where: `this.email === '${email}'`,
}).toArray();

$where ejecuta JavaScript dentro del proceso de la base de datos. Con un payload clásico de inyección de cadenas (' || '1'=='1), un atacante consigue un bypass de autenticación; en casos extremos, ejecución de código. MongoDB deprecó $where en favor de expresiones de agregación — trata cualquier $where en tu código como un hallazgo.

Denegación de Servicio por Regex

js
// ❌ Regex suministrado por el usuario → retroceso catastrófico (ReDoS) contra tu BD
const results = await db.collection("products").find({
  name: { $regex: req.query.q },
}).toArray();

$regex acepta sintaxis regex completa, incluidos cuantificadores anidados como (a+)+$ que retroceden exponencialmente. Una sola petición puede fijar un núcleo de CPU — y MongoDB no tiene timeout por consulta por defecto.

Patrón Defensivo para MongoDB

  1. Nunca aceptes objetos como filtros. Acepta cadenas, valídalas y conviértelas en el servidor, y construye el filtro explícitamente:
ts
import { z } from "zod";

const searchSchema = z.object({
  email: z.string().email().max(254).optional(),
  name: z.string().min(1).max(100).optional(),
});

// ✅ Seguro — el filtro se construye solo a partir de escalares validados
const parsed = searchSchema.parse(req.body);
const filter: Record<string, unknown> = {};
if (parsed.email) filter.email = parsed.email.toLowerCase();
if (parsed.name) filter.name = { $regex: escapeRegex(parsed.name), $options: "i" };

function escapeRegex(input: string) {
  return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
  1. Usa esquemas de Mongoose con strict: true (por defecto) y valida $/. en las cadenas — o mejor, usa Zod/Valibot como frontera de confianza antes de la capa de base de datos, tanto para stacks SQL como NoSQL.
  2. Establece un maxTimeMS en cada consulta para acotar el ReDoS y las agregaciones descontroladas.

Defensa en Profundidad: Las Capas que te Salvan Cuando una Falla

Incluso con parametrización en todas partes, construye las capas siguientes — porque el próximo desarrollador que toque el código escribirá un $queryRawUnsafe.

1. Cuentas de Base de Datos con Mínimos Privilegios

Tu aplicación debe conectarse con una cuenta que solo pueda hacer lo que la aplicación hace — nunca con el rol de migración/DDL:

sql
-- Rol de migración (solo CI/CD): DDL completo
CREATE ROLE app_migrator WITH LOGIN PASSWORD '...';

-- Rol de aplicación: DML en el esquema de la app, nada más
CREATE ROLE app_user WITH LOGIN PASSWORD '...';
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
-- Sin CREATE, sin DROP, sin ALTER, sin TRUNCATE (salvo que la app lo necesite — normalmente no)

Si la inyección se cuela, el radio de explosión son los datos, no el esquema. DROP TABLE se vuelve imposible porque el rol carece del privilegio — este único control neutraliza los payloads de inyección más famosos.

2. Valida la Entrada en el Borde

La parametrización maneja datos-como-código; la validación maneja datos-como-datos. Rechaza la entrada malformada antes de que llegue a una consulta:

ts
const loginSchema = z.object({
  email: z.string().email().max(254),
  password: z.string().min(8).max(128),
});

app.post("/api/login", (req, res) => {
  const parsed = loginSchema.parse(req.body); // lanza 400 con entrada inválida
  // ...consultar con parsed.email / parsed.password
});

3. Nunca Filtres Errores de Base de Datos

Los drivers devuelven errores verbosos que incluyen la consulta que falló. Una respuesta 500 con el error en bruto convierte tu API en un oráculo de inyección — los atacantes usan los mensajes de error para mapear el esquema:

js
try {
  const rows = await pool.query(query, params);
  res.json(rows);
} catch (err) {
  // Registrar el error completo en el servidor (con id de consulta), no devolver nada útil
  console.error("[db]", err);
  res.status(500).json({ error: "Internal server error" });
}

4. Comprobaciones en CI

Añade un escaneo de código fuente al CI que marque las APIs peligrosas. Una puerta rápida basada en grep es burda pero efectiva:

bash
# scripts/check-injection-apis.sh — fallar el CI con patrones peligrosos
if grep -rnE '\$queryRawUnsafe|knex\.raw\(`|sequelize\.query\(`|\.query\(`' src/; then
  echo "❌ Raw-query string interpolation detected — use parameterized APIs"
  exit 1
fi

Las reglas de Semgrep o CodeQL para pg/mysql2/Prisma son más precisas — el objetivo es detectar las válvulas de escape en el momento del merge, no en el de la auditoría.

Checklist de Prevención de Inyección

  • [ ] Sin concatenación de cadenas en SQL — cada valor dinámico pasa por marcadores $1/?/:name
  • [ ] mysql2: usa execute() (sentencias preparadas en el servidor), no query() con escaping
  • [ ] Prisma: solo tagged template $queryRaw; $queryRawUnsafe prohibido (regla de CI con grep)
  • [ ] Knex: sintaxis de objeto .where() o knex.raw() con marcadores ?; sin template literals en raw()
  • [ ] Sequelize: replacements para valores dinámicos; sin interpolación en sequelize.query()
  • [ ] ORDER BY/GROUP BY/nombres de columna dinámicos provienen de una lista blanca hardcodeada
  • [ ] Los patrones LIKE escapan %, _ y \
  • [ ] MongoDB: sin objetos de filtro suministrados por el cliente; escalares validados con Zod antes de construir filtros
  • [ ] Sin $where con cadenas interpoladas; entrada de $regex escapada; maxTimeMS establecido en las consultas
  • [ ] El rol de BD de la app tiene solo DML — sin privilegios de DDL; rol de migración separado
  • [ ] Validación con Zod/Valibot en la frontera de API para todos los campos relevantes para consultas
  • [ ] Errores de base de datos registrados en el servidor, mensajes genéricos devueltos a los clientes
  • [ ] El CI escanea patrones de interpolación en bruto ($queryRawUnsafe, knex.raw() concatenado, etc.)

Resumen

  1. La parametrización es la solución; el escaping no lo es. Las sentencias preparadas hacen que los datos sean inertes. La API del query builder de cada ORM es segura — el riesgo se concentra en las válvulas de escape de consultas en bruto.

  2. Trata las válvulas de escape de los ORMs como puntos calientes de revisión de código. $queryRawUnsafe, knex.raw() con template literals y sequelize.query() con interpolación son los tres hallazgos reales más comunes en auditorías de Node.js — escribe reglas de CI que los marquen.

  3. Los identificadores necesitan listas blancas, no marcadores de posición. ORDER BY, GROUP BY y los nombres de columna no pueden parametrizarse, así que deben restringirse a conjuntos hardcodeados.

  4. NoSQL inyecta operadores, no SQL. Los objetos de filtro de MongoDB provenientes del cliente ($ne, $gt, $where, $regex) son el equivalente NoSQL del bypass de login — valida escalares y construye filtros explícitamente.

  5. Apila capas. Roles de BD de mínimos privilegios, validación en el borde e higiene de errores contienen el daño cuando una vulnerabilidad se cuela de todos modos. La defensa contra la inyección es una pila, no una sola consulta.

La próxima semana: Condiciones de Carrera y Fallos de Lógica de Negocio en Node.js — por qué el código "correcto" con patrones de comprobar-y-actuar pierde dinero y datos, y cómo hacer tus endpoints atómicos.

Comparte este artículo:TwitterLinkedIn
JS

JS Security Audit

Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.