Redirecionamento em PHP: header, códigos HTTP e segurança

Redirecionamento em PHP: header, códigos HTTP e segurança
Resposta rápida

header('Location: /destino') seguido de exit, sempre os dois. Sem código explícito, o PHP envia um 302, usa 301 para uma mudança definitiva e 303 depois de tratar um formulário. Um destino fornecido pelo utilizador tem de ser validado, senão abres um redirecionamento para um site terceiro.

Redirecionar em PHP cabe em duas linhas. O que merece um artigo é a escolha do código HTTP, a paragem do script logo a seguir e a falha de redirecionamento aberto que aparece assim que o destino vem de dados enviados pelo utilizador.

O redirecionamento mínimo

php
<?php
header('Location: /nouvelle-page');
exit;

Dois pontos mal compreendidos com frequência. Primeiro, o Location aceita tanto um URL absoluto como um caminho relativo à raiz: ambos funcionam em todos os browsers atuais. Depois, sem código explícito, o PHP envia um 302 Found.

Escolher o código certo

Código Significado Método preservado Quando usar
301 Movido definitivamente não (o POST passa a GET) Um URL mudou de vez
302 Encontrado, temporário não (o POST passa a GET) Predefinição histórica, a evitar escrever
303 Ver noutro sítio não, nunca Depois de tratar um formulário
307 Temporário sim Manutenção, mudança provisória
308 Permanente sim URL mudado, preservando o POST
php
// Uma página mudou de sítio definitivamente
header('Location: https://exemple.com/nouvelle-adresse', true, 301);
exit;

// Depois de tratar um formulário: evita o reenvio do POST ao recarregar
header('Location: /merci', true, 303);
exit;

O 303 depois de um formulário tem nome: Post/Redirect/Get. Sem ele, atualizar a página faz o browser reenviar o formulário, e a encomenda segue duas vezes.

O 301 fica em cache no browser, às vezes durante muito tempo. Um redirecionamento permanente colocado por engano é penoso de retirar: os visitantes que o receberam continuam a ser redirecionados sem sequer contactar o servidor. Na dúvida, começa por um 302 ou um 307.

Parar sempre o script

O header() coloca um cabeçalho, não interrompe nada. O código que vem a seguir é executado e o que ele produz segue no corpo da resposta.

fuite.php
<?php
header('Location: /connexion', true, 301);
echo "CONTENU QUI FUIT";   // saiu à mesma

Medido com o Apache e o PHP 8.5.10:

code
avec exit  -> corps de la réponse : 0 octet
sans exit  -> corps de la réponse : 16 octets, « CONTENU QUI FUIT »

O browser segue o redirecionamento e não mostra esse corpo, o que torna o problema invisível. Mas ele é mesmo transmitido: o curl, um proxy, um registo intermédio ou um robô veem-no. Numa página que redireciona um visitante não autenticado, é exatamente o conteúdo que se queria proteger que sai para a rede.

die() e exit() são a mesma coisa

Circula muito uma afirmação, presente inclusive na versão anterior deste artigo: o die() fecharia a ligação e o exit() mantê-la-ia aberta. É falso. São duas grafias da mesma construção da linguagem, e a documentação do PHP di-lo explicitamente.

Verificação: dois ficheiros idênticos a menos de uma palavra, servidos pelo mesmo Apache:

php
<?php header('HTTP/1.1 304 Not Modified'); exit();   // avec-exit.php
<?php header('HTTP/1.1 304 Not Modified'); die();    // avec-die.php
code
$ curl -s -D - -o /dev/null http://localhost/avec-exit.php
HTTP/1.1 304 Not Modified
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.5.10

$ curl -s -D - -o /dev/null http://localhost/avec-die.php
HTTP/1.1 304 Not Modified
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.5.10

As duas respostas são idênticas byte a byte, tirando o cabeçalho Date. Escolher entre as duas é uma questão de estilo. O exit é mais comum no código moderno, e escrevê-lo sem parênteses também funciona.

A armadilha dos cabeçalhos já enviados

É o erro mais frequente neste tema. O mais pequeno byte escrito antes do header(), um espaço antes de <?php, uma linha vazia depois de ?> num ficheiro incluído, um echo de depuração, um byte BOM no início do ficheiro, torna o redirecionamento impossível.

code
Warning: Cannot modify header information - headers already sent by
(output started at /var/www/inc/config.php:42) in /var/www/page.php on line 3

A mensagem indica entre parênteses o ficheiro e a linha culpados. Para testar antes de agir:

php
if (headers_sent($fichier, $ligne)) {
    error_log("Sortie déjà commencée dans {$fichier}:{$ligne}");
    // alternativa: redirecionamento do lado do browser
    echo '<meta http-equiv="refresh" content="0;url=/nouvelle-page">';
    exit;
}

header('Location: /nouvelle-page', true, 303);
exit;

Duas regras evitam o problema na origem: nunca fechar um ficheiro PHP com ?> e não mostrar nada antes de ter decidido qual é a resposta.

O redirecionamento aberto

É a verdadeira falha de segurança deste tema, e está ausente da maior parte dos tutoriais. O padrão «voltar à página anterior depois do login» costuma escrever-se assim:

php
// Vulnerável
header('Location: ' . $_GET['retour']);
exit;

Um atacante envia então uma ligação com a forma https://votre-site.com/connexion.php?retour=https://site-pirate.example. A ligação leva o teu nome de domínio, passa os filtros de e-mail e tranquiliza a vítima, que aterra numa cópia da tua página de login. É o mecanismo de base de boa parte das campanhas de phishing.

A correção não passa por filtrar caracteres, mas por aceitar apenas destinos internos.

src/redirection.php
<?php

declare(strict_types=1);

/**
 * Só autoriza um caminho interno. Qualquer URL absoluto é descartado.
 */
function destinationSure(string $demande, string $defaut = '/'): string
{
    // Caracteres de controlo: tentativa de injeção de cabeçalho
    if ($demande === '' || preg_match('/[\x00-\x1F\x7F]/', $demande) === 1) {
        return $defaut;
    }

    // Uma única « / » no início, não seguida de « / » nem de « \ »
    if (!preg_match('#^/(?![/\\\\])#', $demande)) {
        return $defaut;
    }

    $parties = parse_url($demande);
    if ($parties === false || isset($parties['scheme']) || isset($parties['host'])) {
        return $defaut;
    }

    // Sem subida de caminho
    if (str_contains($parties['path'] ?? '', '../')) {
        return $defaut;
    }

    return $demande;
}

function redirigerVers(string $demande, string $defaut = '/'): never
{
    header('Location: ' . destinationSure($demande, $defaut), true, 303);
    exit;
}

A nossa primeira versão desta função só testava a barra dupla inicial. Os testes mostraram que deixava passar três famílias de entradas, o que justifica cada uma das linhas acrescentadas:

  • /site-pirate.example, os browsers tratam a barra invertida como uma barra nesta posição. É a forma mais comum de contornar um controlo que só olha para //.
  • /okrnX-Injecte: 1, o header() recusaria este valor, mas mais vale descartá-lo antes de lá chegar.
  • /ok/../../admin, uma subida de caminho que chega a uma zona não prevista.

As quinze entradas passadas à versão final:

Valor recebido Destino
/mon-compte /mon-compte
/articles?page=2 /articles?page=2
/a/b?x=1#ancre /a/b?x=1#ancre
https://site-pirate.example /
//site-pirate.example /
/site-pirate.example /
javascript:alert(1) /
/../admin e /ok/../../admin /
/okrnX-Injecte: 1 /
mon-compte (sem barra) e string vazia /

Se forem mesmo precisos destinos externos, a única forma segura é uma lista branca de domínios, comparada com o host extraído por parse_url(), nunca com str_contains().

A injeção no cabeçalho

Uma quebra de linha no valor de um cabeçalho permitia antigamente acrescentar outros. O PHP filtra as quebras de linha no header() desde a versão 5.1.2 e lança um erro. Não há mais nada a fazer, mas é mais uma razão para não construir cabeçalhos concatenando dados do utilizador.

Quando não redirecionar em PHP

Um redirecionamento permanente de URL para URL não precisa de PHP. Fazê-lo ao nível do servidor é mais rápido, o pedido não acorda o interpretador, e sobrevive a uma avaria da aplicação.

.htaccess
Redirect 301 /ancienne-page /nouvelle-page

# Forçar HTTPS
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
nginx.conf
location = /ancienne-page {
    return 301 /nouvelle-page;
}

O PHP continua indicado quando o destino depende de um estado: sessão, permissões, resultado de um tratamento, língua negociada.

A reter

  • header('Location: …') e depois exit, sempre os dois.
  • 303 depois de um formulário, 301 só quando é definitivo.
  • die() e exit() são idênticos, medido e verificado.
  • Um destino vindo do utilizador valida-se contra os caminhos internos, recusando os URL absolutos e a barra dupla.

O 303 depois de um formulário anda muitas vezes a par de um aviso de receção: vê enviar um e-mail com PHP. Se o destino tiver de ser calculado do lado do browser em vez do servidor, vê passar variáveis de PHP para JavaScript. Para o redirecionamento que se segue ao envio de um ficheiro, vê fazer upload de um ficheiro em PHP.

Vê também o hub Desenvolvimento web e a ligação a uma base de dados em PHP.

Erros frequentes

die() e exit() seriam diferentes Afirmação falsa, presente na versão anterior deste artigo. São duas grafias da mesma construção. Dois ficheiros idênticos a menos dessa palavra devolvem respostas HTTP idênticas, tirando o cabeçalho Date.
header() sem exit O header() não interrompe nada. O conteúdo que vem a seguir segue no corpo da resposta: invisível no browser, bem presente para o curl, um proxy ou um robô.
Redirecionamento aberto header('Location: ' . $_GET['retour']) permite fabricar uma ligação de phishing com o teu nome de domínio. O destino tem de ser validado como caminho interno.
Contornar com a barra dupla //site-pirate.example é um URL relativo ao protocolo. E /site-pirate.example também passa: os browsers tratam a barra invertida como uma barra nesta posição.
Cabeçalhos já enviados Um espaço antes de <?php, uma linha vazia depois de ?> ou um byte BOM chegam para isso. Nunca fechar um ficheiro PHP com ?>.
301 em cache Um redirecionamento permanente colocado por engano fica no browser dos visitantes que o receberam. Na dúvida, um 302 ou um 307.

HTTPPHPSécuritéSEO

Damien Flandrin Programador web desde 2010, criador da Gekkode e do Email Impact. Cada artigo é testado num projeto real antes de ser publicado. Contacto
Newsletter

Os novos testes, tutoriais e projetos, por e-mail.

Testes reproduzíveis, código versionado, resultados datados. Nunca spam.