SQL Injection e Prepared Statement em Java

1. Visão geral

SQL injection é uma vulnerabilidade de segurança no topo da lista OWASP, originária da concatenação manual de strings SQL com entrada de usuário. Este artigo explica o mecanismo de ataque, mostra por que PreparedStatement o bloqueia e guia você na aplicação com JDBC, JPA/Hibernate e MyBatis. Após ler, você saberá escrever código de consulta seguro e como criar testes para detectar vulnerabilidades cedo.

2. Como o SQL injection funciona

O erro ocorre quando você concatena diretamente o valor de entrada em uma string SQL, em vez de parametrizá-lo.

// NÃO FAÇA ASSIM
public Customer findByUsername(String username) throws SQLException {
    String sql = "SELECT * FROM customers WHERE username = '" + username + "'";
    try (Statement stmt = connection.createStatement();
         ResultSet rs = stmt.executeQuery(sql)) {
        return rs.next() ? mapRow(rs) : null;
    }
}

Se username for um valor válido como "minh", a instrução SQL gerada é:

SELECT * FROM customers WHERE username = 'minh'

Mas se o atacante passar username = "' OR '1'='1", a instrução SQL fica:

SELECT * FROM customers WHERE username = '' OR '1'='1'

A condição '1'='1' é sempre verdadeira, então a query retorna toda a tabela customers — ignorando completamente a lógica de autenticação de username. Pior ainda, o atacante pode acrescentar ; DROP TABLE customers; -- para excluir dados, ou usar UNION SELECT para ler outra tabela (por exemplo, a tabela users contendo hashes de senha).

Nota: A entrada não vem apenas de formulários HTML. Headers HTTP, cookies, query params ou dados de upload de arquivo também são fontes potenciais de ataque.

3. Como o Prepared Statement resolve o problema

3.1. Mecanismo de parametrização

PreparedStatement separa a estrutura SQL (enviada primeiro, analisada pelo BD e compilada em plano de execução) dos valores dos parâmetros (enviados depois, tratados apenas como dados brutos). Dessa forma, a entrada do atacante nunca é interpretada pelo BD como sintaxe SQL.

public Customer findByUsername(String username) throws SQLException {
    String sql = "SELECT * FROM customers WHERE username = ?";
    try (PreparedStatement stmt = connection.prepareStatement(sql)) {
        stmt.setString(1, username);
        try (ResultSet rs = stmt.executeQuery()) {
            return rs.next() ? mapRow(rs) : null;
        }
    }
}
  • O ? é um placeholder; o BD compila a instrução com este placeholder.
  • stmt.setString(1, username) atribui o valor ao parâmetro na posição 1 (numeração começa em 1, não em 0).
  • Mesmo que username contenha ' OR '1'='1, o BD ainda interpreta isto como uma única string de caracteres a ser comparada, não como sintaxe SQL. A query retorna vazia porque nenhum usuário tem esse nome estranho.

3.2. Parâmetros nomeados com Spring JdbcTemplate

NamedParameterJdbcTemplate torna o código mais legível do que usar ? com números ordinais.

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-jdbc</artifactId>
</dependency>
@Repository
public class CustomerRepository {

    private final NamedParameterJdbcTemplate jdbc;

    public CustomerRepository(NamedParameterJdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public Optional<Customer> findByUsername(String username) {
        String sql = "SELECT id, username, email FROM customers WHERE username = :username";
        Map<String, Object> params = Map.of("username", username);
        List<Customer> result = jdbc.query(sql, params, this::mapRow);
        return result.stream().findFirst();
    }

    private Customer mapRow(ResultSet rs, int rowNum) throws SQLException {
        return new Customer(rs.getLong("id"), rs.getString("username"), rs.getString("email"));
    }
}

:username é um placeholder nomeado; Spring o converte internamente para ? e vincula o valor com segurança. Esta abordagem evita confusão com a ordem dos parâmetros quando a consulta SQL tem várias condições.

4. Aplicação com JPA/Hibernate

JPA gera SQL com prepared statement por padrão, mas você ainda pode se prejudicar se usar a API incorretamente.

// SEGURO — JPQL com parâmetros nomeados
public List<Order> findOrdersByStatus(String status) {
    return entityManager.createQuery(
            "SELECT o FROM Order o WHERE o.status = :status", Order.class)
        .setParameter("status", status)
        .getResultList();
}
// PERIGOSO — concatenação de string em JPQL
public List<Order> findOrdersByStatusUnsafe(String status) {
    String jpql = "SELECT o FROM Order o WHERE o.status = '" + status + "'";
    return entityManager.createQuery(jpql, Order.class).getResultList();
}
  • setParameter("status", status) no exemplo seguro faz o Hibernate traduzir JPQL para SQL com ?, depois vincula o valor através de PreparedStatement — o atacante não pode interferir na estrutura da consulta.
  • No exemplo perigoso, o Hibernate ainda analisa JPQL, mas a string JPQL já está “contaminada” antes da análise, permitindo que o atacante injete JPQL injection (semelhante a SQL injection, apenas em uma camada de sintaxe diferente).

Com Spring Data JPA, @Query também deve usar parâmetros em vez de concatenação:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o WHERE o.customerEmail = :email AND o.status = :status")
    List<Order> findByEmailAndStatus(@Param("email") String email, @Param("status") String status);
}

5. Casos em que não é possível parametrizar: nomes de tabelas e colunas

PreparedStatement só pode parametrizar valores, não identificadores (nomes de tabelas, colunas, cláusula ORDER BY). Esta é uma situação que muitos desenvolvedores ignoram.

// Ordenação dinâmica por coluna escolhida pelo usuário
public List<Order> findAllSorted(String sortColumn) {
    Set<String> allowedColumns = Set.of("id", "createdAt", "totalAmount");
    if (!allowedColumns.contains(sortColumn)) {
        throw new IllegalArgumentException("Coluna de ordenação inválida: " + sortColumn);
    }
    String sql = "SELECT * FROM orders ORDER BY " + sortColumn; // seguro porque foi whitelist
    // ... executar com PreparedStatement como de costume
    return jdbc.query(sql, this::mapRow);
}
  • Como não é possível escrever ORDER BY ?, você deve whitelist os valores válidos antes de concatenar.
  • Nunca use blacklist (bloquear palavras-chave como DROP, ;) porque o atacante sempre encontra formas de contornar através de encoding ou variantes de sintaxe.

6. Escrever testes para detectar vulnerabilidades cedo

Um teste simples ajuda você a confirmar que o endpoint está protegido contra injection corretamente.

@SpringBootTest
@AutoConfigureMockMvc
class CustomerSearchSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void searchByUsername_withInjectionPayload_returnsEmptyNotAllRows() throws Exception {
        String payload = "' OR '1'='1";

        mockMvc.perform(get("/api/customers").param("username", payload))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$").isEmpty());
    }

    @Test
    void sortBy_withInvalidColumn_rejectsRequest() throws Exception {
        mockMvc.perform(get("/api/orders").param("sortBy", "id; DROP TABLE orders"))
               .andExpect(status().isBadRequest());
    }
}
class CustomerRepositoryTest {

    @Test
    void findByUsername_withSqlInjectionPayload_returnsEmptyOptional() {
        CustomerRepository repo = new CustomerRepository(jdbcTemplate);

        Optional<Customer> result = repo.findByUsername("nonexistent' OR '1'='1");

        assertThat(result).isEmpty();
    }
}
  • O primeiro teste envia um payload de injection clássico, esperando um resultado vazio (porque nenhum username corresponde àquela string literal) em vez de todos os dados.
  • O segundo teste verifica se o endpoint rejeita entrada que não está na whitelist de colunas de ordenação — retornando 400 Bad Request em vez de executar SQL perigoso.
  • assertThat(result).isEmpty() usa AssertJ para confirmar que Optional está vazio, provando que a query não foi contornada.

7. Erros comuns

  • Usar Statement.executeQuery(String) para entrada dinâmica: rápido ao escrever demos, mas é uma porta aberta em produção.
  • String.format para “encurtar” a instrução SQL: ainda é concatenação, não diferente de usar +.
  • Confiar 100% na ORM: JPQL/HQL/Criteria ainda podem sofrer injection se você concatenar strings antes de chamar createQuery.
  • Escape manual em vez de parametrizar: escrever sua própria função para escapar aspas simples (replace("'", "\\'")) é propenso a erros (por exemplo, encoding Unicode, multi-byte) e não é tão confiável quanto o mecanismo nativo do driver.
  • Cláusula IN dinâmica incorreta: ao construir WHERE id IN (?, ?, ?) com número variável de parâmetros, você deve gerar o número correto de ? correspondendo ao tamanho da lista, não concatenar valores diretamente.

Nota: Prepared statement evita SQL injection, mas não evita injection em outras camadas como command injection, LDAP injection ou NoSQL injection. Cada camada de dados precisa do seu próprio mecanismo de parametrização.

8. Quando PreparedStatement não é necessário

Se a instrução SQL for completamente estática, sem nenhum valor dinâmico da entrada do usuário (por exemplo, SELECT COUNT(*) FROM customers), usar Statement comum ainda é seguro e às vezes um pouco mais rápido porque pula a etapa de parse/bind. No entanto, na prática, a maioria das queries tem condições dinâmicas, portanto use PreparedStatement ou equivalente (JPQL com setParameter, MyBatis com #{} em vez de ${}) como padrão e hábito, evitando ter que lembrar de “quando é seguro”.

9. Conclusão

SQL injection surge da mistura de código e dados em uma string SQL. PreparedStatement resolve o problema na raiz separando os dois: a estrutura da instrução é compilada primeiro, os valores dos parâmetros apenas vinculados depois como dados brutos. Aplique consistentemente em JDBC, JPQL, MyBatis, sempre whitelist para identificadores dinâmicos como nomes de colunas/tabelas, e escreva testes com payloads de injection clássicos para garantir que seus endpoints nunca executem SQL controlado por atacantes.

web-securitysql-injectionjdbcspring-bootjava