PHP Escapar a Saída (XSS)

Toda vez que o seu PHP escreve na página algo que veio do visitante (um nome, um comentário, um termo de busca), existe um risco: e se o "nome" for, na verdade, um pedaço de HTML ou de JavaScript? O navegador não sabe a diferença e executa. Esse ataque se chama XSS, e é um dos mais comuns da web. A defesa é uma função de uma linha, htmlspecialchars, usada sempre. Nesta aula você vê o ataque acontecer e aprende a fechar a porta.

Você vai aprender
  • O que é XSS, vendo o ataque funcionar numa página de verdade.
  • O que é "escapar" um texto, e o que o htmlspecialchars troca.
  • As opções (flags) e o charset do htmlspecialchars.
  • Quando escapar: na saída, sempre, e não na entrada.
  • Como escapar dentro de atributos (value="…") e dentro de URLs (urlencode).
  • Os casos em que o htmlspecialchars sozinho não basta (javascript:).
  • A regra de ouro, que vale para o resto da sua vida de programador.

A ideia: o mural da escola

Imagine o mural de recados de uma escola. A secretária recebe bilhetes e cola no mural. Um dia, alguém entrega um bilhete que diz: "Aviso da diretoria: as aulas de amanhã estão canceladas. Assinado: a Diretora". A secretária cola sem pensar, e no dia seguinte ninguém aparece.

O problema não é o bilhete: é que ele foi colado de um jeito que parece oficial. Se a secretária colasse todo bilhete dentro de um envelope transparente com a etiqueta "recado de aluno", o mesmo texto não enganaria ninguém: todo mundo leria, mas como recado.

No PHP, a página é o mural, o texto do visitante é o bilhete, e o htmlspecialchars é o envelope transparente: o texto continua aparecendo, letra por letra, mas o navegador o trata como texto, nunca como uma ordem (HTML ou JavaScript).


O ataque, na prática

A Ana fez uma página de boas-vindas que repete o nome recebido no endereço:

<?php
$nome = $_GET['nome'] ?? 'visitante';
echo "<p>Olá, " . $nome . "!</p>\n";

Com um nome normal, tudo certo:

$ curl -s "localhost:8000/ola.php?nome=Ana"
<p>Olá, Ana!</p>

Agora alguém manda, como "nome", um pedaço de HTML: <b style="color:red">hackeado</b> (no endereço, codificado com %, como você viu em Formulários HTML):

$ curl -s "localhost:8000/ola.php?nome=%3Cb+style%3D%22color%3Ared%22%3Ehackeado%3C%2Fb%3E"
<p>Olá, <b style="color:red">hackeado</b>!</p>

O PHP colou o texto do jeito que veio, e as tags foram parar dentro da página. O navegador não tem como saber que aquilo era "o nome": para ele, é HTML como qualquer outro, e mostra hackeado em vermelho e negrito. Parece brincadeira, mas o atacante acabou de mudar a sua página. E se ele pode colocar uma tag <b>, pode colocar uma tag <script>:

$ curl -s "localhost:8000/ola.php?nome=%3Cscript%3Ealert(1)%3C%2Fscript%3E"
<p>Olá, <script>alert(1)</script>!</p>

Esse <script> roda no navegador de quem abrir o endereço. O alert(1) só mostra uma janelinha, e é o "teste" clássico de quem procura essa falha. Um atacante de verdade colocaria ali um código que lê os cookies da vítima (inclusive o da sessão de login, veja Sessões) e envia para ele, ou que troca o formulário de login por um falso. Basta mandar o link para a vítima, ou, pior, salvar o "nome" num banco de dados e deixar ele aparecer para todo mundo que abrir a página (um comentário de blog, por exemplo).

TermoSignificado
XSSCross-Site Scripting ("script entre sites"): fazer o site de outra pessoa rodar um código seu no navegador dos visitantes.
XSS refletidoO ataque vem no próprio pedido (no link) e "volta refletido" na resposta. O caso do ola.php.
XSS armazenadoO ataque é salvo (comentário, nome de perfil) e aparece para todos que abrem a página. O mais perigoso.
EscaparTrocar os caracteres especiais de uma linguagem por uma forma "inofensiva" que mostra o mesmo caractere sem o poder especial (a mesma ideia do \ antes de uma aspa, em Strings).

A página vulnerável, aberta com o "nome" malicioso. Rode e veja na aba Resultado: o hackeado vermelho foi o visitante que colocou. Depois, digite no formulário outras tags (<i>oi</i>, <h1>GRANDE</h1>) e veja a página obedecer. Na aba Código-fonte HTML, as tags estão lá, cruas.

<?php
$nome = $_GET['nome'] ?? 'visitante';
echo "<p>Olá, " . $nome . "!</p>";
?>
<form>
  Nome: <input name="nome"> <button>Enviar</button>
</form>

A defesa: htmlspecialchars

A função htmlspecialchars ("caracteres especiais do HTML") troca os cinco caracteres que têm poder no HTML pelas suas entidades, que são a forma de escrever o caractere como texto:

CaractereViraPor que é perigoso
<&lt;Abre uma tag (less than, menor que)
>&gt;Fecha uma tag (greater than, maior que)
&&amp;Começa uma entidade (ampersand, o "e comercial")
"&quot;Fecha um atributo entre aspas duplas (quote)
'&#039;Fecha um atributo entre aspas simples

O navegador mostra &lt; como <, mas sem abrir tag nenhuma. A página corrigida:

<?php
$nome = $_GET['nome'] ?? 'visitante';
echo "<p>Olá, " . htmlspecialchars($nome) . "!</p>\n";
$ curl -s "localhost:8000/ola2.php?nome=%3Cb+style%3D%22color%3Ared%22%3Ehackeado%3C%2Fb%3E"
<p>Olá, &lt;b style=&quot;color:red&quot;&gt;hackeado&lt;/b&gt;!</p>
$ curl -s "localhost:8000/ola2.php?nome=%3Cscript%3Ealert(1)%3C%2Fscript%3E"
<p>Olá, &lt;script&gt;alert(1)&lt;/script&gt;!</p>

No navegador, a pessoa vê literalmente Olá, <b style="color:red">hackeado</b>!, em preto, com os sinais de menor e maior aparecendo. O "nome" esquisito é mostrado, mas não manda em nada. E com nomes normais, nada muda: Ana continua Ana, e acentos continuam acentos.

Anatomia completa

htmlspecialchars(string $string, int $flags = ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, ?string $encoding = null, bool $double_encode = true): string
$string
O texto que vai ser escapado.
$flags
Opções (flags = bandeirinhas), combinadas com |. O padrão, desde o PHP 8.1, já é o que você quer: escapar os dois tipos de aspas e substituir texto inválido. Por isso, no PHP 8, htmlspecialchars($texto) sozinho basta.
$encoding
A codificação (charset) do texto. null usa a configuração default_charset do PHP, que é UTF-8. Só mude se o seu site não for UTF-8 (raro hoje).
$double_encode
Com true (o padrão), um &amp; que já estava no texto é escapado de novo. Deixe assim: o texto do visitante deve aparecer exatamente como ele digitou.

As flags na prática

<?php
echo htmlspecialchars('<b>Tom & Jerry</b> disse "oi" e \'tchau\''), "\n";
echo htmlspecialchars('<b>Tom & Jerry</b> disse "oi" e \'tchau\'', ENT_NOQUOTES), "\n";
echo htmlspecialchars('Café com pão'), "\n";
echo htmlentities('Café com pão'), "\n";
$ php hsc.php
&lt;b&gt;Tom &amp; Jerry&lt;/b&gt; disse &quot;oi&quot; e &#039;tchau&#039;    « padrão: escapa tudo
&lt;b&gt;Tom &amp; Jerry&lt;/b&gt; disse "oi" e 'tchau'                « ENT_NOQUOTES: deixa as aspas
Café com pão                                                       « acentos ficam como estão
Caf&eacute; com p&atilde;o                                           « htmlentities: troca até os acentos
FlagO que faz
ENT_QUOTESEscapa " e '. Está no padrão; essencial para atributos.
ENT_COMPATEscapa só ". Era o padrão antes do PHP 8.1: código antigo com value='…' (aspas simples) ficava vulnerável.
ENT_NOQUOTESNão escapa aspas. Evite.
ENT_SUBSTITUTETexto com bytes inválidos (fora do UTF-8) tem o pedaço ruim trocado por �, em vez de devolver vazio. Está no padrão.
ENT_HTML5Usa entidades do HTML5 (' vira &apos;). Opcional.

O ENT_SUBSTITUTE faz diferença quando chega um texto num formato de letras antigo. Aqui, o "é" de "Café" está no formato ISO-8859-1, inválido em UTF-8:

<?php
$texto = "Caf\xE9";               // "Café" num formato antigo (ISO-8859-1), inválido em UTF-8
var_dump(htmlspecialchars($texto));
var_dump(htmlspecialchars($texto, ENT_QUOTES));
$ php invalido.php
string(6) "Caf�"      « padrão: o byte inválido virou �
string(0) ""          « sem ENT_SUBSTITUTE: o texto inteiro sumiu

Por isso, se você vir em tutoriais htmlspecialchars($x, ENT_QUOTES, 'UTF-8'), saiba que é a forma "por extenso" de antes do PHP 8.1. Funciona, mas perde o ENT_SUBSTITUTE; no PHP 8.5, prefira o padrão: htmlspecialchars($x).

htmlentities é a prima que troca todo caractere que tem entidade, inclusive acentos. Com páginas em UTF-8 isso é desnecessário (e deixa o código-fonte ilegível). Use htmlspecialchars.


Quando escapar: na saída, sempre

Uma dúvida comum: "não seria melhor limpar o nome assim que ele chega, e guardar já escapado?" Não. A regra é: guarde o dado como ele é; escape no momento de escrever na página. Três motivos:

  1. O jeito de escapar depende de onde o dado vai. No HTML, htmlspecialchars; numa URL, urlencode; num arquivo CSV ou num e-mail de texto, nada. Escapando na entrada, você só acerta um desses.
  2. Escapar duas vezes estraga o texto. Tom & Jerry vira Tom &amp; Jerry no banco, e na tela aparece Tom &amp; Jerry literalmente.
  3. Dados vêm de muitos lugares (banco de dados, arquivos, outras APIs), não só de formulários. Se a regra é "escapar ao escrever", você nunca precisa lembrar de onde cada um veio.
Na prática: todo <?= $variavel ?> que mostra texto na página vira <?= htmlspecialchars($variavel) ?>. Sem exceção para "esse dado é confiável": o nome que veio do banco foi digitado por alguém um dia.

Como o nome é longo, muitos projetos criam uma função curta para isso (funções em Funções):

<?php
function e(string $texto): string
{
    return htmlspecialchars($texto);
}

E na página: <p>Olá, <?= e($nome) ?>!</p>. Os sistemas de templates dos frameworks (Twig, Blade, do Laravel) fazem isso sozinhos em todo {{ variavel }}.


Escapar em atributos

Dentro de um atributo, como o value de um campo (que você usou em Validar Dados para manter o que foi digitado), o perigo é a aspa. Veja uma busca que devolve o termo no campo, sem e com escape:

<?php
$busca = $_GET['busca'] ?? '';
?>
<input name="busca" value="<?= $busca ?>">
<input name="busca" value="<?= htmlspecialchars($busca) ?>">
$ curl -s "localhost:8000/atributo.php?busca=bolo"
<input name="busca" value="bolo">
<input name="busca" value="bolo">
$ curl -s "localhost:8000/atributo.php?busca=%22+autofocus+onfocus%3D%22alert(1)"
<input name="busca" value="" autofocus onfocus="alert(1)">         « sem escape: a aspa fechou o value
<input name="busca" value="&quot; autofocus onfocus=&quot;alert(1)">  « com escape: tudo dentro do value

Na primeira linha, a aspa do "termo de busca" fechou o value antes da hora, e o resto virou novos atributos: autofocus (o campo recebe o cursor ao abrir a página) e onfocus="alert(1)" (rode este JavaScript ao receber o cursor). O ataque funciona sem nenhum < ou >. Na segunda linha, o &quot; é só uma aspa desenhada, dentro do valor.

Sempre coloque aspas nos atributos. value=<?= htmlspecialchars($x) ?> (sem aspas) continua vulnerável: um simples espaço no valor já começa um atributo novo, e o htmlspecialchars não escapa espaços. Escreva value="<?= htmlspecialchars($x) ?>".

Escapar em URLs: urlencode

Quando um dado vai dentro de um endereço, como o termo de uma busca num link, o problema são os caracteres que têm significado na URL: &, =, ?, #, espaço. A função urlencode codifica um valor no formato que você viu em Formulários HTML:

<?php
$q = 'bolo & café';
echo urlencode($q), "\n";
echo rawurlencode($q), "\n";
echo '<a href="busca.php?q=' . urlencode($q) . '&amp;pagina=2">Buscar</a>', "\n";
echo '<a href="' . htmlspecialchars('busca.php?' . http_build_query(['q' => $q, 'pagina' => 2])) . '">Buscar</a>', "\n";
$ php url.php
bolo+%26+caf%C3%A9
bolo%20%26%20caf%C3%A9
<a href="busca.php?q=bolo+%26+caf%C3%A9&amp;pagina=2">Buscar</a>
<a href="busca.php?q=bolo+%26+caf%C3%A9&amp;pagina=2">Buscar</a>
urlencode($q)
Codifica um valor de parâmetro: espaço vira +, & vira %26, acentos viram %C3%A9. Sem isso, o & de "bolo & café" cortaria a busca em "bolo ", e "café" viraria um parâmetro sem nome.
rawurlencode($q)
O mesmo, mas espaço vira %20. Use para pedaços do caminho (/fotos/minhas%20ferias.jpg).
&amp;pagina=2
Dentro do HTML, o & que separa os parâmetros também é escrito &amp;. O navegador lê &.
htmlspecialchars(… http_build_query(…))
A forma mais tranquila: monte a query string inteira com http_build_query (que já codifica cada valor, veja $_GET e $_POST) e escape o endereço todo para o HTML.

O caso que nenhuma função resolve: javascript:

Se o visitante escolhe o endereço inteiro de um link (o campo "seu site" de um perfil), o htmlspecialchars não basta:

<?php
$site = $_GET['site'] ?? '';
echo '<a href="' . htmlspecialchars($site) . '">Meu site</a>', "\n";
$ curl -s "localhost:8000/js.php?site=javascript:alert(1)"
<a href="javascript:alert(1)">Meu site</a>

Não havia nada para escapar: javascript:alert(1) não tem <, aspas nem &. Mas um link javascript: roda código quando clicado. A defesa aqui é validar (veja Validar Dados): aceite só endereços que comecem com https:// ou http://, por exemplo com str_starts_with($site, 'https://'), e só então escape.


Cada lugar, sua forma de escapar

Onde o dado vaiUseExemplo
Texto no HTMLhtmlspecialchars<p><?= htmlspecialchars($nome) ?></p>
Atributo HTMLhtmlspecialchars + aspas no atributovalue="<?= htmlspecialchars($v) ?>"
Valor de parâmetro numa URLurlencode (ou http_build_query)?q=<?= urlencode($q) ?>
URL inteira vinda do visitantevalidar o começo (https://) + htmlspecialcharshref="<?= htmlspecialchars($site) ?>"
Dentro de <script>json_encode com JSON_HEX_TAGconst nome = <?= json_encode($nome, JSON_HEX_TAG) ?>; (veja JSON)
SQLPrepared statements, nunca escape manualveja Prepared Statements

E o strip_tags?

A função strip_tags ("arrancar as tags") apaga tudo o que parece tag. Parece uma defesa, mas muda o que a pessoa escreveu, e às vezes estraga texto inocente:

<?php
echo strip_tags('<b style="color:red">hackeado</b>'), "\n";
echo strip_tags('5 < 7 e 8 > 3'), "\n";
echo htmlspecialchars('5 < 7 e 8 > 3'), "\n";
$ php strip.php
hackeado                « a tag foi apagada (e o texto da pessoa, alterado)
5 < 7 e 8 > 3           « aqui deixou passar os sinais soltos
5 &lt; 7 e 8 &gt; 3     « htmlspecialchars: mostra igualzinho, sem risco

E strip_tags não protege atributos (o ataque do onfocus não tem tag nenhuma). Para mostrar texto, a resposta é sempre htmlspecialchars.


A regra de ouro

Filtre na entrada, escape na saída.
  • Entrada: confira se o dado faz sentido (Validar Dados) e recuse o que não faz.
  • Saída: todo dado que vai para a página passa pelo escape do lugar onde ele vai: htmlspecialchars no HTML, urlencode na URL.
  • Sem exceções para dados "confiáveis": o banco, os arquivos e os cookies também guardam o que alguém digitou.

Quando dá errado

Apareceu &amp;, &quot; ou &lt; na tela

O que aconteceu: o texto foi escapado duas vezes. Por exemplo: htmlspecialchars antes de salvar no banco, e de novo ao mostrar. Tom & Jerry → Tom &amp; Jerry → Tom &amp;amp; Jerry, e a pessoa vê Tom &amp; Jerry.

Como resolver: escape uma vez só, na saída. Guarde no banco o texto original.

O nome sumiu da página (ficou vazio)
$ php invalido.php
string(6) "Caf�"
string(0) ""

O que aconteceu: o texto tinha bytes que não são UTF-8 válido, e o htmlspecialchars foi chamado com flags que não incluem ENT_SUBSTITUTE (como ENT_QUOTES sozinho). Nesse caso, ele devolve texto vazio.

Como resolver: use o padrão do PHP 8 (htmlspecialchars($x)), ou inclua a flag: ENT_QUOTES | ENT_SUBSTITUTE. E descubra de onde veio o texto no formato antigo (um CSV exportado de um programa velho, por exemplo; veja Acentos e mbstring).

As tags que eu queria (negrito do meu próprio texto) aparecem como texto

O que aconteceu: você escapou um HTML que você escreveu, como htmlspecialchars("<b>Promoção</b>"). O escape não sabe quem escreveu; ele só desliga tags.

Como resolver: escape só a parte que veio de fora: "<b>" . htmlspecialchars($produto) . "</b>". Se o visitante precisa formatar texto (negrito, links), use um formato próprio para isso, como Markdown, com uma biblioteca que gere HTML seguro.

htmlspecialchars(): Argument #1 ($string) must be of type string, array given

O que aconteceu: chegou um array (?nome[]=x) onde você esperava texto, igual ao caso do trim em Validar Dados. O erro é fatal (TypeError).

Como resolver: confira o tipo ao ler o campo (is_string) antes de chegar na saída.


Referência

FunçãoPara quêExemplo de saída
htmlspecialchars($s)Texto e atributos no HTML<b> → &lt;b&gt;
htmlentities($s)Como a anterior, mas troca também acentos (desnecessário em UTF-8)é → &eacute;
urlencode($s)Valor de parâmetro de URLbolo & café → bolo+%26+caf%C3%A9
rawurlencode($s)Pedaço do caminho de uma URLespaço → %20
http_build_query($a)Query string inteiraq=bolo+%26+caf%C3%A9&pagina=2
json_encode($v, JSON_HEX_TAG)Valor dentro de <script>Ana</script> → "Ana\u003C\/script\u003E"
strip_tags($s)Apagar tags (não é defesa contra XSS)<b>oi</b> → oi

Exercícios

1. Mostrar com segurança

Complete para mostrar o comentário sem risco de XSS:

<p><?= ?></p>

2. Link de busca

Complete com a função que codifica o termo dentro da URL:

<a href="busca.php?q=<?= ($termo) ?>">

3. A entidade da aspa

Em que o htmlspecialchars transforma a aspa dupla "?

4. Quando você deve aplicar o htmlspecialchars?

5. value=<?= htmlspecialchars($x) ?> (sem aspas em volta) é seguro?

6. O campo "seu site" recebeu javascript:alert(1). O que protege o link?

Missão: feche a porta do XSS. A página foi aberta com o nome malicioso. Use htmlspecialchars para que, na aba Resultado, apareça o texto <b style="color:red">hackeado</b> com os sinais de menor e maior, em preto, e não uma palavra vermelha.

<?php
$nome = $_GET['nome'] ?? 'visitante';
echo "<p>Olá, " . $nome . "!</p>";
?>
<form>
  Nome: <input name="nome"> <button>Enviar</button>
</form>
<?php
$nome = $_GET['nome'] ?? 'visitante';
echo "<p>Olá, " . htmlspecialchars($nome) . "!</p>";
?>
<form>
  Nome: <input name="nome"> <button>Enviar</button>
</form>

Missão: a busca da padaria foi aberta procurando bolo & café. Hoje, o campo e o link "Página 2" estão quebrados: o link perde o "café" (repare no endereço dele). Escape o termo no value com htmlspecialchars e no link com urlencode.

<?php
$q = $_GET['q'] ?? '';
?>
<form>
  Buscar: <input name="q" value="<?= $q ?>"> <button>Buscar</button>
</form>
<p>Você buscou: <?= htmlspecialchars($q) ?></p>
<a href="?q=<?= $q ?>&amp;pagina=2">Página 2</a>
<?php
$q = $_GET['q'] ?? '';
?>
<form>
  Buscar: <input name="q" value="<?= htmlspecialchars($q) ?>"> <button>Buscar</button>
</form>
<p>Você buscou: <?= htmlspecialchars($q) ?></p>
<a href="?q=<?= urlencode($q) ?>&amp;pagina=2">Página 2</a>

Resumo

  • XSS: texto do visitante escrito na página sem escape vira HTML/JavaScript que roda no navegador de outras pessoas.
  • htmlspecialchars troca < > & " ' por entidades: o texto aparece igual, mas sem poder.
  • No PHP 8.1+, o padrão (ENT_QUOTES | ENT_SUBSTITUTE, UTF-8) é o certo: htmlspecialchars($x) basta.
  • Escape na saída, uma vez, conforme o lugar: HTML → htmlspecialchars; parâmetro de URL → urlencode.
  • Atributos sempre entre aspas. URLs inteiras do visitante: valide o começo (https://) antes.
  • strip_tags e htmlentities não são a ferramenta para isso.
  • Regra de ouro: filtre na entrada, escape na saída, sem exceções.