Programação Por Equipe DevBras

Capa do artigo: Redirect que obedece ao parâmetro next manda a pessoa para outro site

Redirect que obedece ao parâmetro next manda a pessoa para outro site

Valide o destino do redirect pós-login com o parser de URL e comparação de origem, para que o parâmetro next nunca leve a pessoa a um site falso usando o seu domínio.

Chega um e-mail com um link para o seu próprio domínio: https://app.exemplo.com.br/login?next=https://app-exemplo.example/confirmar. A pessoa confere o endereço, vê que é o seu site, faz login de verdade e, logo depois, cai numa página idêntica pedindo para digitar a senha de novo "por segurança". Quem fez o redirecionamento foi o seu servidor. A resposta curta: depois do login, só redirecione para caminhos do seu próprio site. Interprete o parâmetro com um parser de URL, compare a origem resultante com a sua e, se não bater, mande para uma página padrão.

Isso tem nome: redirecionamento aberto. Parece inofensivo, porque nenhum dado do seu banco vaza. O dano está na confiança. O seu domínio vira trampolim para phishing, e filtros de e-mail que confiam no seu domínio deixam o link passar.

Por que o redirect aberto acontece

O parâmetro next, returnUrl ou redirect existe por um bom motivo: a pessoa tentou abrir /pedidos/42, foi mandada para o login e deve voltar para lá depois. O código mais simples possível lê o parâmetro e devolve um 302 com ele no cabeçalho Location. Funciona no teste, porque no teste o parâmetro sempre é um caminho interno.

O navegador, porém, não sabe o que você quis dizer. Ele segue o Location como está. Se vier um endereço absoluto, ele vai para outro domínio. E há formas menos óbvias de escrever um endereço externo: //site.example é um endereço relativo ao protocolo, que leva a outro host. Uma barra invertida logo depois da primeira barra é tratada pelos navegadores como barra comum, então /\site.example também sai do seu domínio. Endereços com esquema javascript: podem executar código se forem parar num link.

Por isso, validar com startsWith ou expressão regular costuma falhar. startsWith('https://app.exemplo.com.br') aceita https://app.exemplo.com.br.site.example e https://app.exemplo.com.br@site.example, que levam a outro host. O jeito confiável é deixar o mesmo algoritmo de parsing que o navegador usa decidir qual é o host, e comparar o resultado.

Como validar o destino do redirect

  1. Defina a origem do seu app como constante: esquema, host e porta.
  2. Interprete o parâmetro com new URL(valor, origem). Caminhos relativos são resolvidos contra a sua origem; endereços absolutos mantêm o host deles.
  3. Compare url.origin com a sua origem. Se forem diferentes, ou se o parsing falhar, use o destino padrão.
  4. Redirecione apenas para caminho, query e fragmento do resultado, nunca para o valor original.
  5. Se precisar mandar para domínios externos, mantenha uma lista fixa de hosts permitidos e compare o host exato.
  6. Prefira guardar o destino na sessão do servidor antes de mandar para o login, em vez de carregá-lo na URL.
const ORIGEM = new URL('https://app.exemplo.com.br');
const PADRAO = '/painel';

function destinoSeguro(valor) {
  if (typeof valor !== 'string' || valor === '') return PADRAO;
  let url;
  try {
    url = new URL(valor, ORIGEM);
  } catch (erro) {
    return PADRAO;
  }
  if (url.origin !== ORIGEM.origin) return PADRAO;
  return url.pathname + url.search + url.hash;
}

app.post('/login', express.urlencoded({ extended: false }), async (req, res) => {
  const usuario = await autenticar(req.body.email, req.body.senha);
  if (!usuario) return res.status(401).send('Não foi possível entrar.');
  // ... cria a sessão ...
  res.redirect(303, destinoSeguro(req.query.next));
});

Como essa função se comporta com entradas reais:

destinoSeguro('/pedidos/42?aba=itens')               // '/pedidos/42?aba=itens'
destinoSeguro('https://site.example/login')          // '/painel'
destinoSeguro('//site.example')                      // '/painel'
destinoSeguro('/\\site.example')                     // '/painel'
destinoSeguro('https://app.exemplo.com.br@site.example') // '/painel'
destinoSeguro('javascript:alert(1)')                 // '/painel'

O caso do javascript: cai porque a origem de um endereço com esse esquema é a string "null", diferente da sua. O caso da barra invertida cai porque o parser WHATWG, o mesmo do navegador, trata a barra invertida como barra em endereços http e https. O 303 diz ao navegador para fazer GET no destino depois do POST de login; a diferença entre os códigos está em códigos de status HTTP.

Quando o destino externo é legítimo

Às vezes o produto precisa mandar a pessoa para fora: o checkout do meio de pagamento, a central de ajuda em outro domínio. Nesses casos, não aceite o endereço inteiro do parâmetro. Aceite um identificador, como destino=ajuda, e traduza no servidor para uma URL fixa. Se a lista de hosts precisar ser dinâmica, compare url.hostname com igualdade exata, nunca com endsWith, que aceita siteexemplo.com.br no lugar de exemplo.com.br.

Guardar o destino na sessão em vez da URL

A forma mais segura de todas é nem colocar o destino na URL. Quando o middleware de autenticação barra um pedido para /pedidos/42, ele grava esse caminho na sessão do servidor e manda a pessoa para /login sem parâmetro. Depois do login, o servidor lê o caminho da sessão, apaga e redireciona. Como o valor foi gravado pelo seu próprio código a partir da rota que ele mesmo atendeu, ninguém de fora consegue montar um link que o altere. Ainda assim, passe o valor pela mesma função de validação: custa uma linha e protege contra o dia em que alguém mudar a forma de gravar.

O mesmo princípio aparece no login com provedor externo: lá, quem valida o retorno é o provedor, com a lista de URLs cadastradas, como em login com PKCE. Aqui, quem valida é você.

Erros comuns

  • Redirecionar para o valor do parâmetro sem validar. O seu domínio vira trampolim de phishing com aparência legítima.
  • Validar com startsWith do seu domínio. Subdomínios falsos e o truque do @ passam pela checagem.
  • Aceitar qualquer coisa que comece com barra. //site.example e /\site.example começam com barra e saem do site.
  • Usar endsWith para lista de domínios. Um domínio registrado com o seu nome no final é aceito.
  • Corrigir só o login. Logout, troca de idioma e confirmação de e-mail costumam ter o mesmo parâmetro e o mesmo buraco.
  • Colocar o destino num link sem validar. Um href com javascript: executa código no clique.

Checklist

  • Todo redirect que usa parâmetro da requisição passa por uma única função de validação.
  • A validação usa parser de URL e compara origem, não texto.
  • Os seis casos de teste acima caem no destino padrão.
  • Destinos externos vêm de uma lista fixa, por identificador.
  • Login, logout, troca de idioma e confirmação de e-mail foram revisados.
  • Existe teste automatizado com um next de outro domínio.

Abra o login do seu sistema com ?next=https://example.com no final do endereço e faça login. Se a barra de endereço terminar em example.com, o buraco está aberto. E a pergunta para depois: quantas outras rotas do seu sistema leem um parâmetro e devolvem um redirect com ele?

Avalie este artigo

Referência: https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html