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.
| Parte | Qué hace |
|---|---|
front-end/index.html | El marcado del formulario: inputs, un botón de envío, contenedores para mensajes de éxito y de error. |
front-end/app.ts | Lee los inputs, envía la información al back end y muestra el resultado. |
front-end/types.ts | Refleja los tipos del back end para que el cliente conozca la forma de una respuesta. |
back-end/server.ts | Inicia Express, parsea JSON, monta las rutas y escucha en el puerto 3000. |
back-end/routes/auth.ts | El endpoint /api/register-vulnerable. Se salta la validación por completo y devuelve la entrada tal cual. |
back-end/state/mockDb.ts | Un 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:
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.
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.
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:
<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.
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.
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 entrada | Qué puede salir mal | Capítulo |
|---|---|---|
| Renderizada en una página | Un script inyectado por una persona se ejecuta en el navegador de otra | Cross-site scripting |
| Consumiendo recursos del servidor | Una solicitud demasiado grande o manipulada agota memoria, almacenamiento o conexiones | Denegación de servicio |
| Concatenada en una consulta | La entrada se convierte en SQL y lee, cambia o elimina datos | Inyecció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.
¿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.
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:
- La validación del navegador detecta errores de tipeo y ahorra una ida y vuelta. No detiene a nadie decidido a saltársela.
- 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.
- La salida segura escapa los valores al renderizarlos, para que el texto guardado siga siendo texto.
- 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.
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.
Ponlo a prueba
Aquí está de nuevo el manejador de registro:
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:
- ¿Qué asume sobre los valores en
req.body? - ¿Cuáles de esas suposiciones seguirían siendo válidas si la solicitud viniera de
curlen lugar del formulario? - ¿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.
emailes 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.

