FlawOpen

flawopen.com/sql-injection/Javascript

● CWE-89 · Crítica
Investigación · FlawOpen

Inyección SQL en JavaScript (mysql2, pg, Prisma, Knex)

Un template literal o + dentro de pool.query() permite que un parámetro de la petición reescriba tu SQL y devuelva todas las filas (CWE-89). La llamada segura en mysql2, node-postgres, Prisma y Knex, por qué ${} es seguro en un método de Prisma y peligroso en el siguiente, y cómo ordenar sin orderByRaw.[1], [2], [3], [4], [5], [6]

💡 Explicación en Lenguaje Sencillo (ELI5)

Imagina un centro de paquetería: la ruta se imprime y queda cerrada antes de que llegue ningún paquete. El nombre de la etiqueta ocupa un campo propio; no puede añadir una parada a la ruta. Los parámetros SQL funcionan igual: mysql2 execute(sql, [userId]) y pg query(sql, [status]) vinculan valores, y el $queryRaw etiquetado de Prisma vincula cada hueco ${}. Si primero montas el SQL con un template literal o +, o usas $queryRawUnsafe, el texto del cliente acaba escrito en la ruta. Knex orderByRaw presenta el mismo riesgo con la ordenación; asigna la clave solicitada a una columna fija y llama a orderBy.[1], [2], [3], [4], [5], [6]

Conceptos Clave y Términos

Template literals in query()
Una cadena entre comillas invertidas con ${userId} la evalúa JavaScript antes de llamar al driver, así que el driver recibe un SQL terminado con el valor ya dentro. La sintaxis parece un marcador, pero no enlaza nada.[7], [1], [6]
mysql2 execute() vs query()
execute(sql, values) usa una sentencia preparada en el servidor. query(sql, values) escapa los valores en el cliente y los sustituye en los marcadores ?. Ambos son seguros si los valores van en el array; ninguno sirve si la cadena SQL se construyó con + o un template literal. mysql2 desactiva multipleStatements por defecto.[3], [6], [9]
pg query(text, values)
node-postgres envía values por separado de text, así que $1 lo rellena PostgreSQL. Sin valores, pg usa el protocolo de consulta simple, que además acepta varias sentencias separadas por punto y coma en una llamada.[2], [6]
Prisma $queryRaw vs $queryRawUnsafe
$queryRaw es un tagged template: Prisma convierte cada ${} en un parámetro enlazado. $queryRawUnsafe(string) recibe una cadena ya hecha, así que el mismo ${} dentro de un template literal normal se pega en el SQL. Los nombres difieren en una palabra y el riesgo es el contrario.[4], [6]
Knex whereRaw / orderBy
whereRaw('email = ?', [email]) enlaza valores, pero whereRaw y orderByRaw con cadenas interpoladas son SQL en bruto. orderBy(column) entrecomilla la columna como identificador; pásale solo nombres de una lista permitida.[5], [6]

Mapeos de marcos

Flujo de Ataque Paso a Paso

Paso 1

El valor de la petición llega a la capa de datos

Una ruta de Express lee req.query.id y lo pasa a getUser(). Los parámetros de query son cadenas o arrays, nunca números, y nada los valida.[8], [6]

Paso 2

Un template literal construye el SQL

` ... WHERE id = ${userId} se evalúa primero. Con el valor de prueba inofensivo 0 OR 1=1, la cadena queda WHERE id = 0 OR 1=1` antes de que mysql2 la vea.[7], [1], [6]

Paso 3

El driver no recibe valores

pool.query(sql) tiene un array de valores vacío, así que no hay nada que escapar ni enlazar. MySQL analiza OR 1=1 como parte de la cláusula WHERE.[3], [2], [9], [6]

Paso 4

La ruta devuelve la tabla

La condición es verdadera para todas las filas. La ruta que debía devolver un usuario los devuelve todos, y lo mismo ocurre con $queryRawUnsafe o un orderByRaw alimentado desde la query string.[1], [6]

Código Fuente: Vulnerable vs. Seguro

IMPLEMENTACIÓN VULNERABLE
// users.js: cada consulta de abajo construye texto SQL con datos de la petición

// mysql2: el template literal se evalúa antes de llamar al driver
export async function getUser(pool, userId) {
  const [rows] = await pool.query(`SELECT id, email FROM users WHERE id = ${userId}`);
  return rows[0];
}

// pg: concatenación, y ningún array de valores que enlazar
export async function findOrders(pg, status) {
  const { rows } = await pg.query("SELECT id FROM orders WHERE status = '" + status + "'");
  return rows;
}

// Prisma: la variante Unsafe recibe una cadena ya hecha
export function findByEmail(prisma, email) {
  return prisma.$queryRawUnsafe(`SELECT id FROM "User" WHERE email = '${email}'`);
}

// Knex: orderByRaw inserta el valor de la petición como SQL en bruto
export function listUsers(knex, sortColumn) {
  return knex('users').select('id', 'email').orderByRaw(sortColumn);
}
PARCHE SEGURO Y ROBUSTO
// users.js: los valores van en el array de valores, las columnas salen de una lista permitida
const SORTABLE = new Map([['id', 'id'], ['email', 'email'], ['created', 'created_at']]);

// mysql2: sentencia preparada en el servidor, valor enlazado al ?
export async function getUser(pool, userId) {
  const [rows] = await pool.execute('SELECT id, email FROM users WHERE id = ?', [userId]);
  return rows[0];
}

// pg: PostgreSQL rellena $1 con el array de valores
export async function findOrders(pg, status) {
  const { rows } = await pg.query('SELECT id FROM orders WHERE status = $1', [status]);
  return rows;
}

// Prisma: tagged template, cada ${} se convierte en un parámetro enlazado
export function findByEmail(prisma, email) {
  return prisma.$queryRaw`SELECT id FROM "User" WHERE email = ${email}`;
}

// Knex: orderBy entrecomilla la columna permitida como identificador
export function listUsers(knex, sortKey) {
  const column = SORTABLE.get(sortKey) ?? 'id';
  return knex('users').select('id', 'email').orderBy(column);
}

Lista de Verificación de Seguridad para Ingeniería

Fuentes