Newsletter

6 modi per passare variabili da PHP a JavaScript senza XSS

6 modi per passare variabili da PHP a JavaScript senza XSS
Risposta rapida

Il metodo di default è un blocco letto con JSON.parse: i dati non vengono mai interpretati come codice e la pagina resta compatibile con una Content Security Policy stretta. Se scrivi il JSON direttamente dentro uno script, fallo senza apici e senza JSON.parse, con JSON_HEX_TAG e le opzioni vicine.

Far passare un valore da PHP a JavaScript è un’esigenza quotidiana, e la maggior parte dei metodi che si trovano online apre una falla XSS. Questo articolo mette a confronto sei approcci, mostra cosa produce ciascuno con un dato ostile e indica quale usare a seconda del caso.

Il nocciolo del problema

PHP gira sul server, JavaScript nel browser. L’unico punto di passaggio è l’HTML prodotto. Quando un dato PHP viene scritto dentro del JavaScript cambia contesto di interpretazione: quella che era una stringa diventa codice. Se il dato contiene i caratteri giusti, esce dalla stringa e diventa eseguibile.

Prendiamo un valore realistico, come uscirebbe da un database:

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

Contiene un apostrofo, un tag di chiusura e un tag di apertura. Ognuno rompe un metodo diverso.

1. json_encode dentro un blocco script

È il metodo più diffuso, ed è quasi sempre scritto male.

php
// Da non fare
<script>
  let d = JSON.parse('<?php echo json_encode($donnee); ?>');
</script>

Quello che riceve il browser:

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

Due rotture. L’apostrofo di «L’Écran» chiude la stringa JavaScript: errore di sintassi, la pagina non gira più. E la sequenza </script> chiude il blocco a livello del parser HTML, che delle stringhe JavaScript non sa nulla: lo <script>alert(1)</script> che segue diventa un vero blocco di script.

La forma corretta non ha né apici né JSON.parse(). Il JSON è già un’espressione JavaScript valida.

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

Risultato:

javascript
const donnees = {"nom":"L'Écran alert(1)","note":4.5};

Le quattro opzioni di escape trasformano <, >, &, ' e " in sequenze uXXXX. Nessuno di questi caratteri compare più alla lettera, quindi nessuno può chiudere alcunché. JSON_UNESCAPED_UNICODE mantiene leggibili le lettere accentate, il che alleggerisce l’output senza compromettere nulla.

2. Un blocco JSON separato

È il metodo che consigliamo di default. I dati escono dal codice e diventano contenuto.

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

Uno <script> con un type che il browser non conosce non viene eseguito: è un semplice contenitore di testo. JSON_HEX_TAG resta necessario per impedire che un </script> chiuda il blocco, ma il contenuto non viene mai interpretato come codice.

Questa forma ha un vantaggio decisivo: funziona con una Content Security Policy stretta. Un sito che vieta gli script inline per proteggersi dalle injection non può usare il metodo 1 senza aggiungerci un nonce o un’impronta.

3. Gli attributi data

Per qualche valore semplice legato a un elemento, l’attributo data-* è la soluzione più naturale.

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 è obbligatorio. Senza, un valore che contiene delle virgolette esce dall’attributo:

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

I valori di dataset sono sempre stringhe: la conversione in numero va fatta lato JavaScript.

4. I campi nascosti

Un <input type="hidden"> funziona, con lo stesso escape obbligatorio.

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

Serve soprattutto quando il valore deve tornare al server insieme al form. Per un semplice passaggio verso JavaScript l’attributo data-* è più pulito: non inquina l’invio del form.

ENT_SUBSTITUTE vale la pena aggiungerlo: senza, htmlspecialchars() restituisce una stringa vuota se il dato contiene una sequenza UTF-8 non valida. Un campo vuoto al posto di un valore è un bug difficile da rintracciare.

5. Una chiamata a un’API

Appena i dati sono voluminosi, mutevoli o dipendono da un’azione dell’utente, non hanno niente da fare nell’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();

In questo caso l’escape HTML non si pone più: la risposta non viene mai interpretata come HTML, a patto che l’header Content-Type sia corretto e accompagnato da nosniff.

Il costo è una richiesta in più. Per dati necessari al primo rendering, il metodo 2 evita l’andata e ritorno.

6. Accumulare più valori

Quando più parti dell’applicazione hanno ciascuna un valore da trasmettere, raccoglierli e scriverli in una volta sola evita di disseminare blocchi <script>. È il principio di Media::addJsDef() in 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);

Tenere i valori in un oggetto invece che in una variabile globale evita le collisioni di nomi e rende la classe testabile.

I metodi da scartare

Due approcci circolano e non andrebbero usati.

Passare dall’URL. Scrivere <a href="http://script.js?valeur=$v"> non ha senso: un file JavaScript viene servito così com’è, i suoi parametri d’URL non vengono letti. Quello che esiste davvero è leggere lato client i parametri della pagina corrente, ma allora il valore arriva dall’URL, non da PHP.

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

Passare da un cookie. Un cookie destinato a essere letto da JavaScript non può portare l’attributo HttpOnly, che è la protezione principale contro il furto di sessione. In più riparte a ogni richiesta, appesantendo tutto il traffico. Un cookie serve a conservare uno stato tra due richieste, non a trasmettere un dato nella pagina corrente.

Una precisazione sulla sicurezza

Si legge spesso che POST sarebbe «più sicuro» di GET. È falso. Entrambi trasportano i dati in chiaro se la connessione non è cifrata, ed entrambi sono modificabili dall’utente. La differenza sta altrove: i parametri GET compaiono nell’URL, quindi nella cronologia del browser, nei log del server e nell’header Referer. È una questione di tracce lasciate in giro, non di sicurezza del trasporto.

Il punto che vale per tutti e sei i metodi: tutto quello che arriva nel browser è visibile e modificabile dall’utente. Un prezzo, un identificativo di ruolo o un totale trasmesso a JavaScript va rivalidato lato server a ogni azione. Nessun dato inviato al client è degno di fiducia quando torna indietro.

Cosa scegliere

Esigenza Metodo
Un oggetto di configurazione al caricamento Blocco application/json
Qualche valore legato a un elemento Attributi data-*
Un valore che riparte con un form Campo nascosto
Dati voluminosi o caricati più tardi Chiamata a un’API
Più moduli che contribuiscono Un accumulatore, reso in una volta sola
Content Security Policy stretta Blocco application/json, mai script inline

Se i dati arrivano da un’API di terze parti, è una richiesta cURL a recuperarli lato server. L’accumulatore visto sopra applica i principi della programmazione a oggetti in PHP, e il redirect dopo l’elaborazione è spiegato in creare un redirect in PHP.

Vedi anche json_encode e la serializzazione degli oggetti PHP e l’hub Sviluppo web.

Errori frequenti

JSON.parse('<?= json_encode($x) ?>') Un apostrofo nel dato chiude la stringa JavaScript, e una sequenza </script> chiude il blocco a livello del parser HTML. Risultato: errore di sintassi e injection di script.
JSON_HEX_TAG dimenticato Senza, </script> compare alla lettera ed esce dal blocco, anche dentro uno <script type="application/json">.
Attributo senza htmlspecialchars Un valore che contiene delle virgolette esce dall'attributo e aggiunge un gestore di eventi.
ENT_SUBSTITUTE assente Su una sequenza UTF-8 non valida htmlspecialchars() restituisce una stringa vuota al posto del valore.
POST presentato come più sicuro di GET Falso. Senza TLS viaggiano in chiaro entrambi. La differenza sta nelle tracce: l'URL compare nella cronologia, nei log e nel Referer.
Cookie usato per passare un dato alla pagina Un cookie leggibile da JavaScript non può portare HttpOnly, e riparte a ogni richiesta.
Dato del client considerato affidabile Un prezzo o un identificativo di ruolo trasmesso a JavaScript è modificabile. Ogni valore che torna indietro va rivalidato lato server.

JavaScriptJSONPHPSécuritéXSS

Damien Flandrin Sviluppatore web dal 2010, creatore di Gekkode e di Email Impact. Ogni articolo è testato su un progetto reale prima della pubblicazione. Contatti
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.