Programação Por Equipe DevBras

Capa do artigo: Texto com entidade HTML aparecendo na tela: escape uma vez, só na saída

Texto com entidade HTML aparecendo na tela: escape uma vez, só na saída

Descubra por que o nome do produto aparece com &, pare de gravar texto escapado no banco, escape no template conforme o contexto e limpe os dados antigos com segurança.

A padaria cadastrou o produto "Pão & Cia Integral". Na vitrine do site aparece "Pão & Cia Integral". A atendente edita o produto para consertar, salva sem mexer em nada, e agora a vitrine mostra "Pão & Cia Integral". A cada edição, o nome fica mais estranho. Não é o teclado, não é o navegador: o texto está sendo escapado duas vezes.

A resposta curta: guarde no banco o texto exatamente como a pessoa digitou e escape uma única vez, no momento de colocá-lo no HTML, com a função adequada ao contexto. Com UTF-8 de ponta a ponta, você não precisa de entidade para acento nem para símbolo. Entidade fica para os poucos caracteres que têm significado na marcação, e quem cuida delas é a função de escape do template.

Por que o & aparece e cresce a cada edição

Escapar é trocar caracteres que o HTML interpreta por sequências que ele mostra literalmente: o & vira &amp;, o < vira &lt;, as aspas viram &quot;. O navegador lê &amp; e desenha um &. Uma camada de escape, um caractere na tela. Perfeito.

O problema começa quando alguém, com boa intenção, escapa na entrada "para evitar XSS". Veja o caminho do nome da padaria:

  1. O formulário envia Pão & Cia Integral.
  2. O controlador aplica htmlspecialchars antes do INSERT. O banco guarda Pão &amp; Cia Integral.
  3. O template, corretamente, escapa de novo na saída. O HTML recebe Pão &amp;amp; Cia Integral.
  4. O navegador desfaz uma camada e mostra Pão &amp; Cia Integral.

Na edição, o campo do formulário recebe o valor do banco, já escapado. O navegador mostra &amp; dentro do campo, a atendente salva, o controlador escapa de novo e o banco ganha mais uma camada. É por isso que o texto piora a cada salvamento.

Há um segundo prejuízo, menos visível: tudo que lê o banco sem passar pelo HTML recebe lixo. O aplicativo, o JSON da API, a planilha exportada, o e-mail em texto puro, a busca. Todos passam a mostrar entidades, e cada um ganha um "conserto" local.

Entidade não é remédio para acento quebrado

A outra confusão comum é usar entidade para mascarar problema de encoding. O título aparece com um losango no lugar do "ã", alguém troca "ã" por &atilde; na mão e o losango some. O sintoma foi embora; a causa ficou. Arquivo, banco, conexão e página estão em encodings diferentes, e o próximo texto vindo de outra fonte quebra de novo.

A correção é UTF-8 em todas as camadas, como explicam os posts sobre acentos no HTML e encoding de ponta a ponta. Com isso resolvido, "ã", "€" e "→" vão direto no texto, como caracteres Unicode. Entidade nomeada para acento é resquício de quando as páginas não declaravam encoding.

Como escapar do jeito certo em PHP

  1. Remova todo escape da entrada. Valide formato e tamanho, use consultas parametrizadas, e grave o texto como veio.
  2. Crie uma função de escape para HTML e use-a em todo valor dinâmico no template.
  3. Escape conforme o contexto: HTML e atributos com a função de escape, JavaScript com json_encode, parâmetro de URL com rawurlencode.
  4. Corrija os dados já gravados, com backup, decodificando uma camada por vez.
  5. Teste com um texto que tenha &, aspas e <, salvando e reabrindo várias vezes.

A função de escape e o template

<?php
function e(?string $texto): string
{
    return htmlspecialchars($texto ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<h2><?= e($produto['nome']) ?></h2>
<input name="nome" value="<?= e($produto['nome']) ?>">
<script>
  const produto = <?= json_encode(['id' => $produto['id'], 'nome' => $produto['nome']], JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?>;
</script>

ENT_QUOTES escapa aspas simples e duplas, o que protege valores dentro de atributos. ENT_SUBSTITUTE troca sequências inválidas de UTF-8 por um caractere de substituição em vez de devolver texto vazio. Dentro de <script>, entidade HTML não serve: o JavaScript recebe o texto literal, então o certo é json_encode, com as flags que impedem um </script> no meio do dado de fechar o bloco.

Se você usa um motor de templates como Twig ou Blade, o escape automático na saída já faz esse papel. Nesse caso, o escape manual no controlador é exatamente a segunda camada que causa o problema.

Limpando os nomes já gravados

Faça backup da tabela antes. Depois, decodifique uma camada nos registros que contêm entidades. Rode, confira uma amostra e repita até a consulta não trazer mais linhas, porque produtos editados muitas vezes têm várias camadas.

<?php
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'));

$linhas = $pdo->query(
    "SELECT id, nome FROM produtos
     WHERE nome LIKE '%&amp;%' OR nome LIKE '%&quot;%' OR nome LIKE '%&#039;%'
        OR nome LIKE '%&lt;%' OR nome LIKE '%&gt;%'"
)->fetchAll(PDO::FETCH_ASSOC);

$atualiza = $pdo->prepare('UPDATE produtos SET nome = ? WHERE id = ?');
foreach ($linhas as $linha) {
    $limpo = html_entity_decode($linha['nome'], ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $atualiza->execute([$limpo, $linha['id']]);
    echo $linha['id'] . ': ' . $linha['nome'] . ' => ' . $limpo . PHP_EOL;
}

O script imprime o antes e o depois de cada registro, para você revisar. Só rode depois de remover o escape da entrada; senão a próxima edição recria o problema. Se algum produto legitimamente contém a sequência &amp; no nome, ele aparece na lista e você trata à mão.

Erros comuns

  • Escapar na entrada e na saída: o texto ganha uma camada de entidade a cada salvamento e aparece corrompido em todos os canais.
  • Usar double_encode = false como solução: esconde o sintoma, mantém o banco sujo e ainda impede que alguém escreva &amp; de propósito.
  • Trocar acento por entidade para "consertar" encoding: o próximo texto de outra origem quebra igual.
  • Entidade HTML dentro de JavaScript ou URL: cada contexto tem a sua regra, e o escape errado abre brecha ou quebra o dado.
  • Esquecer o escape em um único template: basta um campo sem escape para virar XSS.
  • Limpar o banco sem backup e sem revisar: uma decodificação a mais altera textos que estavam certos.

Checklist

  • Nenhum controlador aplica htmlspecialchars antes de gravar.
  • Todo valor dinâmico no HTML passa pela função de escape ou pelo escape automático do template.
  • Dados para JavaScript saem com json_encode, não com entidade.
  • Banco, conexão, arquivos e página estão em UTF-8.
  • A consulta por entidades na tabela de produtos retorna zero linhas.
  • Um produto com &, aspas e < no nome sobrevive a três edições seguidas sem mudar.
  • A API devolve o nome sem entidades.

Abra o cadastro de qualquer coisa no seu sistema, grave um nome com &, salve três vezes sem mexer e recarregue a vitrine: continua com um só & na tela?

Avalie este artigo

Referência: https://unicode-table.com/pt/html-entities/