Skip to content
This page has been auto-translated and may contain errors.View in English

Nunca confíes en la entrada del usuario

Un formulario de registro es de lo más común que hay en el desarrollo web. Unos cuantos campos de texto, un botón de envío, un mensaje cuando todo sale bien.

También es la puerta de entrada. Cada nombre de usuario, correo electrónico, comentario y consulta de búsqueda que alguien escribe es información que tu aplicación no generó, y que llega desde una máquina que no controlas.

La mayor parte es inofensiva. Basta con que una sola persona tenga curiosidad por saber qué pasa si escribe algo que no habías previsto.

Tratar la entrada del usuario como datos seguros es la suposición más costosa de todo el desarrollo web.

El formulario que ataca esta sección

El ejemplo que usaremos es un formulario de registro con un front end y un back end, construido a propósito de forma deficiente.

ParteQué hace
front-end/index.htmlEl marcado del formulario: inputs, un botón de envío, contenedores para mensajes de éxito y de error.
front-end/app.tsLee los inputs, envía la información al back end y muestra el resultado.
front-end/types.tsRefleja los tipos del back end para que el cliente conozca la forma de una respuesta.
back-end/server.tsInicia Express, parsea JSON, monta las rutas y escucha en el puerto 3000.
back-end/routes/auth.tsEl endpoint /api/register-vulnerable. Se salta la validación por completo y devuelve la entrada tal cual.
back-end/state/mockDb.tsUn sustituto de base de datos, para que el contador de registrados pueda cambiar sin necesitar una real.

El nombre de ese endpoint es una advertencia. Nada de lo que hace debería existir en producción. Está ahí para que los ataques de los próximos capítulos tengan dónde aterrizar.

Reducido a lo esencial, el manejador hace esto:

Vulnerable
ts
router.post('/api/register-vulnerable', (req, res) => {
  const { name, email, password } = req.body

  mockDb.users.push({ name, email, password })

  res.json({ success: true, user: { name, email } })
})

Llegan tres campos, se guardan tres campos, se devuelven dos. En ningún momento se pregunta si name es realmente un nombre, si email tiene un @, o cuánto mide cualquiera de estos valores.

JunoEl formulario que ataca esta sección Este pequeño formulario es toda la superficie de ataque de la sección. Se mantiene igual a lo largo de todo el recorrido, y cada capítulo lo rompe de una manera nueva antes de arreglarlo.

Trabajar sobre una aplicación rota a propósito se siente raro al principio. Está construida mal a propósito, para que puedas ver la falla mientras la observas directamente.

JunoEl formulario que ataca esta sección Un endpoint, un formulario y un puñado de campos ya son suficiente superficie para inyección de scripts, agotamiento de recursos y manipulación de consultas. Esa es la sorpresa útil: no hace falta una aplicación grande para que algo falle en tres direcciones a la vez.

Las aplicaciones reales tienen cientos de casos así. El hábito que vale la pena construir es leer cualquier manejador que toque req.body y preguntarte qué está asumiendo sobre lo que llegó.

JunoEl formulario que ataca esta sección Una superficie tan pequeña es honesta sobre la ruta de la entrada, pero calladamente deshonesta sobre el tiempo. La base de datos simulada permite demostrar y reiniciar un ataque en un segundo, y por eso el foco se mantiene en hacia dónde viajan los valores.

Lo que oculta es la categoría de bug que encontrarás más adelante. El almacenamiento es donde la entrada va a esperar.

Un valor que parece inofensivo al entrar puede volverse peligroso meses después, cuando una página distinta lo renderiza en un contexto distinto. Por más que mires este formulario, eso nunca te lo va a mostrar.

Tenlo presente mientras todo todavía parece sin estado.

El navegador no puede hacer cumplir nada

HTML te da validación gratis. Marca un input como required y el formulario no se enviará vacío. Define type="email" y el navegador comprueba que el valor tenga forma de dirección de correo.

Eso es útil, y no protege a nadie.

Alguien que quiera saltárselo puede desactivar JavaScript, editar los atributos desde las herramientas de desarrollador, o directamente saltarse la página y enviar la solicitud al endpoint con curl. Tu formulario es un solo cliente. El endpoint acepta solicitudes de cualquier origen.

Las validaciones del navegador son una cortesía para los usuarios honestos

Detectan errores de tipeo y ahorran una ida y vuelta al servidor. No son un control de seguridad, porque quien decide si se ejecutan es el atacante.

Por eso el formulario empieza con la validación del navegador desactivada:

html
<form novalidate>

Empezar desde cero hace que el punto sea difícil de olvidar. Cada verificación que importa se agrega de forma deliberada, en el servidor, donde la solicitud ya no puede editarse en el camino.

JunoEl navegador no puede hacer cumplir nada La regla tiene que ver con quién manda. Todo lo que corre en el navegador de alguien corre en su computadora, bajo su control, y esa persona puede cambiarlo.

El servidor corre en tu máquina. Ese es el único lugar donde una verificación no puede ser desactivada por la persona a la que estás verificando.

JunoEl navegador no puede hacer cumplir nada Una forma rápida de sentirlo: abre la pestaña de red, envía el formulario una vez, copia la solicitud como curl, y luego edita el cuerpo y vuelve a enviarla. Sin página, sin JavaScript, sin validación.

Si esa solicitud tiene éxito con valores que el formulario habría rechazado, encontraste la brecha entre la interfaz y el endpoint.

JunoEl navegador no puede hacer cumplir nada La validación del lado del cliente sigue siendo útil. Evita idas y vueltas innecesarias al servidor, da retroalimentación inmediata y mantiene la experiencia de error cerca del campo.

El error está en tratarla como una de tus capas de seguridad. Queda fuera del límite de confianza, esa línea que separa la parte del sistema que controlas de la que no. Por eso pertenece al presupuesto de usabilidad y nunca al de seguridad.

Un equipo que la cuenta como un control suele descubrir el error a través de un reporte de incidente.

Tres maneras en que la entrada se convierte en un bug

Los próximos tres capítulos siguen el mismo valor hacia tres destinos distintos, y es el destino el que decide qué sale mal.

Dónde termina la entradaQué puede salir malCapítulo
Renderizada en una páginaUn script inyectado por una persona se ejecuta en el navegador de otraCross-site scripting
Consumiendo recursos del servidorUna solicitud demasiado grande o manipulada agota memoria, almacenamiento o conexionesDenegación de servicio
Concatenada en una consultaLa entrada se convierte en SQL y lee, cambia o elimina datosInyección SQL

El mismo campo puede alimentar los tres casos. Un nombre termina en una página, en el almacenamiento y en una consulta, así que un solo valor sin verificar tiene tres formas de hacerte daño.

JunoTres maneras en que la entrada se convierte en un bug La pregunta que mantiene esto simple: ¿a dónde va este valor después?

¿Al HTML de una página, a la base de datos, a una consulta, a un correo electrónico? Cada destino tiene su propia manera de malinterpretar texto como si fuera una instrucción.

JunoTres maneras en que la entrada se convierte en un bug Sigue el rastro de un campo de principio a fin antes de leer los capítulos de ataques. Toma el campo del nombre: se lee en app.ts, se envía al endpoint, se guarda, se devuelve tal cual y se renderiza en el mensaje de éxito.

Cinco lugares. Cada uno es una oportunidad para que el valor sea interpretado en vez de simplemente mostrado.

JunoTres maneras en que la entrada se convierte en un bug Estos tres son los casos que se pueden demostrar, pero el patrón que hay detrás es más amplio. Cada vez que un texto pasa de ser dato a ser parte de un lenguaje, el analizador del otro lado decide qué significa: HTML, SQL, un comando de shell, una plantilla, una ruta de archivo.

Reconocer esa forma importa más que memorizar payloads, porque los payloads cambian y la forma no.

No hay una sola solución, hay varias capas

Cada vulnerabilidad tiene una defensa, y ninguna de ellas es la respuesta completa. El término para apilarlas es defensa en profundidad: asume que cualquier capa puede fallar, y asegúrate de que algo detrás de ella siga sosteniendo todo.

Para este formulario, las capas son:

  1. La validación del navegador detecta errores de tipeo y ahorra una ida y vuelta. No detiene a nadie decidido a saltársela.
  2. La validación de esquema en el servidor decide si la solicitud tiene siquiera la forma correcta antes de que un manejador la toque. Más adelante en esta sección esto se convierte en Zod, una librería para describir la forma que esperas y verificar los datos contra ella.
  3. La salida segura escapa los valores al renderizarlos, para que el texto guardado siga siendo texto.
  4. Las consultas parametrizadas mantienen separadas la estructura de una consulta y los datos que se le envían.

Un ataque tiene que atravesar las cuatro capas. Un error en una es un bug; un error en una sin las demás capas es un incidente.

JunoNo hay una sola solución, hay varias capas Las capas son la versión de seguridad de no poner todos los huevos en la misma canasta.

De todas formas, alguna vez te vas a equivocar en una de ellas. Tener cuatro es lo que hace que equivocarte una vez sea algo de lo que puedas recuperarte.

JunoNo hay una sola solución, hay varias capas Las capas cumplen funciones distintas, así que ninguna puede sustituir a otra. La validación decide si aceptar un valor. El escape decide cómo renderizarlo de forma segura. La parametrización decide cómo enviarlo a una base de datos.

Un esquema que solo acepta nombres razonables no vuelve segura una consulta concatenada, y una consulta parametrizada no vuelve seguro ese mismo valor dentro de una página.

La segunda capa vale la pena resolverla con una librería en vez de con verificaciones hechas a mano. Un esquema te da una sola fuente de verdad en lugar de reglas dispersas entre atributos de HTML y código del manejador.

Además obtienes mensajes de error que tú controlas en vez de los genéricos del navegador, espacio para reglas de negocio que el navegador no tiene forma de expresar, y la misma definición utilizable tanto en el servidor como en la página.

JunoNo hay una sola solución, hay varias capas Dónde se ubica una capa importa tanto como si existe. La validación pertenece al límite del sistema, antes de la lógica de negocio, para que nada río abajo tenga que preguntarse qué recibió. El escape pertenece a la salida, porque solo el renderizador conoce el contexto de destino.

Escapar en la entrada es el error clásico. Guarda datos deformados, se rompe en cuanto aparece un segundo destino, y termina dándote una base de datos llena de &amp;amp; que nadie puede deshacer con seguridad. Valida al entrar, escapa al salir.

Se usan tres palabras como si significaran lo mismo, y en realidad nombran decisiones distintas:

  • Validación pregunta si un valor es aceptable y lo rechaza si no lo es. Una edad de -4 falla.
  • Sanitización edita el valor para quitarle las partes no deseadas, y conserva lo que queda.
  • Escape deja el valor intacto y lo codifica para un destino específico, convirtiendo < en &amp;lt; al insertarlo en HTML.

Recurre primero a la validación. Rechazar un valor incorrecto es más fácil de razonar que repararlo, y la sanitización es el último recurso: cada filtro que elimina contenido peligroso es, en el fondo, una suposición sobre qué significa "peligroso".

Ponlo a prueba

Aquí está de nuevo el manejador de registro:

Vulnerable
ts
router.post('/api/register-vulnerable', (req, res) => {
  const { name, email, password } = req.body

  mockDb.users.push({ name, email, password })

  res.json({ success: true, user: { name, email } })
})

Responde tres preguntas sobre él:

  1. ¿Qué asume sobre los valores en req.body?
  2. ¿Cuáles de esas suposiciones seguirían siendo válidas si la solicitud viniera de curl en lugar del formulario?
  3. ¿A dónde viaja cada campo después de que el manejador lo recibe?
Compara tus respuestas

1. Qué asume. Cinco cosas, ninguna verificada:

  • Los tres campos llegaron.
  • Cada uno es una cadena de texto.
  • Ninguno es exageradamente largo.
  • email es realmente un correo electrónico.
  • Ninguno será interpretado como una instrucción por lo que sea que lo procese después.

Cada una de esas es solo una esperanza.

2. Qué sobrevive a curl. Ninguna. Lo único que hacía cumplir cualquiera de esas suposiciones era el propio marcado del formulario, y una solicitud enviada directamente al endpoint nunca pasa por el formulario. El manejador se comporta igual en ambos casos, porque no tiene forma de notar la diferencia.

3. A dónde viajan los campos. name y email van al almacenamiento, y luego regresan en la respuesta, que el front end renderiza en el mensaje de éxito. Con un solo envío, cada valor ya llegó tanto a una página como a un almacén de datos.

password va al almacenamiento tal como se escribió, en texto plano, lo cual es un bug en sí mismo: debería estar cifrada (hasheada) y nunca guardarse de forma legible. Si cambias el almacén simulado por una base de datos real, las tres también terminan en una consulta.

Esas suposiciones son justo las que van a romper los próximos tres capítulos.

Hacia dónde va esto ahora

El formulario está listo, el navegador ya no finge protegerlo, y en este momento cada valor que acepta es de confianza.

Cross-site scripting empieza la demolición, con un campo de nombre que ejecuta código en el navegador de otra persona.