FlawOpen

flawopen.com/sql-injection/Javascript

● CWE-89 · Crítica
Pesquisa · FlawOpen

SQL Injection em JavaScript (mysql2, pg, Prisma, Knex)

Um template literal ou + dentro de pool.query() permite que um parâmetro da requisição reescreva o seu SQL e devolva todas as linhas (CWE-89). A chamada segura em mysql2, node-postgres, Prisma e Knex, por que ${} é seguro num método do Prisma e perigoso no seguinte, e como ordenar sem orderByRaw.[1], [2], [3], [4], [5], [6]

💡 Explicação em Linguagem Simples (ELI5)

Imagine um centro de distribuição: a rota é impressa e travada antes de qualquer pacote chegar. O nome na etiqueta ocupa um campo próprio; não pode acrescentar uma parada à rota. Parâmetros SQL funcionam assim: mysql2 execute(sql, [userId]) e pg query(sql, [status]) vinculam valores, enquanto o $queryRaw com tag do Prisma vincula cada espaço ${}. Se você montar o SQL antes com template literal ou +, ou usar $queryRawUnsafe, o texto do cliente é escrito na própria rota. Knex orderByRaw tem o mesmo risco para instruções de ordenação; associe a chave pedida a uma coluna fixa e chame orderBy.[1], [2], [3], [4], [5], [6]

Conceitos Centrais e Termos

Template literals in query()
Uma string entre crases com ${userId} é avaliada pelo JavaScript antes de o driver ser chamado, então o driver recebe um SQL pronto com o valor já dentro. A sintaxe parece um placeholder, mas não vincula nada.[7], [1], [6]
mysql2 execute() vs query()
execute(sql, values) usa um prepared statement no servidor. query(sql, values) escapa os valores no cliente e os substitui nos marcadores ?. Os dois são seguros quando os valores estão no array; nenhum ajuda se a string SQL foi montada com + ou template literal. O mysql2 desativa multipleStatements por padrão.[3], [6], [9]
pg query(text, values)
O node-postgres envia values separado de text, então quem preenche $1 é o PostgreSQL. Sem valores, o pg usa o protocolo de consulta simples, que também aceita vários comandos separados por ponto e vírgula numa chamada.[2], [6]
Prisma $queryRaw vs $queryRawUnsafe
$queryRaw é um tagged template: o Prisma transforma cada ${} em parâmetro vinculado. $queryRawUnsafe(string) recebe uma string pronta, então o mesmo ${} dentro de um template literal comum é colado no SQL. Os nomes diferem por uma palavra e o risco é o oposto.[4], [6]
Knex whereRaw / orderBy
whereRaw('email = ?', [email]) vincula valores, mas whereRaw e orderByRaw com strings interpoladas são SQL cru. orderBy(column) coloca a coluna entre aspas como identificador; passe a ele apenas nomes de uma allowlist.[5], [6]

Mapeamentos de frameworks

Fluxo de Ataque Passo a Paso

Passo 1

O valor da requisição chega à camada de dados

Uma rota Express lê req.query.id e o repassa a getUser(). Parâmetros de query são strings ou arrays, nunca números, e nada os valida.[8], [6]

Passo 2

Um template literal monta o SQL

` ... WHERE id = ${userId} é avaliado primeiro. Com o valor de teste inofensivo 0 OR 1=1, a string vira WHERE id = 0 OR 1=1` antes de o mysql2 vê-la.[7], [1], [6]

Passo 3

O driver não recebe valores

pool.query(sql) tem um array de valores vazio, então não há nada para escapar ou vincular. O MySQL interpreta OR 1=1 como parte da cláusula WHERE.[3], [2], [9], [6]

Passo 4

A rota devolve a tabela

A condição é verdadeira para todas as linhas. A rota que deveria devolver um usuário devolve todos, e o mesmo acontece via $queryRawUnsafe ou um orderByRaw alimentado pela query string.[1], [6]

Código-Fonte: Vulnerável vs. Seguro

IMPLEMENTAÇÃO VULNERÁVEL
// users.js: cada consulta abaixo monta texto SQL com dados da requisição

// mysql2: o template literal é avaliado antes de o driver ser chamado
export async function getUser(pool, userId) {
  const [rows] = await pool.query(`SELECT id, email FROM users WHERE id = ${userId}`);
  return rows[0];
}

// pg: concatenação, e nenhum array de valores para vincular
export async function findOrders(pg, status) {
  const { rows } = await pg.query("SELECT id FROM orders WHERE status = '" + status + "'");
  return rows;
}

// Prisma: a variante Unsafe recebe uma string pronta
export function findByEmail(prisma, email) {
  return prisma.$queryRawUnsafe(`SELECT id FROM "User" WHERE email = '${email}'`);
}

// Knex: orderByRaw insere o valor da requisição como SQL cru
export function listUsers(knex, sortColumn) {
  return knex('users').select('id', 'email').orderByRaw(sortColumn);
}
PATCH SEGURO E ROBUSTO
// users.js: valores vão no array de valores, colunas vêm de uma allowlist
const SORTABLE = new Map([['id', 'id'], ['email', 'email'], ['created', 'created_at']]);

// mysql2: prepared statement no servidor, valor vinculado ao ?
export async function getUser(pool, userId) {
  const [rows] = await pool.execute('SELECT id, email FROM users WHERE id = ?', [userId]);
  return rows[0];
}

// pg: o PostgreSQL preenche $1 a partir do 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 ${} vira um parâmetro vinculado
export function findByEmail(prisma, email) {
  return prisma.$queryRaw`SELECT id FROM "User" WHERE email = ${email}`;
}

// Knex: orderBy põe a coluna da allowlist entre aspas como identificador
export function listUsers(knex, sortKey) {
  const column = SORTABLE.get(sortKey) ?? 'id';
  return knex('users').select('id', 'email').orderBy(column);
}

Lista de Verificação de Segurança para Engenharia

Fontes