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.
- O que é XSS, vendo o ataque funcionar numa página de verdade.
- O que é "escapar" um texto, e o que o
htmlspecialcharstroca. - 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
htmlspecialcharssozinho 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).
| Termo | Significado |
|---|---|
| XSS | Cross-Site Scripting ("script entre sites"): fazer o site de outra pessoa rodar um código seu no navegador dos visitantes. |
| XSS refletido | O ataque vem no próprio pedido (no link) e "volta refletido" na resposta. O caso do ola.php. |
| XSS armazenado | O ataque é salvo (comentário, nome de perfil) e aparece para todos que abrem a página. O mais perigoso. |
| Escapar | Trocar 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:
| Caractere | Vira | Por que é perigoso |
|---|---|---|
< | < | Abre uma tag (less than, menor que) |
> | > | Fecha uma tag (greater than, maior que) |
& | & | Começa uma entidade (ampersand, o "e comercial") |
" | " | Fecha um atributo entre aspas duplas (quote) |
' | ' | Fecha um atributo entre aspas simples |
O navegador mostra < 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á, <b style="color:red">hackeado</b>!</p> $ curl -s "localhost:8000/ola2.php?nome=%3Cscript%3Ealert(1)%3C%2Fscript%3E" <p>Olá, <script>alert(1)</script>!</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.
nullusa a configuraçãodefault_charsetdo 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&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 <b>Tom & Jerry</b> disse "oi" e 'tchau' « padrão: escapa tudo <b>Tom & Jerry</b> disse "oi" e 'tchau' « ENT_NOQUOTES: deixa as aspas Café com pão « acentos ficam como estão Café com pão « htmlentities: troca até os acentos
| Flag | O que faz |
|---|---|
ENT_QUOTES | Escapa " e '. Está no padrão; essencial para atributos. |
ENT_COMPAT | Escapa só ". Era o padrão antes do PHP 8.1: código antigo com value='…' (aspas simples) ficava vulnerável. |
ENT_NOQUOTES | Não escapa aspas. Evite. |
ENT_SUBSTITUTE | Texto 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_HTML5 | Usa entidades do HTML5 (' vira '). 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:
- 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. - Escapar duas vezes estraga o texto.
Tom & JerryviraTom & Jerryno banco, e na tela apareceTom & Jerryliteralmente. - 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.
<?= $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="" autofocus onfocus="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 " é só uma aspa desenhada, dentro do valor.
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) . '&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&pagina=2">Buscar</a> <a href="busca.php?q=bolo+%26+caf%C3%A9&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). - &pagina=2
- Dentro do HTML, o
&que separa os parâmetros também é escrito&. 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 vai | Use | Exemplo |
|---|---|---|
| Texto no HTML | htmlspecialchars | <p><?= htmlspecialchars($nome) ?></p> |
| Atributo HTML | htmlspecialchars + aspas no atributo | value="<?= htmlspecialchars($v) ?>" |
| Valor de parâmetro numa URL | urlencode (ou http_build_query) | ?q=<?= urlencode($q) ?> |
| URL inteira vinda do visitante | validar o começo (https://) + htmlspecialchars | href="<?= htmlspecialchars($site) ?>" |
Dentro de <script> | json_encode com JSON_HEX_TAG | const nome = <?= json_encode($nome, JSON_HEX_TAG) ?>; (veja JSON) |
| SQL | Prepared statements, nunca escape manual | veja 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 < 7 e 8 > 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
- 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:
htmlspecialcharsno HTML,urlencodena 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
&, " ou < 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 & Jerry → Tom &amp; Jerry, e a pessoa vê Tom & Jerry.
Como resolver: escape uma vez só, na saída. Guarde no banco o texto original.
$ 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).
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ção | Para quê | Exemplo de saída |
|---|---|---|
htmlspecialchars($s) | Texto e atributos no HTML | <b> → <b> |
htmlentities($s) | Como a anterior, mas troca também acentos (desnecessário em UTF-8) | é → é |
urlencode($s) | Valor de parâmetro de URL | bolo & café → bolo+%26+caf%C3%A9 |
rawurlencode($s) | Pedaço do caminho de uma URL | espaço → %20 |
http_build_query($a) | Query string inteira | q=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:
2. Link de busca
Complete com a função que codifica o termo dentro da URL:
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 ?>&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) ?>&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.
htmlspecialcharstroca< > & " '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_tagsehtmlentitiesnão são a ferramenta para isso.- Regra de ouro: filtre na entrada, escape na saída, sem exceções.