Apuntes DAM
Volver al inicio

PreparedStatement por dentro: parámetros frente a inyección SQL

Ejercicio de JavaDifícilUnos 60 minutos

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

  1. consulta SQL prepara una consulta nueva y escribe Consulta 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.
  2. texto V, numero V, fecha V y nulo añ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.
  3. ejecutar sin consulta escribe Primero hace falta una consulta; si faltan parámetros, Faltan parámetros: hay P de N (y se conservan). Si no, escribe Ejecución K, Concatenando: con cada valor pegado tal cual (textos y fechas entre comillas, números tal cual, nulos como NULL) y Preparada: 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 (como clearParameters) y la consulta sigue preparada.
  4. 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.

java
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.

☕JavaPreparedStatement por dentro: parámetros frente a inyección SQLDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
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 esperada
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
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
java
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,V3 para un IN (?) 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).

Dónde se explica