Newsletter

6 formas de pasar variables de PHP a JavaScript sin XSS

6 formas de pasar variables de PHP a JavaScript sin XSS
Respuesta rápida

El método por defecto es un bloque leído con JSON.parse: los datos nunca se interpretan como código y la página sigue siendo compatible con una Content Security Policy estricta. Si escribes JSON directamente dentro de un script, hazlo sin comillas y sin JSON.parse, con JSON_HEX_TAG y sus opciones vecinas.

Pasar un valor de PHP a JavaScript es una necesidad diaria, y la mayoría de los métodos que circulan por internet abren una vulnerabilidad XSS. Este artículo compara seis enfoques, muestra lo que produce cada uno con un dato hostil e indica cuál usar en cada caso.

El fondo del problema

PHP se ejecuta en el servidor, JavaScript en el navegador. El único punto de paso es el HTML generado. Cuando un dato de PHP se escribe dentro de JavaScript, cambia de contexto de interpretación: lo que era una cadena de caracteres pasa a ser código. Si el dato contiene los caracteres adecuados, se sale de la cadena y se vuelve ejecutable.

Tomemos un valor realista, tal y como saldría de una base de datos:

php
$donnee = [
    'nom'  => "L'Écran </script><script>alert(1)</script>",
    'note' => 4.5,
];

Contiene un apóstrofo, una etiqueta de cierre y una etiqueta de apertura. Cada uno rompe un método distinto.

1. json_encode dentro de un bloque script

Es el método más extendido, y casi siempre está mal escrito.

php
// No hagas esto
<script>
  let d = JSON.parse('<?php echo json_encode($donnee); ?>');
</script>

Lo que recibe el navegador:

javascript
let d = JSON.parse('{"nom":"L'Écran </script><script>alert(1)</script>","note":4.5}');

Dos roturas. El apóstrofo de «L’Écran» cierra la cadena JavaScript: error de sintaxis, la página deja de ejecutarse. Y la secuencia </script> cierra el bloque a nivel del parser HTML, que no sabe nada de cadenas JavaScript: el <script>alert(1)</script> que viene detrás se convierte en un bloque de script de verdad.

La forma correcta no lleva ni comillas ni JSON.parse(). Un JSON ya es una expresión 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};

Las cuatro opciones de escapado convierten <, >, &, ' y " en secuencias uXXXX. Ninguno de esos caracteres aparece ya literalmente, así que ninguno puede cerrar nada. JSON_UNESCAPED_UNICODE mantiene los acentos legibles, lo que aligera la salida sin comprometer nada.

2. Un bloque JSON aparte

Es el método que recomendamos por defecto. Los datos salen del código y pasan a ser contenido.

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
);

Un <script> con un tipo que el navegador no conoce no se ejecuta: es un simple contenedor de texto. JSON_HEX_TAG sigue siendo necesario para impedir que un </script> cierre el bloque, pero el contenido nunca se interpreta como código.

Esta forma tiene una ventaja decisiva: funciona con una Content Security Policy estricta. Un sitio que prohíbe los scripts en línea para protegerse de las inyecciones no puede usar el método 1 sin añadirle un nonce o un hash.

3. Los atributos data

Para unos pocos valores simples asociados a un elemento, el atributo data-* es la solución más 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);

htmlspecialchars() con ENT_QUOTES es obligatorio. Sin él, un valor que contenga una comilla se sale del 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;">

Los valores de dataset son siempre cadenas: la conversión a número toca hacerla en el lado JavaScript.

4. Los campos ocultos

Un <input type="hidden"> funciona, con el mismo escapado obligatorio.

php
<input type="hidden" id="jeton"
       value="<?= htmlspecialchars($jeton, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">

Sirve sobre todo cuando el valor tiene que volver al servidor con el formulario. Para un simple paso hacia JavaScript, el atributo data-* es más limpio: no ensucia el envío del formulario.

Vale la pena añadir ENT_SUBSTITUTE: sin él, htmlspecialchars() devuelve una cadena vacía si el dato contiene una secuencia UTF-8 inválida. Un campo vacío en lugar de un valor es un bug difícil de localizar.

5. Una llamada a una API

En cuanto los datos son voluminosos, cambiantes o dependen de una acción del usuario, no pintan nada en el 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();

En ese caso el escapado HTML deja de plantearse: la respuesta nunca se interpreta como HTML, siempre que la cabecera Content-Type sea correcta y vaya acompañada de nosniff.

El coste es una petición más. Para datos necesarios en el primer pintado, el método 2 evita esa ida y vuelta.

6. Acumular varios valores

Cuando varias partes de la aplicación tienen cada una un valor que transmitir, recogerlos y escribirlos de una sola vez evita esparcir bloques <script>. Es el principio de Media::addJsDef() en 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);

Guardarlos en un objeto en lugar de en una variable global evita las colisiones de nombres y hace que la clase se pueda testear.

Los métodos que hay que descartar

Circulan dos enfoques que no deberían usarse.

Pasar por la URL. Escribir <a href="http://script.js?valeur=$v"> no tiene ningún sentido: un fichero JavaScript se sirve tal cual, sus parámetros de URL no se leen. Lo que sí existe es leer en el cliente los parámetros de la página actual, pero entonces el valor viene de la URL, no de PHP.

javascript
const page = new URLSearchParams(window.location.search).get('page');

Pasar por una cookie. Una cookie pensada para que la lea JavaScript no puede llevar el atributo HttpOnly, que es la principal protección contra el robo de sesión. Además vuelve a viajar en cada petición, lo que engorda todo el tráfico. Una cookie sirve para conservar un estado entre peticiones, no para transmitir un dato en la página actual.

Una precisión sobre seguridad

Se lee a menudo que POST sería «más seguro» que GET. Es falso. Los dos transportan los datos en claro si la conexión no está cifrada, y los dos los puede modificar el usuario. La diferencia está en otra parte: los parámetros GET aparecen en la URL y, por tanto, en el historial del navegador, en los registros del servidor y en la cabecera Referer. Es una cuestión de fuga por los rastros, no de seguridad del transporte.

El punto que vale para los seis métodos: todo lo que llega al navegador es visible y modificable por el usuario. Un precio, un identificador de rol o un total que pases a JavaScript hay que revalidarlo en el servidor en cada acción. Ningún dato enviado al cliente merece confianza cuando vuelve.

Qué elegir

Necesidad Método
Un objeto de configuración en la carga Bloque application/json
Unos pocos valores ligados a un elemento Atributos data-*
Un valor que vuelve con un formulario Campo oculto
Datos voluminosos o tardíos Llamada a una API
Varios módulos que aportan datos Un acumulador, renderizado de una vez
Content Security Policy estricta Bloque application/json, nunca script en línea

Si los datos vienen de una API de terceros, es una petición cURL la que los recupera en el servidor. El acumulador que acabas de ver aplica los principios de la POO en PHP, y la redirección después del tratamiento se detalla en crear una redirección en PHP.

Consulta también json_encode y la serialización de objetos PHP, y el hub Desarrollo web.

Errores frecuentes

JSON.parse('<?= json_encode($x) ?>') Un apóstrofo en el dato cierra la cadena JavaScript, y una secuencia </script> cierra el bloque a nivel del parser HTML. Resultado: error de sintaxis e inyección de script.
JSON_HEX_TAG olvidado Sin él, </script> aparece literalmente y sale del bloque, incluso dentro de un <script type="application/json">.
Atributo sin htmlspecialchars Un valor que contiene una comilla se sale del atributo y añade un manejador de evento.
ENT_SUBSTITUTE ausente Ante una secuencia UTF-8 inválida, htmlspecialchars() devuelve una cadena vacía en lugar del valor.
POST presentado como más seguro que GET Falso. Los dos viajan en claro sin TLS. La diferencia está en los rastros: la URL aparece en el historial, en los registros y en el Referer.
Cookie para pasar un dato a la página Una cookie legible por JavaScript no puede llevar HttpOnly, y vuelve a enviarse en cada petición.
Dato del cliente considerado fiable Un precio o un identificador de rol que pases a JavaScript se puede modificar. Todo valor que vuelva debe revalidarse en el servidor.

JavaScriptJSONPHPSécuritéXSS

Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.