Ir para o conteúdo
CodeAuditAgent
Todos os artigos

Prevenindo SQL injection em Node.js, Python e Go

Trechos SQL vulneráveis e corrigidos em pg, Prisma, psycopg, SQLAlchemy e database/sql do Go, mais ORDER BY e listas IN seguros. Mapeado para CWE-89.

· 7 min de leitura · Lina Source LLC

SQL injection (CWE-89) é uma das falhas mais antigas da web e ainda uma das mais destrutivas. A causa nunca mudou: a entrada do usuário é colada no texto de uma consulta, então o banco não consegue distinguir dado de código. A correção também não mudou: envie a consulta e os valores separadamente, e deixe o driver fazer a vinculação.

O que mudou foi onde a falha mora. A maioria dos times usa um ORM para as consultas do dia a dia, então a injeção hoje aparece nas saídas de emergência: a consulta crua escrita para um relatório, o endpoint de busca com ordenação dinâmica, o script de migração, a ferramenta administrativa. Este guia percorre a versão vulnerável e a corrigida em três ecossistemas e depois cobre os dois casos que parâmetros sozinhos não resolvem.

O impacto raramente se limita a uma tabela. Uma única consulta injetável normalmente roda com todos os privilégios de banco da aplicação, então pode ler os dados de todos os clientes, incluindo hashes de senha e tokens de API, alterar linhas e, em alguns bancos, alcançar o sistema de arquivos ou outros servidores. Técnicas cegas extraem dados um bit por vez mesmo quando o resultado da consulta nunca é exibido ao usuário, então um endpoint que só devolve verdadeiro ou falso continua explorável.

Por que parâmetros funcionam e escapar não

Com uma consulta parametrizada, o driver envia o texto SQL com placeholders, e os valores viajam como dados separados. O banco analisa a instrução antes mesmo de ver o valor, então uma aspa ou um ponto e vírgula na entrada é apenas um caractere dentro de uma string. Escapar manualmente tenta tornar um valor seguro para ser colado no texto SQL, e falha em codificações, contextos numéricos, identificadores e todo caso de borda que você não imaginou. Não escape; vincule.

Node.js: pg e Prisma

Com o node-postgres, o padrão vulnerável é um template literal passado para query. A correção é um placeholder $1 e um array de valores. O Prisma é seguro por padrão, mas tem duas APIs cruas, e só uma delas é segura com interpolação.

import { Pool } from "pg";
import { PrismaClient } from "@prisma/client";

const pool = new Pool();
const prisma = new PrismaClient();

// Vulnerável: a entrada vira parte do texto SQL
await pool.query(`SELECT id, email FROM users WHERE email = '${email}'`);

// Corrigido: o valor é vinculado como parâmetro
await pool.query("SELECT id, email FROM users WHERE email = $1", [email]);

// Prisma: $queryRaw é um tagged template, interpolações viram parâmetros
await prisma.$queryRaw`SELECT id, email FROM users WHERE email = ${email}`;

// Prisma: $queryRawUnsafe com concatenação é injetável
await prisma.$queryRawUnsafe(
  "SELECT id, email FROM users WHERE email = '" + email + "'"
);

// Se precisar mesmo usar $queryRawUnsafe, passe os valores como argumentos
await prisma.$queryRawUnsafe(
  "SELECT id, email FROM users WHERE email = $1",
  email
);

Uma armadilha sutil do Prisma: montar a string da consulta antes e passá-la para $queryRaw perde a proteção. A segurança vem da sintaxe de tagged template. Se precisar compor fragmentos, use Prisma.sql e Prisma.join, que mantêm os valores como parâmetros. A mesma regra vale para outras bibliotecas de tagged template, como postgres.js e slonik: é a tag que torna a interpolação segura, então uma string de consulta montada previamente e passada como valor comum contorna a proteção.

Python: psycopg e SQLAlchemy

Em Python, as ferramentas perigosas são as f-strings, o operador % e o str.format aplicados a SQL. O psycopg usa placeholders %s, que parecem formatação de string mas não são: os valores vão no segundo argumento de execute, nunca pelo operador %. O construto text() do SQLAlchemy é seguro quando você usa bind parameters nomeados e inseguro quando você formata valores dentro da string.

Um detalhe do psycopg pega muita gente: o placeholder é %s (ou %(nome)s para valores nomeados) independentemente do tipo da coluna, e você nunca coloca aspas ao redor dele. Escrever '%s' com aspas devolve o parâmetro para dentro de um literal de string e quebra a consulta. O mesmo vale para parâmetros nomeados no SQLAlchemy: :status, não ':status'.

from sqlalchemy import text

# psycopg: vulnerável
cur.execute(f"SELECT id, email FROM users WHERE email = '{email}'")

# psycopg: corrigido, valores passados separadamente
cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))

# SQLAlchemy text(): vulnerável
conn.execute(text(f"SELECT id FROM orders WHERE status = '{status}'"))

# SQLAlchemy text(): corrigido com um bind parameter
conn.execute(
    text("SELECT id FROM orders WHERE status = :status"),
    {"status": status},
)

Go: database/sql

O database/sql do Go suporta placeholders nativamente, mas a sintaxe do placeholder depende do driver: $1 para drivers PostgreSQL como o pgx, ? para MySQL e SQLite. O padrão vulnerável é usar fmt.Sprintf ou concatenação de strings para montar a cláusula WHERE. Como Go é tipado estaticamente, é tentador supor que um parâmetro int não pode ser perigoso. Isso vale para o valor em si, mas o hábito de montar consultas com Sprintf se espalha para os parâmetros string ao lado, então mantenha toda consulta em placeholders.

// Vulnerável: concatenação de strings
q := "SELECT id, email FROM users WHERE email = '" + email + "'"
rows, err := db.QueryContext(ctx, q)

// Corrigido: placeholder mais argumento (sintaxe 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) {
    // não encontrado
}

As saídas de emergência dos ORMs

ORMs protegem o query builder, não todo método do ORM. Estes são os lugares para olhar primeiro em qualquer base de código:

  • Prisma: $queryRawUnsafe e $executeRawUnsafe, e qualquer chamada $queryRaw que receba uma string pronta em vez de um tagged template.
  • Sequelize e TypeORM: sequelize.query, chamadas .where() do query builder com uma string concatenada, e cláusulas order ou group cruas.
  • Knex: knex.raw e whereRaw com valores interpolados em vez de bindings com ?.
  • SQLAlchemy: text() com f-strings, e literal_column() ou nomes de coluna vindos da entrada.
  • Django: .raw(), .extra() e cursor.execute com strings formatadas.
  • GORM: Where, Order e Raw chamados com a saída de fmt.Sprintf em vez de argumentos.

ORDER BY dinâmico: parâmetros não ajudam

Placeholders vinculam valores, não identificadores. Você não pode escrever ORDER BY $1 e passar um nome de coluna; o banco ordenaria por uma constante. Então uma tabela ordenável tende a empurrar quem desenvolve de volta para a montagem de strings, e é aí que a injeção volta. A mesma limitação vale para nomes de tabela, nomes de schema e palavras-chave SQL como ASC e DESC.

O padrão seguro é uma allowlist que mapeia as chaves de ordenação visíveis ao usuário para nomes de coluna conhecidos. A entrada seleciona uma entrada da lista; ela nunca vira SQL. A direção recebe o mesmo tratamento: mapeie para um ASC ou DESC fixo.

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 e dir só podem ser valores do código acima
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)

Se você realmente precisa de um identificador dinâmico que não dá para colocar em allowlist, use o mecanismo de citação de identificadores do driver em vez do seu próprio. No psycopg é sql.SQL(...).format(sql.Identifier(name)); no pgx é pgx.Identifier{name}.Sanitize(). Uma allowlist ainda é melhor, porque a citação torna o nome seguro mas não necessariamente válido ou permitido. Um identificador citado ainda permite a quem chama ordenar ou filtrar por qualquer coluna da tabela, inclusive as que você nunca quis expor, como um hash de senha ou um score interno. Ordenar por uma coluna oculta pode vazar seus valores pela ordem dos resultados, uma comparação de cada vez.

Listas IN sem montar strings

Filtrar por uma lista de IDs é a outra desculpa comum para a concatenação. Toda stack relevante tem uma forma segura de fazer isso:

  • PostgreSQL com pg ou pgx: passe a lista inteira como um único parâmetro array e escreva WHERE id = ANY($1). O driver a envia como um array do Postgres.
  • Prisma: Prisma.sql`SELECT * FROM users WHERE id IN (${Prisma.join(ids)})` se expande em um placeholder por elemento.
  • SQLAlchemy: text("... WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)) e então passe uma lista.
  • Go com MySQL: gere a string de placeholders a partir do tamanho da lista (strings.Repeat("?,", n) com o corte final) e passe os valores como argumentos. Só os placeholders são gerados, nunca os valores.
  • Rejeite listas vazias antes de a consulta rodar, e limite o tamanho da lista para que o endpoint não possa ser usado para montar instruções gigantescas.

Defesa em profundidade

A parametrização é a correção. Estas medidas reduzem o raio do estrago quando uma consulta escapa:

  • Conecte com um papel de banco que tenha apenas os privilégios de que a aplicação precisa. A aplicação web raramente precisa de DROP, ALTER ou acesso a outros schemas.
  • Não devolva erros crus do banco para os clientes. A injeção baseada em erro depende de vê-los.
  • Valide tipos na fronteira: um ID que deveria ser inteiro ou UUID deve ser convertido como tal antes de chegar à camada de dados.
  • Defina um statement timeout, que limita tanto a injeção cega baseada em tempo quanto consultas descontroladas.

Encontrando as que você já tem

Comece procurando pelas saídas de emergência listadas acima e depois por palavras-chave SQL perto de formatação de strings: SELECT ou WHERE dentro de template literals, f-strings, Sprintf ou concatenação com +. Cada ocorrência está corretamente vinculada, construída a partir de uma allowlist, ou é uma falha. Não pule caminhos de código que parecem internos: importadores de CSV, jobs de cron e painéis administrativos frequentemente recebem entradas que originalmente vieram de um usuário, só que um passo antes. Valores lidos de volta do seu próprio banco podem carregar um payload que foi armazenado com segurança antes e colado numa consulta depois, o que se conhece como injeção de segunda ordem. O CodeAuditAgent faz esse rastreamento entre arquivos para repositórios públicos do GitHub e código colado, e reporta achados CWE-89 com a consulta citada, como a entrada chega até ela e uma versão corrigida.

A regra para levar daqui é curta: valores vão em parâmetros, identificadores vêm de uma allowlist, e nada vindo da requisição jamais é colado no texto SQL.