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

Construyendo un endpoint validado

El middleware ya está escrito y el lenguaje de los schemas ya te resulta familiar. Lo que falta es la ruta donde ambos se encuentran.

El formulario de registro ha estado enviando datos a /api/register-vulnerable desde el inicio de esta sección, un endpoint que se salta cualquier verificación. Este capítulo construye el que lo reemplaza.

Conectando el middleware a una ruta

Express recibe el middleware entre la ruta y el handler:

ts
import { validate } from '../middleware/validate.js'
import { registerSchema } from '../schemas/userSchema.js'

router.post(
  '/api/register',
  validate(registerSchema),
  (req, res) => {
    const userData = (req as any).validatedData

    res.status(201).json({ success: true, user: { email: userData.email } })
  },
)

Tres piezas: la ruta, la verificación y el handler. El handler solo se ejecuta después de que todo el middleware anterior haya terminado con éxito, así que una solicitud que falla la validación nunca llega a él.

Apunta el formulario a la nueva ruta y el endpoint anterior queda fuera de juego:

ts
// front-end/app.ts
const response = await fetch('/api/register', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(formData),
})
JunoConectando el middleware a una ruta Leer la definición de la ruta te dice qué acepta, sin necesidad de abrir el handler.

Es un detalle pequeño que se acumula. Las reglas viven en un lugar visible, y el handler queda dedicado únicamente al trabajo real.

JunoConectando el middleware a una ruta Construir la ruta segura al lado de la vulnerable, y luego cambiar el formulario, vale la pena adoptarlo como hábito. La ruta antigua sigue funcionando mientras se prueba la nueva, así que nada se rompe a mitad del cambio.

El paso que la gente olvida es el último: borrar el endpoint antiguo una vez que el formulario se movió. Una ruta vulnerable sin uso sigue siendo una ruta activa, y no deja de responder solo porque tu front end dejó de llamarla.

JunoConectando el middleware a una ruta El orden en la cadena es comportamiento, no estilo. El middleware se ejecuta de arriba hacia abajo, así que una validación colocada después de una verificación de autenticación solo ve solicitudes que ya demostraron quién las hizo.

El límite de tasa (rate limiting) suele ir primero, porque parsear un body cuesta más que contar una solicitud, y un atacante que envía payloads grandes y malformados de otro modo estaría comprando tu CPU muy barato.

El código 201 también vale la pena entenderlo bien: significa "creado", y va acompañado de un header Location que apunta al nuevo recurso.

El primer schema

Empieza con dos campos, suficientes para probar la conexión:

ts
import * as z from 'zod'

const registerSchema = z.object({
  email: z.email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})

Envía el formulario vacío y ambos mensajes vuelven en el 400, listos para que la página los muestre junto a cada campo. Envía una dirección real y una contraseña suficientemente larga y el handler se ejecuta.

Ese es el ciclo funcionando de principio a fin. Todo lo que sigue es ir completando el schema.

JunoEl primer schema Empezar con dos campos es intencional. Es lo suficientemente pequeño como para ver todo el flujo funcionando antes de que haya mucho que pueda fallar.

Logra que un campo se rechace y otro pase, y luego agrega el resto. Depurar un schema de nueve campos que nunca ha funcionado ni una vez es una tarde mucho peor.

JunoEl primer schema Prueba primero el camino que falla, antes que el exitoso. Es tentador escribir datos válidos y ver un 201, pero eso solo demuestra que el handler se ejecuta.

Enviar el formulario vacío debería darte un 400 con un mensaje por cada campo. Si en cambio te da un 201, el middleware no está conectado, y un envío válido no te lo hubiera dicho de ninguna forma.

JunoEl primer schema Un mínimo de longitud es donde la mayoría de los servicios se quedan cortos, y 8 es un umbral bajo. La longitud es el factor individual más importante, así que la recomendación actual es 12 o más.

Más útil aún es verificar contra contraseñas conocidas por haber sido filtradas, porque "Password123!" cumple con cualquier regla de complejidad jamás escrita, mientras que las reglas de composición empujan a la gente hacia sustituciones predecibles y una nota adhesiva en el monitor.

El almacenamiento es la otra mitad del problema: hashea con bcrypt o argon2, nunca guardes la contraseña original.

Sacando el schema a su propio archivo

Un schema en línea está bien para dos campos y deja de estarlo cuando llegas a nueve. Dale su propio archivo:

ts
// back-end/schemas/userSchema.ts
import * as z from 'zod'

export const registerSchema = z.object({
  email: z.email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})

export type RegisterInput = z.infer<typeof registerSchema>

La ruta importa ambas cosas:

ts
import { registerSchema, type RegisterInput } from '../schemas/userSchema.js'

const userData: RegisterInput = (req as any).validatedData

Ahora el schema es reutilizable, el tipo se obtiene de él automáticamente, y los datos del handler quedan tipados. Agrega un campo al schema y RegisterInput lo incorpora sin necesidad de una segunda edición.

JunoSacando el schema a su propio archivo Mover el archivo es el tipo de orden habitual. El export del tipo es la parte que vale la pena notar.

Ahora una sola definición hace dos trabajos: verifica los datos mientras el programa corre, y le indica a TypeScript la forma de los datos mientras escribes código. Ninguno de los dos puede desalinearse del otro, porque solo existe uno.

JunoSacando el schema a su propio archivo Una carpeta de schemas se convierte en el lugar donde buscar qué acepta tu API, algo útil mucho más allá de la validación. Es la respuesta honesta a "¿qué recibe este endpoint?", y se mantiene honesta porque es el mismo código el que hace la verificación.

Mantén los schemas relacionados juntos y combínalos entre sí. Un loginSchema puede tomar campos de un schema de usuario en lugar de repetirlos, y las reglas quedan en un solo lugar.

JunoSacando el schema a su propio archivo Como el schema es un módulo común y corriente, el front end puede importar el mismo archivo y verificar el formulario antes de enviarlo. Una sola definición, usada en dos lugares, sin desalineación, y la verificación del servidor sigue siendo la que realmente cuenta.

Lleva el tipo más allá del handler. Dale a las funciones de servicio RegisterInput como tipo de parámetro y el compilador impide que alguien las llame con un objeto sin validar, convirtiendo el "siempre valida primero" en algo que el build exige.

Una advertencia: z.infer da el tipo de salida, así que si el schema hace coerción, describe la forma después del parseo. Esa es justamente la que necesitas, y es otra razón más por la que los handlers nunca deberían recurrir a req.body.

Haciendo crecer el schema

Ahora los campos reales, cada uno indicando qué acepta y qué le hace al valor:

ts
export const registerSchema = z.object({
  name: z
    .string('Name is required')
    .trim()
    .min(2, 'Name must be at least 2 characters')
    .max(50, 'Name must be 50 characters or fewer')
    .regex(/^[a-zA-Z\s\-'.]+$/, 'Letters, spaces, hyphens, apostrophes and periods only'),

  username: z
    .string('Username is required')
    .min(3, 'Username must be at least 3 characters')
    .max(20, 'Username must be 20 characters or fewer'),

  email: z
    .string('Email is required')
    .trim()
    .toLowerCase()
    .pipe(z.email('Enter a valid email address')),

  age: z.coerce
    .number('Age is required')
    .int('Age must be a whole number')
    .min(13, 'You must be at least 13')
    .max(120, 'Enter a real age'),

  password: z
    .string('Password is required')
    .min(8, 'Password must be at least 8 characters')
    .regex(/^[A-Za-z0-9_]+$/, 'Letters, numbers and underscores only'),

  bio: z.string().max(500, 'Bio must be 500 characters or fewer').optional(),
})

Hay cuatro cosas ocurriendo a lo largo de estos campos.

Todo string tiene un máximo. Eso cierra el problema de entrada sobredimensionada visto en denegación de servicio con una sola línea por campo.

age se convierte con coerción. Los formularios HTML envían strings, así que z.coerce.number() convierte antes de verificar, y .int() rechaza 21.5.

email se normaliza antes de validarse. .trim() y .toLowerCase() se ejecutan primero, y luego .pipe() le pasa el valor limpio a la verificación de email.

bio es opcional. Omitirlo está bien; darle 600 caracteres no.

El orden importa en una cadena

z.email().trim() valida primero y recorta espacios después, así que una dirección enviada con un espacio inicial de sobra falla antes de que el recorte pueda ayudarla. Verificado en Zod 4.5.4: ' [email protected] ' es rechazado. Limpia el valor primero y luego valida el resultado, que es justamente para lo que existe .pipe().

JunoHaciendo crecer el schema Lee un campo a la vez y cada línea es una regla pequeña y clara. Un nombre es texto, limpio de espacios de sobra, de entre 2 y 50 caracteres, usando solo los caracteres que se usan en nombres.

Ese es el atractivo de describir datos de esta forma. Nueve campos de reglas, y aun así puedes verificar cualquiera de ellos con solo leer una oración.

JunoHaciendo crecer el schema Normalizar en el schema es lo que mantiene el almacenamiento consistente. Sin toLowerCase, [email protected] y [email protected] son dos cuentas distintas, y te enteras cuando alguien no puede iniciar sesión.

Ten cuidado con una expresión regular sobre un nombre. El patrón de aquí rechaza cualquier nombre escrito fuera del alfabeto latino, y también varios escritos dentro de él. Un límite de longitud suele ser el control correcto, y si necesitas una verificación de caracteres, decide deliberadamente qué alfabetos excluye.

JunoHaciendo crecer el schema La expresión regular de la contraseña es la que vale la pena cuestionar. Restringir a letras, números y guiones bajos bloquea espacios y símbolos, descartando frases de contraseña y cualquier cosa que genere un gestor de contraseñas, sin lograr nada que le importe a un atacante: el valor se hashea y nunca se interpreta.

Una lista de caracteres permitidos en una contraseña suele ser un resabio de una época de construcción insegura de queries, y la solución para eso era la parametrización. Define un máximo generoso y acepta todo lo demás.

Ese máximo importa más de lo que parece. bcrypt trunca a los 72 bytes, así que sin un límite, el final de una frase de contraseña larga se ignora silenciosamente, y el hasheo es deliberadamente lento, así que un campo sin límite apunta una denegación de servicio hacia tu propia CPU.

Lo que se devuelve

El handler devuelve solo los campos que un cliente debería ver:

ts
const userData: RegisterInput = (req as any).validatedData

// Real work would go here: hash the password with bcrypt or argon2,
// store the user with parameterized queries, send a verification email.

res.status(201).json({
  success: true,
  user: {
    id: Date.now(),
    name: userData.name,
    username: userData.username,
    email: userData.email,
  },
})

password, age y bio se validan y no se devuelven. Construir la respuesta campo por campo es lo que mantiene esa garantía, porque un nuevo campo en el schema no puede filtrarse en la respuesta por accidente.

JunoLo que se devuelve Validar un campo y devolver un campo son decisiones separadas.

La contraseña se verifica cuidadosamente y nunca se envía de vuelta. Nombrar cada campo en la respuesta es lo que hace que eso se mantenga así a medida que el schema crece.

JunoLo que se devuelve El patrón que hay que evitar es res.json({ user: userData }). Funciona hoy y convierte cualquier futuro campo del schema en un campo de la respuesta, así que el día que alguien agregue un campo para uso interno, terminará enviándose a todos los clientes.

Nombrar los campos explícitamente son unas líneas más y una forma menos de filtrar algo.

JunoLo que se devuelve La versión más duradera es un schema de salida: un segundo schema de Zod que describe la respuesta, parseado al salir. Lo que tu API devuelve queda entonces descrito en un solo lugar, verificado, e imposible de ampliar por accidente. Eso es un contrato que no puede desalinearse del código.

Date.now() como id está bien para una simulación y está mal en producción: colisiona bajo concurrencia y filtra el momento de creación. Un UUID, el formato de identificador aleatorio de 128 bits, o una secuencia de base de datos son la respuesta real.

Ponlo a prueba

Este schema tiene tres problemas. Uno rechaza entradas válidas, otro acepta entradas que no debería, y otro filtra información.

ts
export const profileSchema = z.object({
  email: z.email().trim(),
  displayName: z.string().min(2),
  age: z.number().min(13),
})

router.post('/api/profile', validate(profileSchema), (req, res) => {
  const data = (req as any).validatedData
  res.status(201).json({ success: true, user: data })
})
Compara tus respuestas

1. z.email().trim() rechaza entradas válidas. El recorte se ejecuta después de la verificación de email, así que una dirección pegada con un espacio final falla la validación antes de poder limpiarse. Verificado en Zod 4.5.4. Usa z.string().trim().pipe(z.email()).

2. displayName no tiene un máximo, y age no tiene coerción. Dos problemas, uno en cada línea. La falta de .max() significa que el campo acepta un valor de cualquier longitud, que es exactamente la denegación de servicio vista antes en esta sección, ahora llegando a través de un formulario. Y z.number() rechaza el string que un formulario HTML realmente envía, así que age necesita z.coerce.number(), además de .int() y un .max() ya que estamos en eso.

3. user: data devuelve todo. Cada campo que el schema valida vuelve al cliente, incluyendo cualquiera que se agregue después. Nombra explícitamente los campos que quieres devolver.

Una versión corregida:

ts
export const profileSchema = z.object({
  email: z.string().trim().toLowerCase().pipe(z.email('Enter a valid email address')),
  displayName: z.string().trim().min(2).max(50),
  age: z.coerce.number().int().min(13).max(120),
})

router.post('/api/profile', validate(profileSchema), (req, res) => {
  const data = (req as any).validatedData
  res.status(201).json({
    success: true,
    user: { displayName: data.displayName, email: data.email },
  })
})

Hacia dónde va esto

La sección comenzó con un formulario que confiaba en todo y termina con uno que confía solo en lo que ha verificado. Nueve campos describen qué acepta el servidor, el middleware los impone antes de que se ejecute cualquier handler, y la respuesta dice solo lo que se propone decir.

Los tres ataques que abrieron la sección necesitaban una entrada que nadie hubiera revisado. Eso ahora se maneja en el límite de entrada, una sola vez, en un archivo que puedes leer.

Autenticación vs. autorización da inicio a la siguiente pregunta. Un endpoint de registro crea una cuenta, y después de eso la aplicación no tiene idea de quién es quién en la solicitud.