6 formas de passar variáveis de PHP para JavaScript sem XSS

6 formas de passar variáveis de PHP para JavaScript sem XSS
Resposta rápida

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:

php
$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.

php
// A não fazer
<script>
  let d = JSON.parse('<?php echo json_encode($donnee); ?>');
</script>

O que o navegador recebe:

javascript
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.

php
<script>
  const donnees = <?= json_encode($donnee,
      JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE
  ) ?>;
</script>

Resultado:

javascript
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.

php
<script type="application/json" id="donnees-page">
  <?= json_encode($donnee, JSON_HEX_TAG | JSON_UNESCAPED_UNICODE) ?>
</script>
javascript
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.

php
<div id="produit"
     data-id="<?= htmlspecialchars((string) $produit['id'], ENT_QUOTES, 'UTF-8') ?>"
     data-prix="<?= htmlspecialchars((string) $produit['prix'], ENT_QUOTES, 'UTF-8') ?>">
</div>
javascript
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:

code
valeur brute : valeur" onfocus="alert(1)" autofocus x="
sans échappement : <input value="valeur" onfocus="alert(1)" autofocus x="">
avec échappement : <input value="valeur&quot; onfocus=&quot;alert(1)&quot; autofocus x=&quot;">

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.

php
<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.

api/produits.php
<?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,
);
javascript
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.

src/JsDefs.php
<?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),
        );
    }
}
php
$defs = new JsDefs();
$defs->ajouter('urlPanier', '/panier');
$defs->ajouter('devise', '€');
$defs->ajouter('utilisateurConnecte', false);

echo $defs->rendre();
javascript
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.

javascript
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

JSON.parse('<?= json_encode($x) ?>') Um apóstrofo no dado fecha a cadeia JavaScript, e uma sequência </script> fecha o bloco ao nível do parser HTML. Resultado: erro de sintaxe e injeção de script.
JSON_HEX_TAG esquecido Sem ele, </script> aparece literalmente e sai do bloco, mesmo dentro de um <script type="application/json">.
Atributo sem htmlspecialchars Um valor que contenha aspas sai do atributo e acrescenta um handler de evento.
ENT_SUBSTITUTE em falta Numa sequência UTF-8 inválida, o htmlspecialchars() devolve uma cadeia vazia em vez do valor.
POST apresentado como mais seguro do que GET Falso. Ambos viajam em claro sem TLS. A diferença está nos rastos: o URL aparece no histórico, nos registos e no Referer.
Cookie para passar um dado à página Um cookie legível por JavaScript não pode ter HttpOnly e volta a seguir em cada pedido.
Dado do cliente considerado fiável Um preço ou um identificador de perfil transmitidos ao JavaScript são modificáveis. Qualquer valor que regresse tem de ser revalidado do lado do servidor.

JavaScriptJSONPHPSécuritéXSS

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.