Ir al contenido
wannacode

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 formulario
System.out.println(placeOrder(Map.of("product", "17", "price", "59", "qty", "2")));
// La misma solicitud, enviada a mano
System.out.println(placeOrder(Map.of("product", "17", "price", "1", "qty", "2")));
System.out.println(placeOrder(Map.of("product", "17", "price", "59", "qty", "-3")));
Salida
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.1
Content-Type: application/x-www-form-urlencoded
product=17&price=59&qty=-3

El 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
  1. 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.
  2. 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.
  3. Primera comprobación: ¿está el producto en el catálogo? El 17 sí está, en los dos. Un id desconocido se detendría aquí.
  4. 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.
  5. El precio viene del catálogo: 59 €. Lo que diga la solicitud sobre el precio no importa.
  6. 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:

  1. No leas lo que ya sabes. El precio está en el catálogo. La solicitud solo dice qué producto y cuántos.
  2. 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".
  3. 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")));
Salida
Pedido: 2 uds. a 59 € = 118 €
Pedido: 2 uds. a 59 € = 118 €
Rechazado: la cantidad es de 1 a 10
Rechazado: no existe ese producto
matches 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 + " €";
}

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")));
Respuesta

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))));
}
Salida
dato deny-list allow-list
2 aceptado aceptado
-3 rechazado rechazado
0 rechazado rechazado
500 aceptado rechazado
2.5 aceptado rechazado
1e3 aceptado rechazado

La 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. 1042 es 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.matches comprueba el texto entero, mientras que Matcher.find busca el patrón en cualquier parte: find con [1-9]|10 también deja pasar "500".
  • En un proyecto real la validación es declarativa. Jakarta Bean Validation: @Min(1) @Max(10) int qty y @Valid en 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"));
Salida
17 x 3: sí
17 x 500: no
log: producto desconocido 99
99 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.

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:

Esperado
"AUTUMN26": 90 €
"autumn26": rechazado
"AUTUMN26' OR '1'='1": rechazado
"": rechazado
"STUDENTS": 108 €
"NOPE1234": rechazado

autumn26 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/1042 devuelve el pedido 1042. La entrada 1042 pasa cualquier comprobación de formato. ¿Qué más tiene que comprobar el servidor?

Test

Ponte a prueba

1. El campo de cantidad de la página es <input type="number" min="1" max="10">. ¿Tiene que comprobar la cantidad el servidor?
2. ¿Cuál de estas cosas es una entrada no confiable para el servidor de la tienda?
3. ¿Por qué una lista de permitidos es más segura que una de prohibidos?
4. La comprobación del límite lanza una excepción porque no conoce el producto. ¿Qué debe decidir el código?
5. El precio del producto está en un campo oculto del formulario. ¿Qué hace el servidor con él?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·