
Die Standardmethode ist ein Block , den du mit JSON.parse ausliest: Die Daten werden nie als Code interpretiert und die Seite bleibt mit einer strengen Content Security Policy verträglich. Wenn du JSON direkt in ein Skript schreibst, dann ohne Anführungszeichen und ohne JSON.parse, mit JSON_HEX_TAG und den verwandten Optionen.
Einen Wert von PHP an JavaScript zu übergeben, ist Alltag, und die meisten Rezepte, die im Netz kursieren, reißen eine XSS-Lücke auf. Dieser Artikel vergleicht sechs Ansätze, zeigt für jeden, was bei einem bösartigen Wert herauskommt, und sagt, welcher wann passt.
Der Kern des Problems
PHP läuft auf dem Server, JavaScript im Browser. Der einzige Übergang ist das erzeugte HTML. Sobald ein PHP-Wert in JavaScript geschrieben wird, wechselt er den Interpretationskontext: Was ein String war, wird zu Code. Enthält der Wert die passenden Zeichen, bricht er aus dem String aus und wird ausführbar.
Nimm einen realistischen Wert, wie er aus einer Datenbank kommen würde:
$donnee = [
'nom' => "L'Écran </script><script>alert(1)</script>",
'note' => 4.5,
];Er enthält ein Apostroph, ein schließendes und ein öffnendes Tag. Jedes davon bricht eine andere Methode.
1. json_encode in einem script-Block
Das ist die verbreitetste Methode, und sie ist fast immer falsch geschrieben.
// So nicht
<script>
let d = JSON.parse('<?php echo json_encode($donnee); ?>');
</script>Was der Browser bekommt:
let d = JSON.parse('{"nom":"L'Écran </script><script>alert(1)</script>","note":4.5}');Zwei Brüche. Das Apostroph in „L’Écran“ schließt den JavaScript-String: Syntaxfehler, die Seite läuft nicht mehr. Und die Sequenz </script> schließt den Block auf Ebene des HTML-Parsers, der von JavaScript-Strings nichts weiß: Das folgende <script>alert(1)</script> wird zu einem echten Skriptblock.
Die korrekte Form kommt ohne Anführungszeichen und ohne JSON.parse() aus. JSON ist bereits ein gültiger JavaScript-Ausdruck.
<script>
const donnees = <?= json_encode($donnee,
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE
) ?>;
</script>Ergebnis:
const donnees = {"nom":"L'Écran alert(1)","note":4.5};Die vier Escaping-Optionen verwandeln <, >, &, ' und " in uXXXX-Sequenzen. Keines dieser Zeichen taucht noch wörtlich auf, also kann auch keines mehr etwas schließen. JSON_UNESCAPED_UNICODE hält die Akzente lesbar, was die Ausgabe entlastet, ohne etwas aufzugeben.
2. Ein separater JSON-Block
Das ist die Methode, die wir standardmäßig empfehlen. Die Daten verlassen den Code und werden zu Inhalt.
<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
);Ein <script> mit einem Typ, den der Browser nicht kennt, wird nicht ausgeführt: Es ist ein reiner Textcontainer. JSON_HEX_TAG bleibt nötig, damit ein </script> den Block nicht schließt, aber der Inhalt wird nie als Code interpretiert.
Diese Form hat einen entscheidenden Vorteil: Sie funktioniert mit einer strengen Content Security Policy. Eine Site, die Inline-Skripte verbietet, um sich gegen Injektionen zu schützen, kann Methode 1 nur mit einer Nonce oder einem Hash einsetzen.
3. Die data-Attribute
Für ein paar einfache Werte, die an einem Element hängen, ist das Attribut data-* die natürlichste Lösung.
<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() mit ENT_QUOTES ist Pflicht. Ohne das bricht ein Wert mit einem Anführungszeichen aus dem Attribut aus:
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="">Die Werte aus dataset sind immer Strings: Die Umwandlung in eine Zahl erledigst du auf der JavaScript-Seite.
4. Versteckte Formularfelder
Ein <input type="hidden"> funktioniert, mit demselben Pflicht-Escaping.
<input type="hidden" id="jeton"
value="<?= htmlspecialchars($jeton, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">Nützlich ist das vor allem, wenn der Wert mit dem Formular zurück zum Server soll. Für die reine Übergabe an JavaScript ist das Attribut data-* sauberer: Es verschmutzt den Formularversand nicht.
ENT_SUBSTITUTE gehört dazu: Ohne dieses Flag gibt htmlspecialchars() einen leeren String zurück, sobald der Wert eine ungültige UTF-8-Sequenz enthält. Ein leeres Feld statt eines Werts ist ein Bug, den man nur schwer wiederfindet.
5. Ein API-Aufruf
Sobald die Daten umfangreich oder wechselhaft sind oder von einer Nutzeraktion abhängen, haben sie im HTML nichts verloren.
<?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();Damit stellt sich die Frage nach dem HTML-Escaping nicht mehr: Die Antwort wird nie als HTML interpretiert, sofern der Header Content-Type stimmt und von nosniff begleitet wird.
Der Preis ist ein zusätzlicher Request. Für Daten, die schon beim ersten Rendern gebraucht werden, spart Methode 2 diesen Umweg.
6. Mehrere Werte sammeln
Wenn mehrere Teile der Anwendung jeweils einen Wert beisteuern, verhindert das Sammeln und einmalige Ausgeben, dass sich <script>-Blöcke über die Seite verteilen. Nach diesem Prinzip arbeitet Media::addJsDef() in 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);Die Ablage in einem Objekt statt in einer globalen Variablen vermeidet Namenskollisionen und macht die Klasse testbar.
Methoden, die du meiden solltest
Zwei Ansätze kursieren, die niemand einsetzen sollte.
Der Umweg über die URL. <a href="http://script.js?valeur=$v"> zu schreiben, ergibt keinen Sinn: Eine JavaScript-Datei wird unverändert ausgeliefert, ihre URL-Parameter liest niemand. Was es wirklich gibt, ist das Auslesen der Parameter der aktuellen Seite im Client, aber dann stammt der Wert aus der URL und nicht aus PHP.
const page = new URLSearchParams(window.location.search).get('page');Der Umweg über ein Cookie. Ein Cookie, das JavaScript lesen soll, kann das Attribut HttpOnly nicht tragen, den wichtigsten Schutz gegen Session-Diebstahl. Außerdem geht es bei jedem Request wieder mit, was den gesamten Traffic aufbläht. Ein Cookie hält einen Zustand zwischen zwei Requests fest, es transportiert keinen Wert innerhalb der aktuellen Seite.
Eine Klarstellung zur Sicherheit
Oft liest man, POST sei „sicherer“ als GET. Das stimmt nicht. Beide übertragen die Daten im Klartext, wenn die Verbindung nicht verschlüsselt ist, und beide lassen sich vom Nutzer verändern. Der Unterschied liegt woanders: GET-Parameter stehen in der URL, also im Browserverlauf, in den Server-Logs und im Header Referer. Es geht um Spuren, die durchsickern, nicht um die Sicherheit des Transports.
Was für alle sechs Methoden gilt: Alles, was im Browser ankommt, ist für den Nutzer sichtbar und veränderbar. Ein Preis, eine Rollen-ID oder eine Summe, die an JavaScript geht, muss bei jeder Aktion serverseitig erneut geprüft werden. Keine Angabe, die an den Client ging, verdient auf dem Rückweg Vertrauen.
Welche Methode wann
| Bedarf | Methode |
|---|---|
| Ein Konfigurationsobjekt beim Laden | application/json-Block |
| Ein paar Werte an einem Element | data-*-Attribute |
| Ein Wert, der mit einem Formular zurückgeht | Verstecktes Feld |
| Umfangreiche oder nachgeladene Daten | API-Aufruf |
| Mehrere Module, die beisteuern | Ein Sammler, einmal ausgegeben |
| Strenge Content Security Policy | application/json-Block, nie ein Inline-Skript |
Kommen die Daten von einer fremden API, holt sie eine cURL-Anfrage serverseitig ab. Der oben gezeigte Sammler wendet die Prinzipien der OOP in PHP an, und die Weiterleitung nach der Verarbeitung ist unter eine Weiterleitung in PHP erstellen beschrieben.
Siehe auch json_encode und die Serialisierung von PHP-Objekten sowie den Hub Webentwicklung.
Häufige Fehler
</script> schließt den Block auf Ebene des HTML-Parsers. Ergebnis: Syntaxfehler und Skript-Injektion.</script> wörtlich und bricht aus dem Block aus, auch in einem <script type="application/json">.htmlspecialchars() einen leeren String statt des Werts zurück.HttpOnly tragen und geht bei jedem Request wieder mit.

