PreparedStatement por dentro: parámetros frente a inyección SQL
Programa lo que hace un PreparedStatement con sus parámetros: encontrar los ? de la consulta (sin contar los que van dentro de un texto), validar cada parámetro según su tipo, escapar las comillas y comparar el resultado con concatenar a mano, que es por donde entra la inyección SQL.
- PreparedStatement y parámetros ?
- Inyección SQL
- Escapar comillas
- Recorrer una cadena con estado
- Validar tipos (setInt, setDate)
- record y switch como expresión
Enunciado
En JDBC, construir una consulta pegando textos ("... WHERE nombre = '" + nombre + "'") es el origen de la inyección SQL: si el nombre trae una comilla, deja de ser un dato y pasa a ser parte del SQL (x' OR '1'='1 devuelve todas las filas). La solución es PreparedStatement: la consulta lleva un ? por cada dato y los valores se pasan aparte con setString, setInt, setDate o setNull.
En este ejercicio simulas por dentro un PreparedStatement y muestras, para cada ejecución, cómo quedaría la consulta concatenando y cómo con parámetros. Un detalle: un ? dentro de un texto entre comillas ('¿aprobado?') no es un parámetro, y dentro de un texto SQL una comilla se escribe doblada ('It''s').
Qué tiene que hacer el programa
consulta SQLprepara una consulta nueva y escribeConsulta preparada: N parámetros(1 parámetro), contando solo los?que no están dentro de un texto entre comillas simples. Si una comilla queda sin cerrar:Consulta no válida: hay una comilla sin cerrar, y no queda ninguna consulta preparada.texto V,numero V,fecha Vynuloañaden el siguiente parámetro (V es todo lo que va tras el primer espacio, y puede estar vacío). Un número solo admite-?\d+(\.\d+)?: si no,numero «V» rechazado: no es un número; una fecha debe existir en formato AAAA-MM-DD (LocalDate.parse): si no,fecha «V» rechazada: no es una fecha AAAA-MM-DD válida. Sin consulta:Primero hace falta una consulta; con todos los huecos llenos:Sobra el parámetro: la consulta solo tiene N. Si se acepta, no escribe nada.ejecutarsin consulta escribePrimero hace falta una consulta; si faltan parámetros,Faltan parámetros: hay P de N(y se conservan). Si no, escribeEjecución K,Concatenando:con cada valor pegado tal cual (textos y fechas entre comillas, números tal cual, nulos comoNULL) yPreparada:con los textos con cada comilla doblada; después, por cada parámetro de texto con una comilla,¡Cuidado! «V» rompe la consulta concatenada: así entra la inyección SQL. Al acabar, los parámetros se vacían (comoclearParameters) y la consulta sigue preparada.- Otra orden:
Orden desconocida: orden. Al final:Ejecuciones: E; en P la concatenación habría permitido inyección SQL(P cuenta las ejecuciones con al menos un aviso).
Entrada
Una orden por línea: consulta SQL, texto V, numero V, fecha AAAA-MM-DD, nulo o ejecutar.
Ejemplos de ejecución
Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.
Comillas e inyección
Entrada
consulta SELECT * FROM alumnos WHERE nombre = ? AND nota >= ? texto O'Brien numero 5 ejecutar texto x' OR '1'='1 numero 0 ejecutar consulta DELETE FROM alumnos WHERE id = ? numero 1 OR 1=1 numero 12 ejecutar
Salida por consola
Consulta preparada: 2 parámetros Ejecución 1 Concatenando: SELECT * FROM alumnos WHERE nombre = 'O'Brien' AND nota >= 5 Preparada: SELECT * FROM alumnos WHERE nombre = 'O''Brien' AND nota >= 5 ¡Cuidado! «O'Brien» rompe la consulta concatenada: así entra la inyección SQL Ejecución 2 Concatenando: SELECT * FROM alumnos WHERE nombre = 'x' OR '1'='1' AND nota >= 0 Preparada: SELECT * FROM alumnos WHERE nombre = 'x'' OR ''1''=''1' AND nota >= 0 ¡Cuidado! «x' OR '1'='1» rompe la consulta concatenada: así entra la inyección SQL Consulta preparada: 1 parámetro numero «1 OR 1=1» rechazado: no es un número Ejecución 3 Concatenando: DELETE FROM alumnos WHERE id = 12 Preparada: DELETE FROM alumnos WHERE id = 12 Ejecuciones: 3; en 2 la concatenación habría permitido inyección SQL
Huecos, fechas y nulos
Entrada
texto Ana consulta UPDATE alumnos SET comentario = '¿aprobado?', baja = ?, nota = ? WHERE id = ? fecha 2024-02-30 fecha 2024-02-29 nulo ejecutar numero 7 numero 8 ejecutar consulta SELECT * FROM t WHERE a = 'abc ejecutar borrar
Salida por consola
Primero hace falta una consulta Consulta preparada: 3 parámetros fecha «2024-02-30» rechazada: no es una fecha AAAA-MM-DD válida Faltan parámetros: hay 2 de 3 Sobra el parámetro: la consulta solo tiene 3 Ejecución 1 Concatenando: UPDATE alumnos SET comentario = '¿aprobado?', baja = '2024-02-29', nota = NULL WHERE id = 7 Preparada: UPDATE alumnos SET comentario = '¿aprobado?', baja = '2024-02-29', nota = NULL WHERE id = 7 Consulta no válida: hay una comilla sin cerrar Primero hace falta una consulta Orden desconocida: borrar Ejecuciones: 1; en 0 la concatenación habría permitido inyección SQL
Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. Recorrer con estado
Recorre la consulta carácter a carácter con un booleano enTexto que cambia con cada comilla. Un ? solo es un hueco si enTexto es falso. Las comillas dobladas ('') cierran y vuelven a abrir, así que funcionan sin hacer nada especial.
for (int i = 0; i < sql.length(); i++) {
char c = sql.charAt(i);
if (c == '\'') enTexto = !enTexto;
else if (c == '?' && !enTexto) huecos.add(i);
}2. Montar la consulta
Guarda la posición de cada hueco. Para montar, copia el SQL hasta el hueco, añade el parámetro y sigue desde la posición siguiente; StringBuilder.append(cadena, desde, hasta) copia un trozo.
3. Validar al añadir
Igual que setInt no admite un texto, aquí numero rechaza 1 OR 1=1: esa es la primera defensa. La segunda es que los textos nunca se pegan sin escapar.
4. Un switch para las órdenes
case "texto", "numero", "fecha", "nulo" -> agrupa las cuatro órdenes de parámetro en una sola rama.
Resuélvelo aquí
El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.
Ejemplo
consulta SELECT * FROM alumnos WHERE nombre = ? AND nota >= ? texto O'Brien numero 5 ejecutar texto x' OR '1'='1 numero 0 ejecutar consulta DELETE FROM alumnos WHERE id = ? numero 1 OR 1=1 numero 12 ejecutar
Consulta preparada: 2 parámetros Ejecución 1 Concatenando: SELECT * FROM alumnos WHERE nombre = 'O'Brien' AND nota >= 5 Preparada: SELECT * FROM alumnos WHERE nombre = 'O''Brien' AND nota >= 5 ¡Cuidado! «O'Brien» rompe la consulta concatenada: así entra la inyección SQL Ejecución 2 Concatenando: SELECT * FROM alumnos WHERE nombre = 'x' OR '1'='1' AND nota >= 0 Preparada: SELECT * FROM alumnos WHERE nombre = 'x'' OR ''1''=''1' AND nota >= 0 ¡Cuidado! «x' OR '1'='1» rompe la consulta concatenada: así entra la inyección SQL Consulta preparada: 1 parámetro numero «1 OR 1=1» rechazado: no es un número Ejecución 3 Concatenando: DELETE FROM alumnos WHERE id = 12 Preparada: DELETE FROM alumnos WHERE id = 12 Ejecuciones: 3; en 2 la concatenación habría permitido inyección SQL
Solución explicada
Ver la solución completa
1import java.time.LocalDate;
2import java.time.format.DateTimeParseException;
3import java.util.ArrayList;
4import java.util.List;
5import java.util.Scanner;
6
7/** Un parámetro: su tipo (texto, numero, fecha o nulo) y su valor tal como llega. */
8record Parametro(String tipo, String valor) {
9 /** Como lo pondría un PreparedStatement: los textos entre comillas y con cada comilla doblada. */
10 String seguro() {
11 return switch (tipo) {
12 case "texto", "fecha" -> "'" + valor.replace("'", "''") + "'";
13 case "numero" -> valor;
14 default -> "NULL";
15 };
16 }
17
18 /** Como lo pondría quien concatena: el valor pegado tal cual entre comillas. */
19 String concatenado() {
20 return switch (tipo) {
21 case "texto", "fecha" -> "'" + valor + "'";
22 case "numero" -> valor;
23 default -> "NULL";
24 };
25 }
26}
27
28class Sentencia {
29 private final String sql;
30 private final List<Integer> huecos = new ArrayList<>(); // posición de cada ? fuera de las comillas
31 private final List<Parametro> parametros = new ArrayList<>();
32
33 Sentencia(String sql) {
34 this.sql = sql;
35 boolean enTexto = false;
36 for (int i = 0; i < sql.length(); i++) {
37 char c = sql.charAt(i);
38 if (c == '\'') enTexto = !enTexto; // '' dentro de un texto cierra y vuelve a abrir: sigue dentro
39 else if (c == '?' && !enTexto) huecos.add(i);
40 }
41 if (enTexto) throw new IllegalArgumentException("hay una comilla sin cerrar");
42 }
43
44 int huecos() { return huecos.size(); }
45 int parametros() { return parametros.size(); }
46
47 boolean anadir(Parametro p) {
48 if (parametros.size() == huecos.size()) return false;
49 parametros.add(p);
50 return true;
51 }
52
53 List<Parametro> getParametros() { return parametros; }
54
55 /** El SQL con cada ? sustituido por su parámetro, de forma segura o concatenando. */
56 String montar(boolean seguro) {
57 StringBuilder sb = new StringBuilder();
58 int desde = 0;
59 for (int k = 0; k < huecos.size(); k++) {
60 sb.append(sql, desde, huecos.get(k));
61 Parametro p = parametros.get(k);
62 sb.append(seguro ? p.seguro() : p.concatenado());
63 desde = huecos.get(k) + 1;
64 }
65 return sb.append(sql.substring(desde)).toString();
66 }
67
68 void limpiar() { parametros.clear(); }
69}
70
71public class Main {
72 public static void main(String[] args) {
73 Scanner sc = new Scanner(System.in);
74 Sentencia s = null;
75 int ejecuciones = 0, peligrosas = 0;
76 while (sc.hasNextLine()) {
77 String linea = sc.nextLine().trim();
78 if (linea.isEmpty()) continue;
79 String[] p = linea.split(" ", 2);
80 String orden = p[0], valor = p.length > 1 ? p[1] : "";
81 switch (orden) {
82 case "consulta" -> {
83 try {
84 s = new Sentencia(valor);
85 System.out.println("Consulta preparada: " + s.huecos() + (s.huecos() == 1 ? " parámetro" : " parámetros"));
86 } catch (IllegalArgumentException e) {
87 s = null;
88 System.out.println("Consulta no válida: " + e.getMessage());
89 }
90 }
91 case "texto", "numero", "fecha", "nulo" -> {
92 if (s == null) {
93 System.out.println("Primero hace falta una consulta");
94 } else if (orden.equals("numero") && !valor.matches("-?\\d+(\\.\\d+)?")) {
95 System.out.println("numero «" + valor + "» rechazado: no es un número");
96 } else if (orden.equals("fecha") && !fechaValida(valor)) {
97 System.out.println("fecha «" + valor + "» rechazada: no es una fecha AAAA-MM-DD válida");
98 } else if (!s.anadir(new Parametro(orden, valor))) {
99 System.out.println("Sobra el parámetro: la consulta solo tiene " + s.huecos());
100 }
101 }
102 case "ejecutar" -> {
103 if (s == null) {
104 System.out.println("Primero hace falta una consulta");
105 } else if (s.parametros() < s.huecos()) {
106 System.out.println("Faltan parámetros: hay " + s.parametros() + " de " + s.huecos());
107 } else {
108 ejecuciones++;
109 System.out.println("Ejecución " + ejecuciones);
110 System.out.println(" Concatenando: " + s.montar(false));
111 System.out.println(" Preparada: " + s.montar(true));
112 boolean peligro = false;
113 for (Parametro par : s.getParametros()) {
114 if (par.tipo().equals("texto") && par.valor().contains("'")) {
115 System.out.println(" ¡Cuidado! «" + par.valor() + "» rompe la consulta concatenada: así entra la inyección SQL");
116 peligro = true;
117 }
118 }
119 if (peligro) peligrosas++;
120 s.limpiar();
121 }
122 }
123 default -> System.out.println("Orden desconocida: " + orden);
124 }
125 }
126 System.out.println("Ejecuciones: " + ejecuciones + "; en " + peligrosas + " la concatenación habría permitido inyección SQL");
127 }
128
129 static boolean fechaValida(String v) {
130 try {
131 LocalDate.parse(v); // ISO estricto: 2024-02-30 no existe
132 return true;
133 } catch (DateTimeParseException e) {
134 return false;
135 }
136 }
137}Con x' OR '1'='1 concatenado, la consulta queda nombre = 'x' OR '1'='1': la comilla del dato cierra el texto y el resto se ejecuta como SQL. Con parámetros queda 'x'' OR ''1''=''1', un texto raro que no coincide con ningún nombre, y nada más.
Un PreparedStatement real hace aún más: la base de datos recibe la consulta con los ? y los valores por separado, compila el plan una sola vez y lo reutiliza en cada ejecución (por eso es también más rápido en un bucle). El SQL «Preparada» de este ejercicio es solo una forma de verlo.
Validar el tipo al añadir (setInt, setDate) corta otra vía de ataque: 1 OR 1=1 como número borraría todas las filas de un DELETE ... WHERE id = ? concatenado.
Los parámetros solo sirven para valores: un nombre de tabla o de columna no puede ser un ?. Si tiene que ser variable, se valida contra una lista de nombres permitidos.
Para ir más allá
- Añade
lista V1,V2,V3para unIN (?)que se expanda a tantos?como valores. - Escapa también la barra invertida, como hace MySQL.
- Cuenta cuántas veces se reutiliza cada consulta preparada (el plan que ahorra la base de datos).