Entrada no confiable
En resumen: Una entrada no confiable es todo lo que llega de fuera (una solicitud, un formulario, un archivo); se comprueba en el servidor contra una lista de permitidos, y ante cualquier error se rechaza la solicitud.
Problema
Audífonos por -177 €
La tienda vende audífonos por 59 €. El formulario de la página no deja poner una cantidad menor que 1 ni mayor que 10, y el precio está en un campo oculto:
<form method="post" action="/orders"> <input type="hidden" name="product" value="17"> <input type="hidden" name="price" value="59"> <input type="number" name="qty" min="1" max="10" value="1"> <button>Comprar</button></form>El servidor toma los campos y calcula el total:
static String placeOrder(Map<String, String> form) { int price = Integer.parseInt(form.get("price")); int qty = Integer.parseInt(form.get("qty")); return "Pedido: " + qty + " uds. a " + price + " € = " + qty * price + " €";}
// Desde la página: el navegador ha comprobado el formularioSystem.out.println(placeOrder(Map.of("product", "17", "price", "59", "qty", "2")));// La misma solicitud, enviada a manoSystem.out.println(placeOrder(Map.of("product", "17", "price", "1", "qty", "2")));System.out.println(placeOrder(Map.of("product", "17", "price", "59", "qty", "-3")));Pedido: 2 uds. a 59 € = 118 €Pedido: 2 uds. a 1 € = 2 €Pedido: -3 uds. a 59 € = -177 €La primera solicitud viene de la página. Las otras dos no. El atacante abrió las herramientas de desarrollo del navegador, o envió la solicitud con curl, y escribió lo que quiso:
POST /orders HTTP/1.1Content-Type: application/x-www-form-urlencoded
product=17&price=59&qty=-3El proveedor de pagos recibe un total de -177 € y devuelve 177 € a la tarjeta. La comprobación min="1" no estaba: la solicitud nunca pasó por el formulario.
La idea
El límite de confianza
El navegador es del comprador. Todo lo que hay en él, del formulario al JavaScript de la página, el atacante puede cambiarlo u omitirlo. Tu código empieza en el servidor. El lugar por donde los datos entran de fuera hacia dentro es el límite de confianza.
Lo que lo cruza es entrada no confiable hasta que lo compruebes:
- los campos del formulario, también los ocultos;
- la dirección y los parámetros que lleva;
- los encabezados y las cookies de la solicitud;
- el nombre, el tipo y el tamaño de un archivo subido;
- la respuesta de un sistema ajeno, por ejemplo del proveedor de pagos.
Tu sitio puso allí el campo oculto y la cookie. Pero el navegador del comprador los devuelve, y pueden haberse cambiado por el camino.
Pensar como el atacante es preguntar por cada campo: ¿y si es negativo? ¿Vacío? ¿Enorme? ¿Texto en lugar de un número? ¿El id del pedido de otro? ¿10 MB en lugar de 10 caracteres?
Míralo
Qué deja entrar el servidor
El manejador corregido no confía en nada de la solicitud. Así pasan por él los dos pedidos:
static String placeOrder(Map<String, String> form) { String product = form.getOrDefault("product", ""); String qty = form.getOrDefault("qty", ""); if (!PRICES.containsKey(product)) { return "Rechazado: no existe ese producto"; } if (!qty.matches("[1-9]|10")) { return "Rechazado: la cantidad es de 1 a 10"; } int count = Integer.parseInt(qty); int price = PRICES.get(product); // del catálogo return "Pedido: " + count + " uds. a " + price + " € = " + count * price + " €";}Paso a paso, en palabras
- Llegan dos pedidos. Uno viene de la página, donde el navegador comprobó qty ≥ 1. El otro se envió a mano: precio 1, cantidad -3. Para el servidor los dos son solo texto.
- Cruzan el límite de confianza. A partir de aquí nada está comprobado. placeOrder lee solo product y qty; el precio de la solicitud no se lee nunca.
- Primera comprobación: ¿está el producto en el catálogo? El 17 sí está, en los dos. Un id desconocido se detendría aquí.
- Segunda comprobación: la cantidad tiene que ser uno de "1" … "10". "2" está en la lista. "-3" no: rechazado, y el pedido no sigue.
- El precio viene del catálogo: 59 €. Lo que diga la solicitud sobre el precio no importa.
- El pedido honesto: 2 × 59 € = 118 €. El falsificado se detuvo en las comprobaciones tras el límite. Entra solo lo que pasó todas las comprobaciones.
Tres reglas:
- No leas lo que ya sabes. El precio está en el catálogo. La solicitud solo dice qué producto y cuántos.
- Comprueba contra una lista de permitidos. El producto tiene que estar en el catálogo. La cantidad tiene que ser exactamente uno de los textos
"1"a"10". - Si algo no pasa, rechaza. Todo camino que no llega al pedido es un rechazo.
static final Map<String, Integer> PRICES = Map.of("17", 59, "23", 9);
static String placeOrder(Map<String, String> form) { String product = form.getOrDefault("product", ""); String qty = form.getOrDefault("qty", ""); if (!PRICES.containsKey(product)) { return "Rechazado: no existe ese producto"; } if (!qty.matches("[1-9]|10")) { return "Rechazado: la cantidad es de 1 a 10"; } int count = Integer.parseInt(qty); int price = PRICES.get(product); // del catálogo return "Pedido: " + count + " uds. a " + price + " € = " + count * price + " €";}
System.out.println(placeOrder(Map.of("product", "17", "price", "59", "qty", "2")));System.out.println(placeOrder(Map.of("product", "17", "price", "1", "qty", "2")));System.out.println(placeOrder(Map.of("product", "17", "price", "59", "qty", "-3")));System.out.println(placeOrder(Map.of("product", "99", "price", "1", "qty", "1")));Pedido: 2 uds. a 59 € = 118 €Pedido: 2 uds. a 59 € = 118 €Rechazado: la cantidad es de 1 a 10Rechazado: no existe ese productomatches comprueba el texto entero contra el patrón: [1-9]|10 significa una cifra del 1 al 9, o exactamente 10.Pruébalo
De dónde viene el precio
Completa la línea en la que el manejador decide el precio:
static String placeOrder(Map<String, String> form) {
String product = form.getOrDefault("product", "");
String qty = form.getOrDefault("qty", "");
if (!PRICES.containsKey(product)) {
return "Rechazado: no existe ese producto";
}
if (!qty.matches("[1-9]|10")) {
return "Rechazado: la cantidad es de 1 a 10";
}
int count = Integer.parseInt(qty);
int price = ;
return "Pedido: " + count + " uds. a " + price
+ " € = " + count * price + " €";
}El precio está en el catálogo, por el id del producto. La solicitud puede traer el precio que quiera: el manejador no lo lee.
Pruébalo
Tres solicitudes
¿Qué imprime el manejador corregido para estas tres solicitudes?
System.out.println(placeOrder(Map.of("product", "23", "price", "0", "qty", "10")));System.out.println(placeOrder(Map.of("product", "17", "qty", "11")));System.out.println(placeOrder(Map.of("product", "17", "qty", " 3")));La primera es un cable (producto 23) por 9 €: el precio 0 de la solicitud no se lee. 11 no está en la lista de 1 a 10. " 3" tiene un espacio delante, y eso no es ninguno de los textos permitidos. No «arregles» la entrada por el comprador: lo que no encaja exactamente se rechaza.
La idea
Permitido, no prohibido
A mucha gente lo primero que se le ocurre es prohibir lo malo: «la cantidad no puede ser negativa ni cero». Eso es una lista de prohibidos (deny-list). El otro camino es describir exactamente lo bueno: «la cantidad es uno de los textos del 1 al 10». Eso es una lista de permitidos (allow-list).
Aquí están las dos con las mismas entradas:
static boolean denyList(String qty) { return !qty.startsWith("-") && !qty.equals("0");}
static boolean allowList(String qty) { return qty.matches("[1-9]|10");}
static String word(boolean ok) { return ok ? "aceptado" : "rechazado";}
System.out.println(String.format("%-6s%-11s%s", "dato", "deny-list", "allow-list"));for (String qty : List.of("2", "-3", "0", "500", "2.5", "1e3")) { System.out.println(String.format("%-6s%-11s%s", qty, word(denyList(qty)), word(allowList(qty))));}dato deny-list allow-list2 aceptado aceptado-3 rechazado rechazado0 rechazado rechazado500 aceptado rechazado2.5 aceptado rechazado1e3 aceptado rechazadoLa lista de prohibidos dejó pasar 500, 2.5 y 1e3. Nadie pensó en ellas al escribirla. El atacante prueba justo lo que no se te ocurrió. Con una lista de permitidos, lo desconocido se rechaza sin que tengas que preverlo.
Así que: describe cómo es una entrada correcta (tipo, longitud, caracteres, rango, un valor de una lista conocida) y rechaza todo lo demás.
Más precisamente, para curiosos
- Válido no significa tuyo.
1042es un número de pedido totalmente correcto, pero puede ser de otro comprador. Una comprobación de formato no basta: el servidor también comprueba que el pedido pertenezca al comprador que inició sesión. Sin eso tienes uno de los agujeros más comunes de los sitios reales (IDOR, insecure direct object reference). - Normaliza, después comprueba. Si el sistema más adelante lee la entrada de otra manera que la comprobación (espacios, mayúsculas y minúsculas, Unicode, codificación en la dirección), comprueba la entrada después de normalizarla. Si no, la comprobación mira una cosa y el sistema recibe otra.
- Limita el tamaño. Cada campo tiene un límite superior de longitud y cada archivo subido uno de tamaño. Si no, un «nombre» de 50 MB se come la memoria del servidor.
- Expresiones regulares y el final del texto.
String.matchescomprueba el texto entero, mientras queMatcher.findbusca el patrón en cualquier parte:findcon[1-9]|10también deja pasar"500". - En un proyecto real la validación es declarativa. Jakarta Bean Validation:
@Min(1) @Max(10) int qtyy@Validen un controlador de Spring. Las reglas son las mismas: una lista de permitidos, rechazar ante el error, comprobar en el servidor.
Problema
La comprobación que deja pasar ante un error
La oferta tiene un límite: como máximo 10 audífonos por pedido. La comprobación consulta una tabla de límites, y ante un producto desconocido lanza una excepción. El código atrapa la excepción, la escribe en el registro y sigue:
static final Map<String, Integer> LIMITS = Map.of("17", 10, "23", 50);
static int limitOf(String product) { Integer limit = LIMITS.get(product); if (limit == null) { throw new IllegalArgumentException("producto desconocido " + product); } return limit;}
static boolean mayBuy(String product, int qty) { boolean allowed = true; try { allowed = qty <= limitOf(product); } catch (IllegalArgumentException e) { System.out.println("log: " + e.getMessage()); } return allowed;}
System.out.println("17 x 3: " + (mayBuy("17", 3) ? "sí" : "no"));System.out.println("17 x 500: " + (mayBuy("17", 500) ? "sí" : "no"));System.out.println("99 x 500: " + (mayBuy("99", 500) ? "sí" : "no"));17 x 3: sí17 x 500: nolog: producto desconocido 9999 x 500: síPasaron 500 unidades del producto 99. El error en la comprobación se convirtió en «permitido».
Pruébalo
Encuentra la línea
¿Qué línea hace que el pedido pase cuando hay un error?
Elige la línea con el error.
El valor inicial. Si limitOf lanza una excepción, la asignación de try nunca ocurre y se queda el «permitido» inicial. Empieza con un rechazo: boolean allowed = false;. Entonces un error significa «no» y la última línea pasa a ser 99 x 500: no.
Esta es la regla fail closed (fallar cerrado). Lo desconocido, lo vacío, un error en la comprobación y una excepción inesperada llevan todos a «no». Lo contrario (fail open) es cómodo hasta que alguien encuentra la entrada que rompe la comprobación.
Ejercicio
El código de descuento
Resuelve la tarea en tu editor.
El cupón. El carrito es de 120 €. Escribe discount(code), que devuelve el total después del descuento, o «rechazado». Los códigos son AUTUMN26 (25 %) y STUDENTS (10 %). Deja pasar solo exactamente 8 caracteres de A a Z y de 0 a 9, y solo un código que esté en la tabla. Todo lo demás se rechaza. Prueba estas entradas:
"AUTUMN26": 90 €"autumn26": rechazado"AUTUMN26' OR '1'='1": rechazado"": rechazado"STUDENTS": 108 €"NOPE1234": rechazadoautumn26 se rechaza: la regla dice mayúsculas, no «arréglalo por el comprador». La tienda puede decidir aceptar minúsculas, pero entonces lo escribe en la regla y comprueba eso.
Más tareas
- La dirección de entrega. Describe con reglas cómo son un código postal (en Bulgaria, 4 cifras), una ciudad y un número de teléfono. ¿Cuáles pueden ser una lista de permitidos y cuáles solo un límite de longitud y de caracteres?
- El pedido de otro.
GET /orders/1042devuelve el pedido 1042. La entrada1042pasa cualquier comprobación de formato. ¿Qué más tiene que comprobar el servidor?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Expresiones regulares · Listas · HashMap y HashSet · Programación en red
- Continúa en: Inyecciones: cuando los datos se vuelven una orden · XSS: un script ajeno en tu página · Sesiones, cookies y CSRF · JWT y sus trampas
- Ejercicios sobre el tema: Seguridad para programadores