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:
def place_order(form): price = int(form["price"]) qty = int(form["qty"]) return f"Pedido: {qty} uds. a {price} € = {qty * price} €"
# Desde la página: el navegador ha comprobado el formularioprint(place_order({"product": "17", "price": "59", "qty": "2"}))# La misma solicitud, enviada a manoprint(place_order({"product": "17", "price": "1", "qty": "2"}))print(place_order({"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:
def place_order(form): product = form.get("product", "") qty = form.get("qty", "") if product not in PRICES: return "Rechazado: no existe ese producto" if not re.fullmatch(r"[1-9]|10", qty): return "Rechazado: la cantidad es de 1 a 10" count = int(qty) price = PRICES[product] # del catálogo total = count * price return f"Pedido: {count} uds. a {price} € = {total} €"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. place_order 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.
import re
PRICES = {"17": 59, "23": 9}
def place_order(form): product = form.get("product", "") qty = form.get("qty", "") if product not in PRICES: return "Rechazado: no existe ese producto" if not re.fullmatch(r"[1-9]|10", qty): return "Rechazado: la cantidad es de 1 a 10" count = int(qty) price = PRICES[product] # del catálogo total = count * price return f"Pedido: {count} uds. a {price} € = {total} €"
print(place_order({"product": "17", "price": "59", "qty": "2"}))print(place_order({"product": "17", "price": "1", "qty": "2"}))print(place_order({"product": "17", "price": "59", "qty": "-3"}))print(place_order({"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 productore.fullmatch 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:
def place_order(form):
product = form.get("product", "")
qty = form.get("qty", "")
if product not in PRICES:
return "Rechazado: no existe ese producto"
if not re.fullmatch(r"[1-9]|10", qty):
return "Rechazado: la cantidad es de 1 a 10"
count = int(qty)
price =
total = count * price
return f"Pedido: {count} uds. a {price} € = {total} €"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?
print(place_order({"product": "23", "price": "0", "qty": "10"}))print(place_order({"product": "17", "qty": "11"}))print(place_order({"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:
import re
def deny_list(qty): return not qty.startswith("-") and qty != "0"
def allow_list(qty): return re.fullmatch(r"[1-9]|10", qty) is not None
def word(ok): return "aceptado" if ok else "rechazado"
print(f"{'dato':<6}{'deny-list':<11}allow-list")for qty in ["2", "-3", "0", "500", "2.5", "1e3"]: print(f"{qty:<6}{word(deny_list(qty)):<11}{word(allow_list(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.
re.searchyre.matchno comprueban el texto entero: también dejan pasar"500".$también acepta un\nal final, por eso aquí se usare.fullmatch. - En un proyecto real la validación es declarativa. pydantic o los formularios de Django y Flask-WTF:
conint(ge=1, le=10)y un tipo para cada campo. 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:
LIMITS = {"17": 10, "23": 50}
def limit_of(product): if product not in LIMITS: raise ValueError(f"producto desconocido {product}") return LIMITS[product]
def may_buy(product, qty): allowed = True try: allowed = qty <= limit_of(product) except ValueError as e: print(f"log: {e}") return allowed
print(f"17 x 3: {'sí' if may_buy('17', 3) else 'no'}")print(f"17 x 500: {'sí' if may_buy('17', 500) else 'no'}")print(f"99 x 500: {'sí' if may_buy('99', 500) else '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: 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: append, remove y sort · dict y set · 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