
O método por predefinição é um bloco lido com JSON.parse: os dados nunca são interpretados como código e a página continua compatível com uma política de segurança de conteúdo estrita. Se escreveres JSON diretamente dentro de um script, fá-lo sem aspas e sem JSON.parse, com JSON_HEX_TAG e as opções vizinhas.
Passar um valor de PHP para JavaScript é uma necessidade diária, e a maioria dos métodos que circulam por aí abre uma falha XSS. Este artigo compara seis abordagens, mostra o que cada uma produz com um dado hostil e indica qual usar em cada caso.
A raiz do problema
O PHP é executado no servidor, o JavaScript no navegador. O único ponto de passagem é o HTML produzido. Quando um dado PHP é escrito dentro de JavaScript, muda de contexto de interpretação: o que era uma cadeia de caracteres passa a ser código. Se esse dado contiver os caracteres certos, sai da cadeia e torna-se executável.
Vejamos um valor realista, tal como sairia de uma base de dados:
$donnee = [
'nom' => "L'Écran </script><script>alert(1)</script>",
'note' => 4.5,
];Contém um apóstrofo, uma tag de fecho e uma tag de abertura. Cada um deles parte um método diferente.
1. json_encode dentro de um bloco script
É o método mais comum e está quase sempre mal escrito.
// A não fazer
<script>
let d = JSON.parse('<?php echo json_encode($donnee); ?>');
</script>O que o navegador recebe:
let d = JSON.parse('{"nom":"L'Écran </script><script>alert(1)</script>","note":4.5}');Duas ruturas. O apóstrofo de «L’Écran» fecha a cadeia JavaScript: erro de sintaxe, a página deixa de ser executada. E a sequência </script> fecha o bloco ao nível do parser HTML, que não conhece as cadeias JavaScript: o <script>alert(1)</script> que se segue passa a ser um bloco de script a sério.
A forma correta não leva aspas nem JSON.parse(). JSON já é, por si só, uma expressão JavaScript válida.
<script>
const donnees = <?= json_encode($donnee,
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE
) ?>;
</script>Resultado:
const donnees = {"nom":"L'Écran alert(1)","note":4.5};As quatro opções de escape transformam <, >, &, ' e " em sequências uXXXX. Nenhum destes caracteres aparece literalmente, por isso nenhum consegue fechar seja o que for. O JSON_UNESCAPED_UNICODE mantém os acentos legíveis, o que aligeira a saída sem comprometer nada.
2. Um bloco JSON separado
É o método que recomendamos por predefinição. Os dados saem do código e passam a ser conteúdo.
<script type="application/json" id="donnees-page">
<?= json_encode($donnee, JSON_HEX_TAG | JSON_UNESCAPED_UNICODE) ?>
</script>const donnees = JSON.parse(
document.getElementById('donnees-page').textContent
);Um <script> com um tipo que o navegador desconhece não é executado: é apenas um contentor de texto. O JSON_HEX_TAG continua a ser necessário para impedir que um </script> feche o bloco, mas o conteúdo nunca é interpretado como código.
Esta forma tem uma vantagem decisiva: funciona com uma política de segurança de conteúdo estrita. Um site que proíbe os scripts em linha para se proteger de injeções não pode usar o método 1 sem lhe juntar um nonce ou um hash.
3. Os atributos data
Para alguns valores simples associados a um elemento, o atributo data-* é a solução mais natural.
<div id="produit"
data-id="<?= htmlspecialchars((string) $produit['id'], ENT_QUOTES, 'UTF-8') ?>"
data-prix="<?= htmlspecialchars((string) $produit['prix'], ENT_QUOTES, 'UTF-8') ?>">
</div>const el = document.getElementById('produit');
const id = Number(el.dataset.id);
const prix = parseFloat(el.dataset.prix);O htmlspecialchars() com ENT_QUOTES é obrigatório. Sem ele, um valor que contenha aspas sai do atributo:
valeur brute : valeur" onfocus="alert(1)" autofocus x="
sans échappement : <input value="valeur" onfocus="alert(1)" autofocus x="">
avec échappement : <input value="valeur" onfocus="alert(1)" autofocus x="">Os valores de dataset são sempre cadeias: a conversão para número faz-se do lado do JavaScript.
4. Os campos ocultos
Um <input type="hidden"> funciona, com o mesmo escape obrigatório.
<input type="hidden" id="jeton"
value="<?= htmlspecialchars($jeton, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">É sobretudo útil quando o valor tem de voltar ao servidor com o formulário. Para uma simples passagem para JavaScript, o atributo data-* é mais limpo: não polui o envio do formulário.
Vale a pena acrescentar o ENT_SUBSTITUTE: sem ele, o htmlspecialchars() devolve uma cadeia vazia se o dado contiver uma sequência UTF-8 inválida. Um campo vazio em vez de um valor é um bug difícil de localizar.
5. Uma chamada a uma API
A partir do momento em que os dados são volumosos, mudam com frequência ou dependem de uma ação do utilizador, não têm nada que fazer no HTML.
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
echo json_encode(
['produits' => $produits],
JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
);const reponse = await fetch('/api/produits.php', {
headers: { 'Accept': 'application/json' },
});
if (!reponse.ok) {
throw new Error(`HTTP ${reponse.status}`);
}
const { produits } = await reponse.json();Neste caso, o escape HTML deixa de se colocar: a resposta nunca é interpretada como HTML, desde que o cabeçalho Content-Type esteja correto e acompanhado de nosniff.
O custo é um pedido adicional. Para dados necessários logo no primeiro carregamento, o método 2 evita essa ida e volta.
6. Acumular vários valores
Quando várias partes da aplicação têm, cada uma, um valor a transmitir, recolhê-los e escrevê-los de uma só vez evita espalhar blocos <script>. É o princípio do Media::addJsDef() no PrestaShop.
<?php
declare(strict_types=1);
final class JsDefs
{
/** @var array<string, mixed> */
private array $valeurs = [];
public function ajouter(string $nom, mixed $valeur): void
{
$this->valeurs[$nom] = $valeur;
}
public function rendre(string $id = 'js-defs'): string
{
return sprintf(
'<script type="application/json" id="%s">%s</script>',
htmlspecialchars($id, ENT_QUOTES, 'UTF-8'),
json_encode($this->valeurs, JSON_HEX_TAG | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
);
}
}$defs = new JsDefs();
$defs->ajouter('urlPanier', '/panier');
$defs->ajouter('devise', '€');
$defs->ajouter('utilisateurConnecte', false);
echo $defs->rendre();const defs = JSON.parse(document.getElementById('js-defs').textContent);
console.log(defs.urlPanier, defs.devise, defs.utilisateurConnecte);Guardar num objeto em vez de numa variável global evita as colisões de nomes e torna a classe testável.
Os métodos a pôr de lado
Há duas abordagens que circulam e que não deveriam ser usadas.
Passar pelo URL. Escrever <a href="http://script.js?valeur=$v"> não faz sentido: um ficheiro JavaScript é servido tal e qual, os seus parâmetros de URL não são lidos. O que existe mesmo é ler os parâmetros da página atual do lado do cliente, mas então o valor vem do URL, não do PHP.
const page = new URLSearchParams(window.location.search).get('page');Passar por um cookie. Um cookie destinado a ser lido por JavaScript não pode ter o atributo HttpOnly, que é a principal proteção contra o roubo de sessão. Além disso, volta a seguir em cada pedido, o que sobrecarrega todo o tráfego. Um cookie serve para conservar um estado entre pedidos, não para transmitir um dado na página atual.
Um esclarecimento sobre segurança
Lê-se muitas vezes que o POST seria «mais seguro» do que o GET. É falso. Ambos transportam os dados em claro se a ligação não estiver cifrada, e ambos são modificáveis pelo utilizador. A diferença está noutro lado: os parâmetros GET aparecem no URL, portanto no histórico do navegador, nos registos do servidor e no cabeçalho Referer. É uma questão de fuga pelos rastos, não de segurança do transporte.
O ponto que vale para os seis métodos: tudo o que chega ao navegador é visível e modificável pelo utilizador. Um preço, um identificador de perfil ou um total transmitidos ao JavaScript têm de ser revalidados do lado do servidor a cada ação. Nenhum dado enviado ao cliente é digno de confiança quando regressa.
O que escolher
| Necessidade | Método |
|---|---|
| Um objeto de configuração no carregamento | Bloco application/json |
| Alguns valores ligados a um elemento | Atributos data-* |
| Um valor que regressa com um formulário | Campo oculto |
| Dados volumosos ou tardios | Chamada a uma API |
| Vários módulos que contribuem | Um acumulador, escrito de uma só vez |
| Política de segurança de conteúdo estrita | Bloco application/json, nunca script em linha |
Se os dados vêm de uma API de terceiros, é um pedido cURL que os vai buscar do lado do servidor. O acumulador apresentado acima aplica os princípios da POO em PHP, e o redirecionamento depois do processamento está detalhado em criar um redirecionamento em PHP.
Vê também json_encode e a serialização de objetos PHP e o hub Desenvolvimento web.
Erros frequentes
</script> fecha o bloco ao nível do parser HTML. Resultado: erro de sintaxe e injeção de script.</script> aparece literalmente e sai do bloco, mesmo dentro de um <script type="application/json">.htmlspecialchars() devolve uma cadeia vazia em vez do valor.HttpOnly e volta a seguir em cada pedido.

