SQL Injection y Prepared Statement en Java

1. Descripción general

SQL injection es una vulnerabilidad de seguridad de alto nivel en la lista OWASP, que surge de concatenar strings SQL manualmente con input del usuario. Este artículo explica el mecanismo de ataque, muestra por qué PreparedStatement lo bloquea, y te guía en su aplicación en JDBC, JPA/Hibernate y MyBatis. Al terminar, escribirás código de consultas seguro y sabrás cómo escribir tests para detectar vulnerabilidades temprano.

2. Cómo funciona SQL injection

El error ocurre cuando concatenas directamente valores de input en una cadena SQL, en lugar de parametrizarlos.

// NO HAGAS ESTO
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;
    }
}

Si username es un valor válido como "minh", la declaración SQL generada es:

SELECT * FROM customers WHERE username = 'minh'

Pero si el atacante pasa username = "' OR '1'='1", la declaración SQL se convierte en:

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

La condición '1'='1' siempre es verdadera, por lo que la consulta devuelve toda la tabla customers — ignorando completamente la lógica de autenticación de username. Peor aún, el atacante puede agregar ; DROP TABLE customers; -- para eliminar datos, o usar UNION SELECT para leer otras tablas (por ejemplo, la tabla users que contiene hashes de contraseñas).

Nota: La entrada no solo proviene de formularios HTML. Los encabezados HTTP, cookies, parámetros de query y datos de carga de archivos son también fuentes potenciales de ataque.

3. Cómo PreparedStatement resuelve el problema

3.1. Mecanismo de parametrización

PreparedStatement separa la estructura SQL (se envía primero, la BD la parsea y compila en un plan de ejecución) de los valores de parámetros (se envían después, solo se ven como datos sin procesar). Gracias a esto, la entrada del atacante nunca se interpreta por la BD como sintaxis 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;
        }
    }
}
  • El signo ? es un placeholder, la BD pre-compila la sentencia con este placeholder.
  • stmt.setString(1, username) asigna el valor a la posición del parámetro 1 (numerado desde 1, no desde 0).
  • Aunque username contenga ' OR '1'='1, la BD lo entiende como una cadena de caracteres única para coincidir, no como sintaxis SQL. La consulta devuelve vacío porque no hay ningún usuario con ese nombre extraño.

3.2. Parámetros nombrados con Spring JdbcTemplate

Si usas Spring, NamedParameterJdbcTemplate hace que el código sea más legible que usar ? con números de posición.

<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 es un placeholder nombrado, Spring lo convierte internamente a ? y vincula el valor de manera segura. Esto evita confusiones con el orden de los parámetros cuando la SQL tiene múltiples condiciones.

4. Aplicación con JPA/Hibernate

JPA por defecto genera SQL usando prepared statements, pero aún puedes dispararle al pie si usas mal la API.

// SEGURO — JPQL con parámetro nombrado
public List<Order> findOrdersByStatus(String status) {
    return entityManager.createQuery(
            "SELECT o FROM Order o WHERE o.status = :status", Order.class)
        .setParameter("status", status)
        .getResultList();
}
// PELIGROSO — concatenar strings en 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) en el ejemplo seguro hace que Hibernate traduzca JPQL a SQL con ?, luego vincula el valor a través de PreparedStatement — el atacante no puede interferir en la estructura de la sentencia.
  • En el ejemplo peligroso, Hibernate aún parsea JPQL, pero la cadena JPQL ya está “contaminada” antes de parsearse, así que el atacante puede inyectar JPQL injection (similar a SQL injection, solo que en una capa sintáctica diferente).

Con Spring Data JPA, @Query también necesita usar parámetros en lugar de concatenar strings:

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 donde no se puede parametrizar: nombres de tablas y columnas

PreparedStatement solo puede parametrizar valores, no puede parametrizar identificadores (nombres de tablas, nombres de columnas, cláusula ORDER BY). Esta es una situación que muchas personas pasan por alto.

// Ordenamiento dinámico por columna elegida por el usuario
public List<Order> findAllSorted(String sortColumn) {
    Set<String> allowedColumns = Set.of("id", "createdAt", "totalAmount");
    if (!allowedColumns.contains(sortColumn)) {
        throw new IllegalArgumentException("Columna de ordenamiento no válida: " + sortColumn);
    }
    String sql = "SELECT * FROM orders ORDER BY " + sortColumn; // seguro porque ya está en whitelist
    // ... ejecutar con PreparedStatement como de costumbre
    return jdbc.query(sql, this::mapRow);
}
  • Puesto que no se puede escribir ORDER BY ?, debes hacer whitelist de valores válidos antes de concatenar strings.
  • Nunca uses blacklist (bloquear palabras clave como DROP, ;) porque el atacante siempre tiene formas de evitarlo mediante codificación o variantes de sintaxis.

6. Escribir tests para detectar vulnerabilidades temprano

Un test simple te ayuda a confirmar que tu endpoint está previniendo injection correctamente.

@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();
    }
}
  • El primer test envía el payload de injection clásico, esperando un resultado vacío (porque no hay ningún username que coincida con esa cadena literal) en lugar de todos los datos.
  • El segundo test verifica que el endpoint rechace la entrada que no esté en la lista blanca de columnas de ordenamiento — retorna 400 Bad Request en lugar de ejecutar SQL peligroso.
  • assertThat(result).isEmpty() usa AssertJ para confirmar que el Optional esté vacío, probando que la consulta no fue bypasseada.

7. Errores comunes

  • Usar Statement.executeQuery(String) para input dinámico: es rápido para escribir demos pero es una puerta trasera real en producción.
  • String.format para “acortar” la SQL: sigue siendo concatenación de strings, no es diferente a usar +.
  • Confiar al 100% en ORM: JPQL/HQL/Criteria siguen siendo vulnerables a injection si concatenas strings antes de llamar createQuery.
  • Escapar manualmente en lugar de parametrizar: escribir tu propia función de escape para comillas simples (replace("'", "\\'")) es propenso a omitir casos (como codificación Unicode, multi-byte) y no es tan confiable como el mecanismo nativo del driver.
  • Cláusula IN dinámica de forma incorrecta: al construir WHERE id IN (?, ?, ?) con un número variable de parámetros, debes generar el número correcto de ? correspondiente al tamaño de la lista, no concatenar valores directamente.

Nota: Los prepared statements previenen SQL injection pero no previenen injection en otras capas como command injection, LDAP injection o NoSQL injection. Cada capa de datos necesita su propio mecanismo de parametrización.

8. Cuándo no necesitas PreparedStatement

Si la SQL es completamente estática, sin ningún valor dinámico del input del usuario (por ejemplo SELECT COUNT(*) FROM customers), usar Statement normal sigue siendo seguro y a veces es ligeramente más ligero porque se salta el paso de parse/bind. Sin embargo, en la práctica, la mayoría de las consultas tienen condiciones dinámicas, así que por defecto debes usar PreparedStatement o equivalente (JPQL con setParameter, MyBatis con #{} en lugar de ${}) como un hábito, evitando tener que recordar “qué casos son seguros”.

9. Conclusión

SQL injection surge de mezclar código y datos en una cadena SQL. PreparedStatement lo resuelve de raíz separando ambos: la estructura de la sentencia se compila antes, los valores de parámetros solo se vinculan después como datos sin procesar. Aplica esto consistentemente en JDBC, JPQL, MyBatis, siempre haz whitelist de identificadores dinámicos como nombres de columnas/tablas, y escribe tests con payloads de injection clásicos para asegurar que tu endpoint nunca ejecute SQL controlado por un atacante.

web-securitysql-injectionjdbcspring-bootjava