Artigo

Express.js Open Redirect

Como uma barra invertida em uma URL bypassa allowlists e engana o browser. Análise completa do CVE-2024-29041 no Express.js com demo interativo.

O que é Open Redirect?

Toda vez que você clica em um link e o servidor envia o seu browser para uma URL diferente da original, aconteceu um redirecionamento HTTP. O servidor diz: “não fique aqui, vá para lá”, e o browser obedece. Isso é completamente normal e útil.

O problema começa quando o destino do redirecionamento vem de uma entrada do usuário sem nenhuma verificação. Nesse caso, um atacante pode colocar qualquer coisa como destino, inclusive o site de phishing dele.

Por que isso é perigoso na prática?

A vítima recebe um e-mail com: “Clique aqui para acessar sua conta”. O link é banco.com.br/redirect?para=.... Como o domínio banco.com.br parece legítimo, ela não vai revisar o link inteiro e clica, o servidor a envia para um site de phishing idêntico. Filtros de e-mail, antivírus e o próprio instinto humano são enganados porque a origem do link é genuína.

Um exemplo clássico: o usuário tenta acessar /configuracoes sem estar logado. O sistema salva essa URL e redireciona para ela após o login. O perigo aparece quando a URL de destino vem de um parâmetro externo sem validação:

// Após o login, redirecionar para onde o usuário queria ir
app.get('/apos-login', (req, res) => {
  const destino = req.query.proximo;

  // ❌ Sem nenhuma verificação — o atacante controla 'proximo'
  // URL de ataque: /apos-login?proximo=http://phishing.com
  res.redirect(destino);
});

A solução óbvia é criar uma allowlist, uma lista de domínios permitidos como destino. O CVE-2024-29041 demonstra exatamente como uma URL malformada consegue passar por essa allowlist mesmo quando ela está corretamente implementada.

Como o Express trata redirects internamente

Para entender a falha, precisamos entender o que o Express faz por baixo dos panos quando você chama res.redirect().

  1. Você chama res.redirect(url): O Express recebe a URL de destino. Pode ser uma URL completa (http://...) ou um path relativo (/dashboard).
  2. Internamente ele faz res.location(url): res.redirect() é um wrapper que define o header Location: url e envia a resposta com status 302.
  3. res.location() aplica encodeUrl(url): Antes de colocar a URL no header, o Express passa por uma função de encoding. Isso garante que caracteres especiais não quebrem o header HTTP, mas é aqui que o problema nasce.
  4. Header Location é enviado: O browser lê esse header e navega para a URL indicada. A navegação final é feita pelo browser e ele pode interpretar a URL de forma diferente do EXpress e do Node.js.

O servidor Node.js verifica e transforma a URL de um jeito. O browser que vai navegar pode interpretar a mesma string de forma diferente, especialmente para URLs malformadas. É nessa diferença que vive a vulnerabilidade.

A função encodeUrl() converte caracteres especiais para o formato %XX aceito em headers HTTP. Um espaço vira %20, uma barra invertida \ vira %5C, e assim por diante:

const encodeUrl = require('encodeurl');

encodeUrl('http://google.com/busca com espaço')
// → 'http://google.com/busca%20com%20espa%C3%A7o'  ✓

encodeUrl('http://google.com\@evil.com')
// → 'http://google.com%5C@evil.com'
//                      ^^^
//                      \ virou %5C — isso vai causar o problema

A vulnerabilidade em detalhe

Anatomia de uma URL

http://
protocol
usuario:senha@
userinfo credenciais
google.com
host domínio
:8080
porta
/caminho
path
?q=busca
query
#ancora
hash

Na URL http://google.com@evil.com, quem é o host real? É evil.com. O google.com antes do @ é tratado como nome de usuário (campo userinfo). Browsers modernos ignoram esse campo de credencial na navegação e vão direto para o host evil.com.

O papel da barra invertida \

URLs usam a barra normal /. A barra invertida \ é tecnicamente inválida em URLs. Mas navegadores modernos seguem a especificação WHATWG, que diz:

em URLs HTTP/HTTPS, trate \ como se fosse /. Isso foi feito para ser mais tolerante com URLs malformadas no cotidiano da web.

Essa tolerância cria uma diferença importante entre o Node.js legado e o browser:

const urlLegado = require('url');

const maliciosa = 'http://google.com\\@evil.com';

// url.parse() parser legado do Node.js (não normaliza \)
urlLegado.parse(maliciosa).hostname;
// → 'google.com'   ← trata \ como parte do path

// new URL() parser moderno WHATWG (mesma spec que browsers usam)
new URL(maliciosa).hostname;
// → 'evil.com'     ← normaliza \ como /, então @evil.com é o host!

Um desenvolvedor que usa url.parse() para validar pensa que está protegido. Mas o browser, que usa WHATWG, vai para um lugar diferente do que o servidor verificou.

url.parse() está oficialmente depreciado

O Node.js marcou url.parse() como depreciado (DEP0169, Node.js ≥ 21) exatamente por esse tipo de comportamento imprevisível com URLs malformadas. A substituta oficial é a API WHATWG URL, disponível globalmente desde o Node.js 10, que usa a mesma especificação que browsers modernos implementam:

Como o encodeUrl() fecha o ciclo do ataque

// PASSO 1: atacante envia a URL maliciosa
const input = 'http://google.com\@evil.com';

// PASSO 2: allowlist com url.parse deixa passar
require('url').parse(input).hostname;
// → 'google.com' ← está na allowlist!

// PASSO 3: Express aplica encodeUrl
encodeUrl(input); // → 'http://google.com%5C@evil.com'
//                                      ^^^
//                                      \ virou %5C

// PASSO 4: header enviado ao browser
// Location: http://google.com%5C@evil.com

// PASSO 5: browser processa o header
// Decodifica: %5C → \
// Normaliza:  \ → / (spec WHATWG)
// Interpreta: http://google.com/@evil.com
// Host real:  evil.com  ← o browser vai AQUI!

Criando o exploit

Ambiente vulnerável

# Criar projeto isolado com versão vulnerável
mkdir express-cve-lab && cd express-cve-lab
npm init -y
npm install express@4.19.0   # qualquer versão <4.19.0 é vulnerável
// server.js — Express vulnerável com allowlist
const express   = require('express');
const urlParser = require('url');
const app       = express();

const PERMITIDOS = ['google.com', 'meuapp.com.br'];

app.get('/redirect', (req, res) => {
  const destino = req.query.url;
  const host = urlParser.parse(destino).hostname;

  if (!PERMITIDOS.includes(host)) {
    return res.status(403).send('Host não permitido: ' + host);
  }

  res.redirect(destino); // Express chama encodeUrl() aqui internamente
});

app.listen(3000, () => console.log('http://localhost:3000'));

O exploit

# Tentativa direta — BLOQUEADA corretamente
curl -v "http://localhost:3000/redirect?url=http://evil.com"
# ← 403: "Host não permitido: evil.com"  ✓

# Exploit — BYPASSA a allowlist
# %5C é o \ codificado para colocar no query string
curl -v "http://localhost:3000/redirect?url=http://google.com%5C@evil.com"
# ← 302 Found
# ← Location: http://google.com%5C@evil.com   ← browser vai para evil.com!

Demo interativo

Simule o fluxo completo do input do atacante até o que o browser receber e para onde ele navega. Escolha um payload pronto ou escreva o seu.

Pipeline de processamento — CVE-2024-29041 MODERATE 6.1
Payloads prontos

Patch 1: a primeira tentativa de correção

Após o reporte da vulnerabilidade, a equipe do Express lançou a versão 4.19.1 com o commit 0867302. A ideia: “se o problema é que encodeUrl() muda o host, então verificamos o host antes e depois de encodar, se mudou, algo estava errado com a URL.”

lib/response.js — commit 0867302 (express@4.19.1) 4.19.1
34 var mime = send.mime;
...
911 res.location = function location(url) {
912 // ... (lógica do 'back') ...
+ lowerLoc.indexOf('http://') === 0) {
925 + }
930 + return this.set('Location', loc);
931 + }
932 + }

clique em uma linha com · para ver a anotação

Por que veio o Patch 2?

Após o 4.19.1, a equipe percebeu que a abordagem tinha uma fraqueza conceitual: ela usava url.parse(), o mesmo parser problemático que causou o bug, para tentar detectar o bug.

Timeline das três versões

  1. express@4.19.0 — Mar 2024

    Primeira tentativa de fix, regressão identificada: o encoding de URLs legítimas com caracteres especiais no path parou de funcionar corretamente.

  2. express@4.19.1 — Mar 2024

    Correção da regressão, commit 0867302 (Patch 1). Mecanismo de comparação de hosts via url.parse() permanece.

  3. Falha conceitual identificada

    Equipe identifica que a comparação via url.parse() não é confiável o suficiente. A solução certa não é "detectar quando o encoding deu errado", é nunca encodar a parte que não deve ser encodada.

  4. express@4.19.2 — Mar 2024

    Patch 2 abordagem completamente diferente, commit 0b74695. Remove toda a lógica de comparação de hosts. Usa regex para localizar onde o host termina e só aplica encodeUrl() a partir daí.

Patch 2 — a solução definitiva

O commit 0b74695 substituiu completamente a lógica do Patch 1. Em vez de tentar detectar problemas pós-encoding, o patch divide a URL em duas partes e só encoda o que é seguro encodar.

lib/response.js — commit 0b74695 (express@4.19.2) 4.19.2
...
+ /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?\/\/[^\\\/\?]+/;
...
@@ res.location @@
910 if (url === 'back') {
911 loc = this.req.get('Referrer') || '/';
913 + } else {
915 + }
@@ nova lógica de encoding @@

clique em uma linha com · para ver a anotação

Entendendo a regex em detalhe

var re = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?\/\/[^\\\/\?]+/;

// Lendo peça por peça:
// ^                        — começa do início
// (?:[a-zA-Z][...]*:)?     — schema opcional: "http:" ou "https:" etc.
// \/\/                     — as duas barras "//"
// [^\\ \/ \?]+              — HOST: qualquer coisa que NÃO seja \, / ou ?
//                            a regex PARA no primeiro caractere problemático!

// Testando com payload malicioso:
re.exec('http://google.com\\@evil.com/path')
// m[0] = 'http://google.com'  ← para ANTES da barra invertida!
// pos  = 17
//
// loc.slice(0, 17)  = 'http://google.com'    ← host PRESERVADO
// loc.slice(17)     = '\\@evil.com/path'     ← isso é encodado
// encodeUrl(slice)  = '%5C@evil.com/path'
// loc final         = 'http://google.com%5C@evil.com/path'
//
// O host no Location header é 'google.com' — sem ambiguidade para o browser.

Análise comparativa dos dois fixes

AspectoPatch 1 (4.19.1)Patch 2 (4.19.2)
AbordagemReativa — detectar e desfazer o problema após encodarPreventiva — nunca encodar onde o problema pode ocorrer
Usa url.parse?Sim — o mesmo parser problemáticoNão — remove a dependência completamente
ConfiabilidadeDepende do comportamento de url.parse com URLs malformadasDeterminística — regex bem definida, sem ambiguidade
ComplexidadeMaior — try/catch, dois parse calls, comparação de stringsMenor — uma regex, um slice, um encode
Conceito centralVerificar se o resultado do encoding está corretoSó aplicar encoding onde é seguro aplicar

O que o Patch 2 resolve (e o que não resolve):

  • Resolve: o vetor principal — encodeUrl() não toca mais o host, então \ no host nunca vira %5C de forma perigosa.
  • Mantém: URLs legítimas continuam sendo encodadas corretamente no path e query.
  • Remove: a dependência frágil do url.parse() legado — mais simples e robusto.
  • Não impede: passar uma URL maliciosa se a aplicação não tiver sua própria validação de host.
  • Não substitui: validação do desenvolvedor. res.redirect(req.query.url) sem verificação ainda é redirect aberto, mesmo com Express 4.19.2.

Como desenvolver com segurança

Mesmo com o Express atualizado para a versão corrigida, a responsabilidade de validar os destinos de redirect é inteiramente sua.

❌ Nunca faça: URL do usuário direto no redirect

// ❌ Zero validação — redirect aberto completo
app.get('/go', (req, res) => res.redirect(req.query.url));

// ❌ startsWith — bypassável com subdomínio falso
// 'https://meuapp.com.br.evil.com' passa nessa checagem!
if (req.query.url.startsWith('https://meuapp.com.br')) { ... }

// ❌ url.parse — vulnerável como vimos neste CVE
const p = require('url').parse(req.query.url);
if (p.hostname === 'meuapp.com.br') { ... }

✅ Correto: validar com new URL()

const HOSTS_OK = new Set(['meuapp.com.br', 'www.meuapp.com.br']);

function validarRedirect(urlString) {
  try {
    // ✅ new URL() usa a especificação WHATWG — a mesma que o browser usa
    // Normaliza \→/, userinfo, encoding — o que você valida é o que o browser executa
    const parsed = new URL(urlString);

    // Só http e https — bloqueia javascript:, data:, ftp:, etc.
    if (!['http:', 'https:'].includes(parsed.protocol)) return null;

    // .hostname (sem porta) — evita bypass com meuapp.com:80@evil.com
    return HOSTS_OK.has(parsed.hostname) ? urlString : null;
  } catch (e) {
    return null; // new URL() lança TypeError para URLs inválidas
  }
}

app.get('/redirect', (req, res) => {
  const destino = validarRedirect(req.query.url);
  if (!destino) return res.status(403).json({ error: 'Destino não permitido' });
  res.redirect(destino);
});

✅ Melhor ainda: mapa fixo de destinos

// O usuário passa uma CHAVE, não a URL — impossível de manipular
const DESTINOS = {
  'dashboard': '/app/painel',
  'config':    '/app/configuracoes',
  'perfil':    '/app/perfil',
};

app.get('/apos-login', (req, res) => {
  const destino = DESTINOS[req.query.proximo] || '/';
  res.redirect(destino); // URL de usuário nunca toca o redirect
});

// Se precisar de redirect relativo pós-login (ex: /relatorio/42):
function isRelativoSeguro(url) {
  // Aceita apenas paths que começam com / mas NÃO com // nem /\
  // //evil.com seria um protocol-relative URL → navega para evil.com
  return typeof url === 'string' && /^\/[^\/\\]/.test(url);
}

Resumo

AspectoDetalhe
CVECVE-2024-29041 / GHSA-rv95-896h-c2vc
TipoOpen Redirect (CWE-601) + Validação de Input (CWE-1286)
SeveridadeMODERATE — CVSS 6.1
Versões afetadasExpress <4.19.2 e 5.0.0-alpha.1 até <5.0.0-beta.3
Causa raizurl.parse() e browsers discordam sobre o host de URLs com \; encodeUrl() convertia \ para %5C de forma que o browser revertia e navegava para host diferente
Patch 1 (4.19.1)Compara host antes/depois de encodar via url.parse() — funciona, mas usa o mesmo parser problemático
Patch 2 (4.19.2)Regex localiza onde o host termina; encoda somente o path/query em diante — preventivo e sem dependência de parsers frágeis
PrevençãoUsar new URL() para validar, checar .hostname, preferir paths relativos ou mapa fixo de destinos