PHP naar JavaScript: 6 manieren om variabelen door te geven

PHP naar JavaScript: 6 manieren om variabelen door te geven
Kort antwoord

De standaardmethode is een blok dat je met JSON.parse uitleest: de data wordt nooit als code geïnterpreteerd en de pagina blijft compatibel met een strikt Content Security Policy. Schrijf je JSON rechtstreeks in een script, doe dat dan zonder aanhalingstekens en zonder JSON.parse, met JSON_HEX_TAG en de verwante opties.

Een waarde van PHP naar JavaScript doorgeven is dagelijkse kost, en de meeste methoden die je online vindt, openen een XSS-lek. Dit artikel vergelijkt zes aanpakken, laat zien wat elke aanpak met vijandige data oplevert en geeft aan welke je per geval gebruikt.

De kern van het probleem

PHP draait op de server, JavaScript in de browser. Het enige doorgeefluik is de HTML die eruit komt. Zodra PHP-data in JavaScript wordt geschreven, verandert de interpretatiecontext: wat een string was, wordt code. Bevat de data de juiste tekens, dan breekt ze uit die string en wordt ze uitvoerbaar.

Neem een realistische waarde, zoals ze uit een database zou komen:

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

Ze bevat een apostrof, een sluitende tag en een openende tag. Elk daarvan breekt een andere methode.

1. json_encode in een scriptblok

Dit is de bekendste methode, en ze wordt bijna altijd fout geschreven.

php
// Zo moet het niet
<script>
  let d = JSON.parse('<?php echo json_encode($donnee); ?>');
</script>

Wat de browser te zien krijgt:

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

Twee breuken. De apostrof van “L’Écran” sluit de JavaScript-string: syntaxisfout, de pagina draait niet meer. En de reeks </script> sluit het blok op het niveau van de HTML-parser, die niets van JavaScript-strings weet: de <script>alert(1)</script> die volgt, wordt een echt scriptblok.

De juiste vorm heeft geen aanhalingstekens en geen JSON.parse(). JSON is al een geldige JavaScript-expressie.

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

Resultaat:

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

De vier escape-opties zetten <, >, &, ' en " om in uXXXX-reeksen. Geen van die tekens komt nog letterlijk voor, dus geen ervan kan nog iets afsluiten. JSON_UNESCAPED_UNICODE houdt de accenten leesbaar, wat de uitvoer lichter maakt zonder iets prijs te geven.

2. Een apart JSON-blok

Dit is de methode die we standaard aanraden. De data verlaat de code en wordt content.

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

Een <script> met een type dat de browser niet kent, wordt niet uitgevoerd: het is niet meer dan een tekstcontainer. JSON_HEX_TAG blijft nodig om te verhinderen dat een </script> het blok afsluit, maar de inhoud wordt nooit als code geïnterpreteerd.

Deze vorm heeft een doorslaggevend voordeel: ze werkt onder een strikt Content Security Policy. Een site die inline scripts verbiedt om zich tegen injecties te beschermen, kan methode 1 niet gebruiken zonder er een nonce of een hash aan toe te voegen.

3. De data-attributen

Voor een paar eenvoudige waarden die aan een element hangen, is het attribuut data-* de meest natuurlijke oplossing.

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() met ENT_QUOTES is verplicht. Zonder die optie breekt een waarde met een aanhalingsteken uit het attribuut:

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

De waarden uit dataset zijn altijd strings: de omzetting naar een getal doe je aan de JavaScript-kant.

4. Verborgen velden

Een <input type="hidden"> werkt, met dezelfde verplichte escaping.

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

Dat is vooral nuttig wanneer de waarde met het formulier weer naar de server moet. Voor een simpele doorgifte naar JavaScript is het attribuut data-* netter: het vervuilt de formulierverzending niet.

ENT_SUBSTITUTE is het toevoegen waard: zonder die optie geeft htmlspecialchars() een lege string terug zodra de data een ongeldige UTF-8-reeks bevat. Een leeg veld in plaats van een waarde is een bug die je maar moeilijk terugvindt.

5. Een API-aanroep

Zodra de data omvangrijk is, vaak verandert of van een actie van de gebruiker afhangt, heeft ze niets in de HTML te zoeken.

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 dat geval speelt HTML-escaping geen rol meer: het antwoord wordt nooit als HTML geïnterpreteerd, op voorwaarde dat de header Content-Type klopt en vergezeld gaat van nosniff.

De prijs is een extra request. Voor data die je al bij de eerste weergave nodig hebt, vermijdt methode 2 dat heen en weer.

6. Meerdere waarden verzamelen

Wanneer verschillende delen van de applicatie elk een waarde door te geven hebben, voorkomt ze verzamelen en in één keer wegschrijven dat je <script>-blokken over de pagina uitsmeert. Dat is het principe van 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);

Opslaan in een object in plaats van in een globale variabele voorkomt naamconflicten en maakt de klasse testbaar.

Twee aanpakken circuleren en horen niet gebruikt te worden.

Via de URL. <a href="http://script.js?valeur=$v"> schrijven slaat nergens op: een JavaScript-bestand wordt geserveerd zoals het is, zijn URL-parameters worden niet gelezen. Wat wel bestaat, is de parameters van de huidige pagina aan de clientkant uitlezen, maar dan komt de waarde uit de URL en niet uit PHP.

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

Via een cookie. Een cookie dat door JavaScript gelezen moet worden, kan het attribuut HttpOnly niet dragen, en dat is de belangrijkste bescherming tegen sessiediefstal. Het gaat bovendien bij elke request weer mee, wat al het verkeer verzwaart. Een cookie dient om een status tussen twee requests te bewaren, niet om data door te geven binnen de huidige pagina.

Een verduidelijking over beveiliging

Je leest vaak dat POST “veiliger” zou zijn dan GET. Dat klopt niet. Beide vervoeren de data leesbaar als de verbinding niet versleuteld is, en beide zijn door de gebruiker aan te passen. Het verschil zit elders: GET-parameters staan in de URL, en dus in de browsergeschiedenis, in de serverlogs en in de header Referer. Het gaat om lekken via sporen, niet om de beveiliging van het transport.

Wat voor alle zes de methoden geldt: alles wat in de browser aankomt, is voor de gebruiker zichtbaar en aanpasbaar. Een prijs, een rol-id of een totaal dat je naar JavaScript stuurt, moet bij elke actie opnieuw aan de serverkant worden gevalideerd. Geen enkele waarde die je naar de client stuurt, is bij terugkeer te vertrouwen.

Wat kies je

Behoefte Methode
Een configuratieobject bij het laden Blok application/json
Een paar waarden die bij een element horen Attributen data-*
Een waarde die met een formulier terugkeert Verborgen veld
Omvangrijke of laat opgehaalde data Een API-aanroep
Meerdere modules die elk bijdragen Een accumulator, in één keer gerenderd
Strikt Content Security Policy Blok application/json, nooit een inline script

Komt de data van een externe API, dan haalt een cURL-request ze aan de serverkant op. De accumulator hierboven past de principes van OOP in PHP toe, en de redirect na verwerking staat uitgewerkt in een redirect maken in PHP.

Zie ook json_encode en de serialisatie van PHP-objecten, en de hub Webontwikkeling.

Veelgemaakte fouten

JSON.parse('<?= json_encode($x) ?>') Een apostrof in de data sluit de JavaScript-string, en een reeks </script> sluit het blok op het niveau van de HTML-parser. Resultaat: syntaxisfout en scriptinjectie.
JSON_HEX_TAG vergeten Zonder die optie verschijnt </script> letterlijk en breekt het uit het blok, ook in een <script type="application/json">.
Attribuut zonder htmlspecialchars Een waarde met een aanhalingsteken breekt uit het attribuut en voegt een event handler toe.
ENT_SUBSTITUTE ontbreekt Bij een ongeldige UTF-8-reeks geeft htmlspecialchars() een lege string terug in plaats van de waarde.
POST voorgesteld als veiliger dan GET Onjuist. Beide reizen leesbaar zonder TLS. Het verschil zit in de sporen: de URL komt in de geschiedenis, in de logs en in de Referer terecht.
Cookie om data aan de pagina door te geven Een cookie dat JavaScript kan lezen, kan geen HttpOnly dragen, en het gaat bij elke request weer mee.
Data van de client als betrouwbaar beschouwen Een prijs of een rol-id die je naar JavaScript stuurt, is aanpasbaar. Elke waarde die terugkomt, moet aan de serverkant opnieuw worden gevalideerd.

JavaScriptJSONPHPSécuritéXSS

Damien Flandrin Webdeveloper sinds 2010, maker van Gekkode en Email Impact. Elk artikel wordt vóór publicatie getest op een echt project. Contact
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.