Programação Por Equipe DevBras

Capa do artigo: Host da requisição não é o endereço do site: monte links sem abrir brecha

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

  1. 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.
  2. Gere todo link absoluto a partir dessa base. E-mails, canonical, Open Graph, sitemap e callbacks de pagamento usam a mesma função.
  3. Codifique o que vai na query. Use http_build_query para a query e rawurlencode para segmentos de caminho que vêm de dados.
  4. 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.
  5. 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-Host sem 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_SELF ou REQUEST_URI sem escapar. Vira XSS refletido em qualquer página que use o padrão.

Checklist

  • Existe uma variável como APP_URL e 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_HOST ou SERVER_NAME para 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_query ou rawurlencode.
  • 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?

Avalie este artigo

Referência: http://rubsphp.blogspot.com.br/2012/04/manipulacao-de-url-uri-e-links.html