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

Construindo um endpoint validado

O middleware já está escrito e a linguagem do schema já é familiar. O que falta é a rota onde os dois se encontram.

O formulário de cadastro vem enviando dados para /api/register-vulnerable desde o início desta seção, um endpoint que não faz nenhuma verificação. Este capítulo constrói o endpoint que vai substituí-lo.

Ligando o middleware a uma rota

O Express recebe o middleware entre o caminho e o 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 } })
  },
)

Três posições: o caminho, a verificação, o handler. O handler só roda depois que todo middleware anterior a ele tiver terminado com sucesso, então uma requisição que falha na validação nunca chega até ele.

Aponte o formulário para o novo caminho e o endpoint antigo sai de cena:

ts
// front-end/app.ts
const response = await fetch('/api/register', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(formData),
})
JunoLigando o middleware a uma rota Ler a definição da rota já diz o que ela aceita, sem precisar abrir o handler.

É um detalhe pequeno que faz diferença. As regras ficam visíveis em um lugar só, e o handler fica livre para fazer só o trabalho de verdade.

JunoLigando o middleware a uma rota Construir a rota segura ao lado da vulnerável, e só depois trocar o formulário de destino, vale a pena adotar como hábito. O caminho antigo continua funcionando enquanto o novo é testado, então nada quebra no meio da mudança.

O passo que as pessoas esquecem é o último: apagar o endpoint antigo depois que o formulário já mudou de destino. Uma rota vulnerável sem uso ainda é uma rota ativa, e ela não para de responder só porque seu front-end parou de chamá-la.

JunoLigando o middleware a uma rota A ordem na cadeia é comportamento, não estilo. O middleware roda de cima para baixo, então uma validação colocada depois de uma checagem de autenticação só vê requisições que já provaram quem são.

O rate limiting geralmente deve vir primeiro, porque processar um corpo de requisição custa mais caro do que simplesmente contar uma requisição, e um atacante enviando payloads grandes e malformados estaria consumindo sua CPU de graça se a ordem fosse outra.

O 201 também vale a pena acertar: ele significa criado, e costuma vir acompanhado de um header Location apontando para o novo recurso.

O primeiro schema

Comece com dois campos, o suficiente para provar que a conexão funciona:

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'),
})

Envie o formulário vazio e as duas mensagens voltam no 400, prontas para a página exibir ao lado de cada campo. Envie um endereço real e uma senha longa o suficiente e o handler roda.

Esse é o ciclo funcionando de ponta a ponta. Tudo depois disso é só ir preenchendo o schema.

JunoO primeiro schema Dois campos é intencional. É pequeno o suficiente para você ver o caminho inteiro funcionando antes que tenha muita coisa para dar errado.

Deixe um campo rejeitando e um campo passando, depois adicione o resto. Depurar um schema de nove campos que nunca funcionou nem uma vez é uma tarde bem mais difícil.

JunoO primeiro schema Teste primeiro o caminho de falha, antes do caminho de sucesso. É tentador digitar um dado válido e ver um 201, mas isso só prova que o handler roda.

Enviar o formulário vazio deveria te dar um 400 com uma mensagem por campo. Se der um 201, o middleware não está conectado, e um envio válido não teria te dito nada sobre isso.

JunoO primeiro schema Um comprimento mínimo é o controle em que a maioria dos serviços para, e 8 é uma barra baixa. O comprimento é o fator isolado mais forte, então a orientação atual é 12 ou mais.

Mais útil ainda é checar contra listas de senhas conhecidas por vazamentos, porque "Senha123!" satisfaz qualquer regra de complexidade já escrita, enquanto regras de composição acabam empurrando as pessoas para substituições previsíveis e um post-it colado no monitor.

O armazenamento é a outra metade: faça hash com bcrypt ou argon2, nunca guarde a senha original.

Movendo o schema para fora

Um schema inline é aceitável para dois campos e passa a ser errado quando já são nove. Dê a ele um arquivo próprio:

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>

A rota importa os dois:

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

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

Agora o schema é reutilizável, o tipo sai dele automaticamente, e os dados do handler ficam tipados. Adicione um campo ao schema e RegisterInput ganha esse campo sem uma segunda edição.

JunoMovendo o schema para fora Mover o arquivo é a arrumação de sempre. A exportação do tipo é a parte que vale a pena notar.

Uma definição só agora faz dois trabalhos: verifica os dados enquanto o programa roda, e diz ao TypeScript qual é o formato enquanto você escreve o código. Um não pode se desalinhar do outro, porque só existe uma definição.

JunoMovendo o schema para fora Uma pasta de schemas vira o lugar certo para consultar o que sua API aceita, o que é útil bem além da validação. É a resposta honesta para "o que esse endpoint recebe", e ela continua honesta porque é o próprio código fazendo a verificação.

Mantenha schemas relacionados juntos e componha uns com os outros. Um loginSchema pode pegar campos emprestados de um schema de usuário em vez de repeti-los, e as regras ficam concentradas em um lugar só.

JunoMovendo o schema para fora Como o schema é um módulo comum, o front-end pode importar o mesmo arquivo e checar o formulário antes de enviar. Uma definição, dois lugares, sem desalinhamento, e a verificação do servidor continua sendo a que vale de verdade.

Leve o tipo para além do handler. Dê às funções de serviço RegisterInput como tipo do parâmetro, e o compilador impede que alguém as chame com um objeto não validado, transformando "sempre valide primeiro" em algo que o build obriga.

Um cuidado: z.infer te dá o tipo de saída, então, com coerção no schema, ele descreve o formato pós-parse. É esse mesmo que você quer, e é mais um motivo para os handlers nunca recorrerem a req.body.

Fazendo o schema crescer

Agora os campos de verdade, cada um dizendo o que aceita e o que faz com o 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(),
})

Quatro coisas estão acontecendo ao longo desses campos.

Toda string tem um máximo. Isso resolve o problema de entrada superdimensionada de denial of service em uma linha por campo.

age sofre coerção. Formulários HTML enviam strings, então z.coerce.number() converte antes de verificar, e .int() rejeita 21.5.

email é normalizado antes de ser validado. .trim() e .toLowerCase() rodam primeiro, depois .pipe() passa o valor já limpo para a verificação de e-mail.

bio é opcional. Deixar de fora tudo bem; passar 600 caracteres, não.

A ordem importa em uma cadeia

z.email().trim() valida primeiro e só depois corta os espaços, então um endereço enviado com um espaço perdido no início falha antes que o .trim() possa ajudar. Verificado no Zod 4.5.4: ' [email protected] ' é rejeitado. Limpe o valor primeiro e valide o resultado, que é para isso que serve o .pipe().

JunoFazendo o schema crescer Leia um campo de cada vez e cada linha é uma regra pequena e simples. Um nome é texto, sem espaços perdidos, entre 2 e 50 caracteres, usando só os caracteres que nomes costumam usar.

É essa a vantagem de descrever dados dessa forma. Nove campos de regras, e você ainda consegue conferir qualquer uma delas lendo uma frase.

JunoFazendo o schema crescer Normalizar no schema é o que mantém o armazenamento consistente. Sem toLowerCase, [email protected] e [email protected] são duas contas diferentes, e você só descobre isso quando alguém não consegue fazer login.

Tenha cuidado com regex em nome. O padrão usado aqui rejeita todo nome escrito fora do alfabeto latino, e vários escritos dentro dele também. Um limite de comprimento costuma ser o controle certo, e se você precisar de uma checagem de caracteres, decida com cuidado quais alfabetos ela vai excluir.

JunoFazendo o schema crescer O regex da senha é o que vale a pena questionar. Restringir a letras, números e underscores bloqueia espaços e símbolos, descartando frases-senha e qualquer coisa gerada por um gerenciador de senhas, sem impedir nada que um atacante se importe: o valor passa por hash e nunca é interpretado.

Uma lista de caracteres permitidos em uma senha costuma ser resquício de uma era de construção insegura de queries, e a solução correta ali era parametrização. Defina um máximo generoso e aceite todo o resto.

Esse máximo importa mais do que parece. O bcrypt trunca em 72 bytes, então sem um limite o final de uma frase-senha longa é silenciosamente ignorado, e o hashing é propositalmente lento, então um campo sem limite acaba mirando um ataque de negação de serviço contra sua própria CPU.

O que volta na resposta

O handler retorna só os campos que um cliente deveria 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 e bio são validados e não são retornados. Construir a resposta campo por campo é o que garante isso, porque um novo campo no schema não pode vazar para a resposta por acidente.

JunoO que volta na resposta Validar um campo e retornar um campo são decisões separadas.

A senha é conferida com cuidado e nunca é enviada de volta. Nomear cada campo na resposta é o que faz isso continuar valendo à medida que o schema cresce.

JunoO que volta na resposta O padrão a evitar é res.json({ user: userData }). Funciona hoje e transforma todo campo futuro do schema em um campo da resposta, então no dia em que alguém adicionar um campo para uso interno, ele vai sair para todo cliente.

Nomear os campos explicitamente é só algumas linhas a mais e uma forma a menos de vazar alguma coisa.

JunoO que volta na resposta A versão mais durável é um schema de saída: um segundo schema Zod descrevendo a resposta, analisado no caminho de volta. O que sua API retorna passa a estar descrito em um lugar só, verificado, e impossível de ampliar por acidente. Isso é um contrato que não pode se desalinhar do código.

Date.now() como id serve para um mock e está errado em produção: colide sob concorrência e vaza o horário de criação. Um UUID, o formato de identificador aleatório de 128 bits, ou uma sequência de banco de dados é a resposta de verdade.

Tente você mesmo

Este schema tem três problemas. Um rejeita entrada válida, um aceita entrada que não deveria, e um vaza dado.

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 })
})
Compare suas respostas

1. z.email().trim() rejeita entrada válida. O trim roda depois da checagem de e-mail, então um endereço colado com um espaço no final falha na validação antes de poder ser limpo. Verificado no Zod 4.5.4. Use z.string().trim().pipe(z.email()).

2. displayName não tem máximo, e age não passa por coerção. Dois problemas em uma linha cada. A falta de .max() significa que o campo aceita um valor de qualquer tamanho, o que é a negação de serviço de mais cedo nesta seção chegando através de um formulário. E z.number() rejeita a string que um formulário HTML realmente envia, então age precisa de z.coerce.number(), mais um .int() e um .max() já que você está mexendo nisso.

3. user: data retorna tudo. Todo campo que o schema valida volta para o cliente, incluindo qualquer um que for adicionado depois. Nomeie os campos que você pretende retornar.

Uma versão corrigida:

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 },
  })
})

Para onde isso vai a seguir

A seção começou com um formulário que confiava em tudo e termina com um que confia apenas no que já verificou. Nove campos descrevem o que o servidor aceita, o middleware os impõe antes de qualquer handler rodar, e a resposta diz só o que pretende dizer.

Os três ataques que abriram a seção precisavam, todos, de uma entrada que ninguém tinha examinado. Isso agora é tratado na fronteira, uma vez só, em um arquivo que você consegue ler.

Autenticação vs autorização começa a próxima pergunta. Um endpoint de cadastro cria uma conta, e o app não tem a menor ideia de quem é quem nas requisições depois disso.