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().
- Você chama
res.redirect(url): O Express recebe a URL de destino. Pode ser uma URL completa (http://...) ou um path relativo (/dashboard). - Internamente ele faz
res.location(url):res.redirect()é um wrapper que define o headerLocation: urle envia a resposta com status 302. res.location()aplicaencodeUrl(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.- 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
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.
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.”
var mime = send.mime; var urlParse = require('url').parse; ... res.location = function location(url) { // ... (lógica do 'back') ... var lowerLoc = loc.toLowerCase(); var encodedUrl = encodeUrl(loc); if (lowerLoc.indexOf('https://') === 0 || lowerLoc.indexOf('http://') === 0) { try { var parsedUrl = urlParse(loc); var parsedEncodedUrl = urlParse(encodedUrl); if (parsedUrl.host !== parsedEncodedUrl.host) { return this.set('Location', loc); } } catch (e) { return this.set('Location', loc); } } return this.set('Location', encodeUrl(loc)); return this.set('Location', encodedUrl); 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
- 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.
- 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.
- 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.
- 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.
var urlParse = require('url').parse; ... var schemaAndHostRegExp = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?\/\/[^\\\/\?]+/; ... @@ res.location @@ var loc = String(url); var loc; if (url === 'back') { loc = this.req.get('Referrer') || '/'; } else { loc = String(url); } @@ nova lógica de encoding @@ var m = schemaAndHostRegExp.exec(loc); var pos = m ? m[0].length + 1 : 0; loc = loc.slice(0, pos) + encodeUrl(loc.slice(pos)); return this.set('Location', encodedUrl); return this.set('Location', loc); 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
| Aspecto | Patch 1 (4.19.1) | Patch 2 (4.19.2) |
|---|---|---|
| Abordagem | Reativa — detectar e desfazer o problema após encodar | Preventiva — nunca encodar onde o problema pode ocorrer |
| Usa url.parse? | Sim — o mesmo parser problemático | Não — remove a dependência completamente |
| Confiabilidade | Depende do comportamento de url.parse com URLs malformadas | Determinística — regex bem definida, sem ambiguidade |
| Complexidade | Maior — try/catch, dois parse calls, comparação de strings | Menor — uma regex, um slice, um encode |
| Conceito central | Verificar se o resultado do encoding está correto | Só 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%5Cde 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
| Aspecto | Detalhe |
|---|---|
| CVE | CVE-2024-29041 / GHSA-rv95-896h-c2vc |
| Tipo | Open Redirect (CWE-601) + Validação de Input (CWE-1286) |
| Severidade | MODERATE — CVSS 6.1 |
| Versões afetadas | Express <4.19.2 e 5.0.0-alpha.1 até <5.0.0-beta.3 |
| Causa raiz | url.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ção | Usar new URL() para validar, checar .hostname, preferir paths relativos ou mapa fixo de destinos |