Host da requisição não é o endereço do site: monte links sem abrir brecha
Monte links absolutos, e-mails de redefinição de senha e canonical a partir de uma base configurada, e trate host, caminho e query da requisição como entrada do usuário.
Segunda de manhã, um cliente da loja reclama: pediu para redefinir a senha e o link do e-mail apontava para um domínio que não é o seu. Ninguém invadiu o servidor. O código montou o link com $_SERVER['HTTP_HOST'], e esse valor é o cabeçalho Host que quem fez a requisição mandou. A resposta curta: o endereço público do site vem de configuração, nunca da requisição. Host, caminho e query string são entrada do usuário, como qualquer campo de formulário.
Este guia mostra por que isso acontece, como montar URL absoluta com segurança em PHP, como ler o caminho e a query sem se expor e como testar se o seu sistema já está vulnerável. O exemplo que atravessa o texto é a loja loja.exemplo.com.br, rodando atrás de um proxy, com uma rota /senha/esqueci que manda e-mail.
Por que o Host da requisição não é confiável
Uma requisição HTTP é texto que o cliente escreve. O navegador preenche o Host com o domínio que a pessoa digitou, mas um curl ou um script pode colocar qualquer coisa ali. Se o servidor web aceita esse Host (porque o site responde como servidor padrão, ou porque o VirtualHost aceita qualquer nome), o valor chega intacto ao PHP.
O ataque clássico se chama envenenamento do link de redefinição de senha. O atacante pede a redefinição para o e-mail da vítima, trocando o Host para o domínio dele. O seu sistema gera um token legítimo e manda para a vítima um link como https://atacante.exemplo/senha/redefinir?token=.... Se a vítima clica, o token vai parar no log do atacante, que entra na conta. O e-mail saiu do seu servidor, com o seu remetente, e por isso parece confiável.
O mesmo valor vaza para outros lugares: a tag canonical, o Open Graph, o sitemap, o link de confirmação de cadastro e qualquer redirect absoluto. Atrás de proxy ou container, o problema aparece sem atacante nenhum: o PHP enxerga app:8080 ou um IP interno, e o Google indexa esse endereço.
E o SERVER_NAME?
$_SERVER['SERVER_NAME'] parece mais seguro, mas depende da configuração do servidor web. No Apache, com UseCanonicalName Off (o padrão), ele também reflete o Host do cliente. Não conte com ele como fonte de verdade.
Como montar link absoluto do jeito certo
- Defina a base pública em configuração. Uma variável de ambiente como
APP_URL=https://loja.exemplo.com.br, lida uma vez na inicialização. Se faltar, a aplicação falha ao subir, em vez de improvisar. - Gere todo link absoluto a partir dessa base. E-mails, canonical, Open Graph, sitemap e callbacks de pagamento usam a mesma função.
- Codifique o que vai na query. Use
http_build_querypara a query erawurlencodepara segmentos de caminho que vêm de dados. - Rejeite hosts desconhecidos na borda. O servidor web só entrega ao PHP requisições com os nomes que você atende. O resto recebe erro antes de chegar ao código.
- Se precisar ler o Host, compare com uma lista. Sistemas multi-domínio podem usar o Host para escolher o tenant, mas só depois de conferir que ele está numa lista de permitidos.
A função que gera URL absoluta
<?php
// config.php
$baseUrl = rtrim(getenv('APP_URL') ?: '', '/');
if ($baseUrl === '') {
throw new RuntimeException('APP_URL não configurada');
}
function urlAbsoluta(string $baseUrl, string $caminho, array $query = []): string
{
$url = $baseUrl . '/' . ltrim($caminho, '/');
if ($query) {
$url .= '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
}
return $url;
}
// senha_esqueci.php
$token = bin2hex(random_bytes(32));
$link = urlAbsoluta($baseUrl, '/senha/redefinir', ['token' => $token]);
// $link = https://loja.exemplo.com.br/senha/redefinir?token=9f2c...
Repare que o Host da requisição não aparece em lugar nenhum. Pode chegar o cabeçalho que for: o link do e-mail sempre aponta para loja.exemplo.com.br.
Multi-domínio com lista de permitidos
<?php
$hostsPermitidos = [
'loja.exemplo.com.br' => 'https://loja.exemplo.com.br',
'atacado.exemplo.com.br' => 'https://atacado.exemplo.com.br',
];
$host = strtolower($_SERVER['HTTP_HOST'] ?? '');
if (!isset($hostsPermitidos[$host])) {
http_response_code(400);
exit('Host inválido');
}
$baseUrl = $hostsPermitidos[$host];
O valor usado no link é o da sua tabela, não o que chegou. O Host só serve como chave de busca.
Recusar host estranho no nginx
Na borda, crie um servidor padrão que não responde nada, e deixe a loja só no server_name correto. O post sobre como o nginx escolhe o site pelo nome do host detalha a escolha do bloco, inclusive em HTTPS.
server {
listen 80 default_server;
server_name _;
return 444;
}
server {
listen 80;
server_name loja.exemplo.com.br;
# ...
}
O código 444 é específico do nginx: ele fecha a conexão sem resposta. No Apache, o equivalente é um primeiro VirtualHost que nega tudo, como explicado em vários sites no mesmo Apache.
Ler caminho e query sem se expor
Ler a URL é necessário: o roteador precisa do caminho, a listagem precisa do filtro. O cuidado é o que você faz com o valor depois.
<?php
$caminho = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$categoria = $_GET['categoria'] ?? '';
// consulta: sempre com parâmetro
$stmt = $pdo->prepare('SELECT id, nome FROM produtos WHERE categoria = ?');
$stmt->execute([$categoria]);
// saída em HTML: sempre escapada
echo '<h1>' . htmlspecialchars($categoria, ENT_QUOTES, 'UTF-8') . '</h1>';
Um exemplo antigo que ainda aparece em tutorial: <form action="<?= $_SERVER['PHP_SELF'] ?>">. O PHP_SELF pode carregar texto extra depois do nome do script, e ecoar isso sem escapar é XSS. Use uma ação fixa ou escape o valor.
Query string usada em redirect é outro capítulo: o parâmetro next que manda para qualquer lugar está em redirect que obedece ao parâmetro next.
Atrás de proxy: X-Forwarded-Host não resolve sozinho
Quando o nginx ou o balanceador fica na frente, o PHP vê o host interno. A tentação é ler X-Forwarded-Host e X-Forwarded-Proto. Esses cabeçalhos também podem ser forjados se a requisição chegar direto na aplicação ou se o proxy repassar o que o cliente mandou. Eles só valem quando o seu proxy sobrescreve o valor e a aplicação aceita conexões apenas desse proxy.
Mesmo assim, para montar link absoluto, a base configurada continua sendo o caminho mais simples. Ela não depende da cadeia de proxies e não muda quando você troca de balanceador.
Erros comuns
- Montar o link do e-mail com
HTTP_HOST. Abre a porta para o envenenamento do link de redefinição e entrega tokens a terceiros. - Servidor padrão que responde com a aplicação. Qualquer Host chega ao PHP, e o ataque acima vira trivial.
- Confiar em
X-Forwarded-Hostsem controlar o proxy. O cabeçalho vira um Host forjado com outro nome. - Concatenar valor na query sem codificar. Um
&ou espaço no dado quebra o link ou injeta outro parâmetro. - Canonical gerada a partir da requisição. A busca indexa o IP ou o nome do container, como descrito em canonical da página certa.
- Ecoar
PHP_SELFouREQUEST_URIsem escapar. Vira XSS refletido em qualquer página que use o padrão.
Checklist
- Existe uma variável como
APP_URLe a aplicação não sobe sem ela. - Todo link absoluto (e-mail, canonical, sitemap, callback) sai de uma única função.
- Nenhum arquivo usa
HTTP_HOSTouSERVER_NAMEpara montar URL; confira com uma busca no código. - O servidor web recusa requisição com Host desconhecido.
- Valores na query passam por
http_build_queryourawurlencode. - Toda saída de dado da URL em HTML passa por
htmlspecialchars. - Um teste com Host falso foi feito e o e-mail gerado aponta para o domínio certo.
Como testar agora
Em homologação, mande o pedido de redefinição com um Host inventado e abra o e-mail que chegou:
curl -i -H "Host: atacante.exemplo" -d "email=teste@exemplo.com" http://localhost:8080/senha/esqueci
Se o servidor respondeu e o link do e-mail traz atacante.exemplo, a base está vindo da requisição. Se a conexão foi recusada ou o link aponta para loja.exemplo.com.br, a borda e o código estão fazendo o trabalho deles.
Abra agora o último e-mail de redefinição que o seu sistema mandou, gerado atrás do proxy de produção. De onde veio o domínio desse link: de uma variável de configuração ou do cabeçalho que alguém enviou?
Referência: http://rubsphp.blogspot.com.br/2012/04/manipulacao-de-url-uri-e-links.html