
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:
$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.
// No hagas esto
<script>
let d = JSON.parse('<?php echo json_encode($donnee); ?>');
</script>Lo que recibe el navegador:
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.
<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};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.
<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
);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.
<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);htmlspecialchars() con ENT_QUOTES es obligatorio. Sin él, un valor que contenga una comilla se sale del 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="">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.
<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.
<?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();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.
<?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);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.
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
</script> cierra el bloque a nivel del parser HTML. Resultado: error de sintaxis e inyección de script.</script> aparece literalmente y sale del bloque, incluso dentro de un <script type="application/json">.htmlspecialchars() devuelve una cadena vacía en lugar del valor.HttpOnly, y vuelve a enviarse en cada petición.

