SQL Segurança JDBC
Acesso a Dados

Queries Parametrizadas & SQL Injection

SQL Injection é a vulnerabilidade mais explorada em aplicações web há mais de duas décadas — e continua no topo do OWASP Top 10. O problema não é o SQL em si: é construir SQL por concatenação de strings, misturando código e dados no mesmo canal. A solução é igualmente simples: PreparedStatement — separa o código SQL dos dados ao nível do protocolo de rede, tornando a injecção estruturalmente impossível. Este artigo explica o mecanismo do ataque, por que o PreparedStatement o resolve de forma definitiva, e os padrões correctos para os casos onde a parametrização directa não é suficiente.

Como SQL Injection Funciona

-- ── O código vulnerável ──────────────────────────────────────────────────────
// Input do utilizador via formulário de login:
String email = request.getParameter("email");
String pass  = request.getParameter("password");

// Construção da query por concatenação — NUNCA fazer isto:
String sql = "SELECT id, nome FROM utilizador " +
             "WHERE email = '" + email + "' " +
             "AND password_hash = '" + pass + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
if (rs.next()) {
    // login bem-sucedido
}


-- ── Ataque 1: Bypass de autenticação ─────────────────────────────────────────
-- Input malicioso:
-- email: admin@example.com' --
-- pass:  qualquer_coisa

-- Query resultante após concatenação:
SELECT id, nome FROM utilizador
WHERE email = 'admin@example.com' --' AND password_hash = 'qualquer_coisa'
--                                  ↑↑
--                                  -- é um comentário SQL — tudo depois é ignorado

-- Query efectivamente executada:
SELECT id, nome FROM utilizador
WHERE email = 'admin@example.com'
-- A condição da password foi removida — login sem saber a password!


-- ── Ataque 2: Extracção de dados com UNION ────────────────────────────────────
-- email: ' UNION SELECT id, password_hash FROM utilizador --
-- pass:  x

-- Query resultante:
SELECT id, nome FROM utilizador
WHERE email = '' UNION SELECT id, password_hash FROM utilizador --'
AND password_hash = 'x'

-- Resultado: a aplicação recebe todos os ids e hashes de passwords da tabela!


-- ── Ataque 3: Destruição de dados (DROP TABLE) ────────────────────────────────
-- email: '; DROP TABLE utilizador; --

-- Query resultante (se o driver executar múltiplos statements):
SELECT id, nome FROM utilizador WHERE email = '';
DROP TABLE utilizador;
-- A tabela utilizador é eliminada.
-- (PostgreSQL JDBC não executa múltiplos statements por default — mas outros
--  drivers ou interfaces podem)


-- ── Ataque 4: Blind SQL Injection (sem mensagem de erro visível) ──────────────
-- O atacante não vê o resultado da query, mas infere dados por comportamento:
-- email: admin@example.com' AND SUBSTRING(password_hash,1,1)='a' --
-- Se login bem-sucedido → primeiro char do hash é 'a'
-- Se falhou → não é 'a' → tentar 'b', 'c', ...
-- Automatizado com ferramentas como sqlmap: extrai a BD inteira em minutos

Por Que PreparedStatement Resolve o Problema

-- A solução não é "escapar" strings — é separar código de dados ao nível do protocolo.
--
-- Com Statement (concatenação):
-- ┌─────────────────────────────────────────────────────────────┐
-- │ Rede → PostgreSQL: "SELECT ... WHERE email = 'x' OR '1'='1'"│
-- │ PostgreSQL vê uma string de texto e faz parse              │
-- │ Não distingue o que é código do que é dados                │
-- └─────────────────────────────────────────────────────────────┘
--
-- Com PreparedStatement (Extended Query Protocol do PostgreSQL):
-- ┌─────────────────────────────────────────────────────────────┐
-- │ Mensagem 1 — Parse:                                         │
-- │   query: "SELECT ... WHERE email = $1 AND password = $2"   │
-- │   O PostgreSQL faz parse e compila o plano de execução      │
-- │                                                             │
-- │ Mensagem 2 — Bind:                                          │
-- │   param $1: "admin@example.com' OR '1'='1"  (valor literal)│
-- │   param $2: "qualquer_coisa"                               │
-- │   Os parâmetros são enviados como dados binários separados  │
-- │                                                             │
-- │ Mensagem 3 — Execute                                        │
-- └─────────────────────────────────────────────────────────────┘
--
-- O PostgreSQL já compilou o plano antes de receber os parâmetros.
-- Os parâmetros são SEMPRE tratados como dados — NUNCA como código SQL.
-- ' OR '1'='1 no parâmetro $1 é simplesmente uma string com aspas e espaços.
-- Não há parse adicional. Injecção é estruturalmente impossível.


-- ── O código correcto ─────────────────────────────────────────────────────────
String sql = "SELECT id, nome FROM utilizador WHERE email = ? AND password_hash = ?";

try (
    Connection conn = dataSource.getConnection();
    PreparedStatement ps = conn.prepareStatement(sql)
) {
    ps.setString(1, email);         // email é sempre um valor, nunca código SQL
    ps.setString(2, passwordHash);  // idem para a password

    try (ResultSet rs = ps.executeQuery()) {
        if (rs.next()) {
            // login bem-sucedido
            long userId = rs.getLong("id");
            String nome = rs.getString("nome");
        }
    }
}
// Qualquer valor em email ou passwordHash — incluindo ' OR '1'='1 —
// é tratado como string literal pelo PostgreSQL.
// Injecção é impossível independentemente do conteúdo dos parâmetros.

Tipos de Parâmetros em PreparedStatement

// ── Mapeamento Java → SQL ────────────────────────────────────────────────────
PreparedStatement ps = conn.prepareStatement(
    "INSERT INTO produto (nome, preco, stock, activo, criado_em, atributos) " +
    "VALUES (?, ?, ?, ?, ?, ?)"
);

ps.setString   (1, "Teclado Mecânico");          // VARCHAR, TEXT, CHAR
ps.setBigDecimal(2, new BigDecimal("89.99"));    // NUMERIC — usar BigDecimal para dinheiro
ps.setInt      (3, 50);                          // INTEGER, INT4
ps.setBoolean  (4, true);                        // BOOLEAN
ps.setTimestamp(5, Timestamp.from(Instant.now())); // TIMESTAMPTZ
ps.setObject   (6, "{\"cor\":\"preto\"}", Types.OTHER); // JSONB, tipos custom do PostgreSQL

// Valores NULL:
ps.setNull(3, Types.INTEGER);                    // NULL explícito
// ou:
ps.setObject(3, null);                           // null Java → NULL SQL


// ── Arrays PostgreSQL ────────────────────────────────────────────────────────
Array tags = conn.createArrayOf("text", new String[]{"electrónica", "periférico"});
ps.setArray(7, tags);
// Equivalente a: INSERT ... tags = '{"electrónica","periférico"}'


// ── Batch inserts — múltiplos registos com um só PreparedStatement ────────────
String sql = "INSERT INTO linha_encomenda (encomenda_id, produto_id, quantidade, preco_unit) " +
             "VALUES (?, ?, ?, ?)";

try (
    Connection conn = dataSource.getConnection();
    PreparedStatement ps = conn.prepareStatement(sql)
) {
    conn.setAutoCommit(false);

    for (LinhaEncomenda linha : linhas) {
        ps.setLong      (1, linha.getEncomendaId());
        ps.setLong      (2, linha.getProdutoId());
        ps.setInt       (3, linha.getQuantidade());
        ps.setBigDecimal(4, linha.getPrecoUnit());
        ps.addBatch();                    // acumula no buffer — não executa ainda

        if (linhas.indexOf(linha) % 500 == 0) {
            ps.executeBatch();            // envia 500 linhas de uma vez
            conn.commit();
        }
    }
    ps.executeBatch();                    // enviar o último batch (resto < 500)
    conn.commit();
}
// Sem batching: 1000 linhas = 1000 round-trips à base de dados
// Com batching (500/batch): 1000 linhas = 2 round-trips
// Com reWriteBatchedInserts=true (driver PG): agrupa em INSERT ... VALUES (...),(...)
// → ainda mais rápido (1 mensagem de rede em vez de 500)

Casos Especiais: Nomes de Tabelas e Colunas Dinâmicos

-- PreparedStatement parametriza VALORES — não nomes de tabelas, colunas ou operadores.
-- Para queries com estrutura dinâmica, é necessária outra abordagem.

-- ── Problema: ordenação dinâmica (ORDER BY coluna escolhida pelo utilizador) ──

// ❌ Vulnerável — ORDER BY não pode ser parametrizado:
String col = request.getParameter("sort");  // input: "nome; DROP TABLE produto; --"
String sql = "SELECT * FROM produto ORDER BY " + col;
// Injecção possível!

// ✅ Solução: whitelist de colunas permitidas
private static final Set COLUNAS_ORDEM = Set.of("nome", "preco_base", "criado_em");
private static final Set DIRECOES = Set.of("ASC", "DESC");

String col = request.getParameter("sort");
String dir = request.getParameter("dir");

if (!COLUNAS_ORDEM.contains(col)) col = "nome";        // fallback seguro
if (!DIRECOES.contains(dir.toUpperCase())) dir = "ASC";

// Só valores da whitelist chegam à query:
String sql = "SELECT * FROM produto ORDER BY " + col + " " + dir;
// Mesmo que col/dir venham do utilizador, só valores permitidos são aceites.
// A whitelist é o mecanismo de segurança — não a parametrização.


-- ── Problema: cláusula IN com lista dinâmica de valores ──────────────────────

// ❌ Tentação errada: construir "IN (?,?,?)" dinamicamente com concatenação
// (isto é parametrizado mas trabalhoso)

// ✅ Solução A: construir os placeholders programaticamente
List ids = List.of(1, 5, 10, 42);
String placeholders = ids.stream()
    .map(i -> "?")
    .collect(Collectors.joining(", "));  // "?, ?, ?, ?"

String sql = "SELECT * FROM produto WHERE id IN (" + placeholders + ")";
PreparedStatement ps = conn.prepareStatement(sql);
for (int i = 0; i < ids.size(); i++) {
    ps.setInt(i + 1, ids.get(i));        // todos os valores parametrizados
}
// Os valores são sempre parâmetros — só a estrutura (número de ?) é dinâmica.

// ✅ Solução B (PostgreSQL): usar array e ANY
PreparedStatement ps = conn.prepareStatement(
    "SELECT * FROM produto WHERE id = ANY(?)"
);
Array arr = conn.createArrayOf("integer", ids.toArray());
ps.setArray(1, arr);
// Mais limpo — um único parâmetro independentemente do número de IDs.


-- ── Problema: nome de schema ou tabela dinâmico (multi-tenant) ───────────────

// ❌ Nunca construir nomes de schema com input do utilizador directamente
String tenant = request.getHeader("X-Tenant");
String sql = "SELECT * FROM " + tenant + ".produto";  // injecção possível!

// ✅ Solução: lookup do schema num mapa de tenants validado
private static final Map TENANT_SCHEMAS = Map.of(
    "cliente-a", "tenant_cliente_a",
    "cliente-b", "tenant_cliente_b"
);

String schema = TENANT_SCHEMAS.get(request.getHeader("X-Tenant"));
if (schema == null) throw new UnauthorizedException("Tenant desconhecido");

// schema é um valor do nosso mapa interno — nunca input directo do utilizador
String sql = "SELECT * FROM " + schema + ".produto";
// Ou usar SET search_path via PreparedStatement antes da query:
try (PreparedStatement setSchema = conn.prepareStatement("SET search_path = " + schema)) {
    setSchema.execute();
}
// Agora as queries sem schema explícito usam o schema do tenant

jOOQ e ORMs — SQL Injection de Segunda Ordem

-- Frameworks de alto nível (Hibernate, jOOQ, Spring Data) são seguros por default
-- — usam PreparedStatement internamente para todos os parâmetros.
-- O risco volta quando se usa SQL nativo com interpolação de strings.

// ── Hibernate / Spring Data JPA ───────────────────────────────────────────────

// ✅ Seguro — JPQL com parâmetros nomeados:
TypedQuery q = em.createQuery(
    "SELECT p FROM Produto p WHERE p.categoriaId = :catId AND p.activo = true",
    Produto.class
);
q.setParameter("catId", categoriaId);

// ✅ Seguro — Spring Data repository (gera PreparedStatement automaticamente):
List findByCategoriaIdAndActivoTrue(Integer categoriaId);

// ❌ Vulnerável — native query com concatenação:
em.createNativeQuery(
    "SELECT * FROM produto WHERE nome LIKE '%" + nomeInput + "%'"
);
// nomeInput pode conter SQL injection!

// ✅ Seguro — native query com parâmetros posicionais:
em.createNativeQuery("SELECT * FROM produto WHERE nome LIKE ?1")
  .setParameter(1, "%" + nomeInput + "%");
// nomeInput é sempre dados — os % são parte do valor, não do SQL


// ── jOOQ — type-safe SQL ──────────────────────────────────────────────────────

// ✅ jOOQ é seguro por design — tudo é parametrizado:
dsl.selectFrom(PRODUTO)
   .where(PRODUTO.NOME.containsIgnoreCase(nomeInput))  // gera ILIKE ? com parâmetro
   .and(PRODUTO.ACTIVO.isTrue())
   .fetch();

// ⚠️ Excepção: DSL.field() e DSL.table() com strings — não usar com input do utilizador:
dsl.select(DSL.field(nomeColuna))  // vulnerável se nomeColuna vem do utilizador
// ✅ Usar a whitelist pattern (como no ORDER BY dinâmico acima)


-- ── SQL Injection de segunda ordem ────────────────────────────────────────────
-- Dados guardados na BD que são lidos e usados noutras queries sem parametrização.
-- Exemplo:
-- 1. Utilizador regista username: admin'--
-- 2. Username é guardado correctamente (parametrizado)
-- 3. Mais tarde, outra parte da aplicação faz:
--    "UPDATE utilizador SET ultimo_login = NOW() WHERE nome = '" + username + "'"
--    (obtendo o username da BD sem re-parametrizar)
-- 4. A query resultante: UPDATE utilizador SET ultimo_login = NOW() WHERE nome = 'admin'--'
-- Solução: parametrizar SEMPRE — mesmo dados que vêm da própria base de dados.

Defesa em Profundidade

-- PreparedStatement é a defesa principal — mas não a única camada.
-- Defesa em profundidade: múltiplas camadas independentes.

-- ── 1. Princípio do mínimo privilégio na base de dados ────────────────────────
-- O utilizador da aplicação não deve ter permissões que não precisa:

-- Criar utilizador de aplicação com apenas o necessário:
CREATE ROLE app_user LOGIN PASSWORD 'password';
GRANT CONNECT ON DATABASE loja TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;

-- Apenas SELECT, INSERT, UPDATE, DELETE nas tabelas necessárias:
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
    utilizador, produto, encomenda, linha_encomenda TO app_user;

-- Sem permissões para:
-- DROP TABLE, CREATE TABLE, ALTER TABLE → não pode destruir schema
-- TRUNCATE → não pode apagar tudo de uma tabela
-- pg_read_server_files, pg_write_server_files → não pode ler ficheiros do SO
-- SUPERUSER → nunca para a conta da aplicação

-- Se a aplicação for comprometida com SQL injection:
-- O atacante opera com as permissões limitadas do app_user
-- Não consegue fazer DROP TABLE, ler outros schemas, ou aceder ao SO


-- ── 2. Validação de input na camada de aplicação ──────────────────────────────
// Validar ANTES de usar — mesmo com PreparedStatement:
public void criarUtilizador(String email, String nome) {
    if (email == null || !email.matches("^[^@]+@[^@]+\\.[^@]+$")) {
        throw new ValidationException("Email inválido");
    }
    if (nome == null || nome.length() < 2 || nome.length() > 100) {
        throw new ValidationException("Nome deve ter entre 2 e 100 caracteres");
    }
    // ... PreparedStatement seguro ...
}
// Validação não previne SQL injection (PreparedStatement já faz isso)
// mas previne dados inválidos e dá feedback útil ao utilizador


-- ── 3. WAF (Web Application Firewall) — última linha de defesa ───────────────
-- Um WAF detecta e bloqueia payloads de SQL injection no tráfego HTTP.
-- Não é substituto para código correcto — é uma rede de segurança adicional.
-- Ferramentas: ModSecurity, AWS WAF, Cloudflare WAF.


-- ── 4. Logging e detecção de anomalias ────────────────────────────────────────
-- Activar log de queries lentas / erros no PostgreSQL:
-- postgresql.conf:
-- log_min_duration_statement = 1000   -- log queries > 1s
-- log_error_verbosity = default       -- log erros com detalhe
-- log_connections = on                -- log novas conexões
--
-- Monitorizar erros SQL na aplicação:
-- Múltiplos erros de sintaxe SQL de um mesmo IP podem indicar tentativa de injecção.

Checklist SQL Injection & Queries Parametrizadas