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
usernamecontenga' 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 dePreparedStatement— 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 Requesten lugar de ejecutar SQL peligroso. assertThat(result).isEmpty()usa AssertJ para confirmar que elOptionalesté 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.formatpara “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
INdinámica de forma incorrecta: al construirWHERE 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.