
Domyślna metoda to blok odczytany przez JSON.parse: dane nigdy nie są interpretowane jako kod, a strona pozostaje zgodna z restrykcyjną polityką bezpieczeństwa treści. Jeśli wpisujesz JSON wprost do skryptu, zrób to bez cudzysłowów i bez JSON.parse, z JSON_HEX_TAG i opcjami sąsiednimi.
Przeniesienie wartości z PHP do JavaScriptu to codzienna potrzeba, a większość metod krążących w sieci otwiera dziurę typu XSS. Ten artykuł porównuje sześć podejść, pokazuje, co każde z nich robi z wrogą daną, i podpowiada, które wybrać w danej sytuacji.
Na czym polega problem
PHP wykonuje się na serwerze, JavaScript w przeglądarce. Jedynym punktem styku jest wygenerowany HTML. Kiedy dana z PHP trafia do JavaScriptu, zmienia kontekst interpretacji: to, co było ciągiem znaków, staje się kodem. Jeśli dana zawiera odpowiednie znaki, wychodzi poza ciąg i staje się wykonywalna.
Weźmy realistyczną wartość, taką, jaka wyszłaby z bazy danych:
$donnee = [
'nom' => "L'Écran </script><script>alert(1)</script>",
'note' => 4.5,
];Zawiera apostrof, znacznik zamykający i znacznik otwierający. Każdy z nich psuje inną metodę.
1. json_encode w bloku script
To metoda najpopularniejsza i prawie zawsze napisana źle.
// Tak nie rób
<script>
let d = JSON.parse('<?php echo json_encode($donnee); ?>');
</script>To, co dostaje przeglądarka:
let d = JSON.parse('{"nom":"L'Écran </script><script>alert(1)</script>","note":4.5}');Dwa pęknięcia. Apostrof w „L’Écran” zamyka ciąg JavaScriptu: błąd składni, strona przestaje się wykonywać. A sekwencja </script> zamyka blok na poziomie parsera HTML, który nie zna ciągów JavaScriptu: idący za nią <script>alert(1)</script> staje się prawdziwym blokiem skryptu.
Poprawna postać nie ma ani cudzysłowów, ani JSON.parse(). JSON sam w sobie jest już poprawnym wyrażeniem JavaScriptu.
<script>
const donnees = <?= json_encode($donnee,
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE
) ?>;
</script>Wynik:
const donnees = {"nom":"L'Écran alert(1)","note":4.5};Cztery opcje escapowania zamieniają <, >, &, ' i " na sekwencje uXXXX. Żaden z tych znaków nie pojawia się już dosłownie, więc żaden nie może niczego zamknąć. JSON_UNESCAPED_UNICODE zachowuje czytelne znaki diakrytyczne, co odchudza wynik i niczego nie psuje.
2. Osobny blok JSON
To metoda, którą polecamy domyślnie. Dane wychodzą z kodu i stają się treścią.
<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
);<script> z typem nieznanym przeglądarce nie jest wykonywany: to zwykły pojemnik na tekst. JSON_HEX_TAG nadal jest potrzebny, żeby </script> nie zamknął bloku, ale zawartość nigdy nie jest interpretowana jako kod.
Ta postać ma decydującą zaletę: działa przy restrykcyjnej polityce bezpieczeństwa treści. Serwis, który blokuje skrypty inline, żeby bronić się przed wstrzyknięciami, nie użyje metody 1 bez dołożenia nonce’a albo hasza.
3. Atrybuty data
Przy kilku prostych wartościach przypiętych do elementu atrybut data-* jest rozwiązaniem najbardziej naturalnym.
<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() z ENT_QUOTES jest obowiązkowe. Bez niego wartość zawierająca cudzysłów wychodzi poza atrybut:
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="">Wartości z dataset to zawsze ciągi znaków: konwersję na liczbę robi się po stronie JavaScriptu.
4. Pola ukryte
<input type="hidden"> działa, z tym samym obowiązkowym escapowaniem.
<input type="hidden" id="jeton"
value="<?= htmlspecialchars($jeton, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">Przydaje się głównie wtedy, gdy wartość ma wrócić na serwer razem z formularzem. Do zwykłego przekazania czegoś do JavaScriptu atrybut data-* jest czystszy: nie zaśmieca wysyłki formularza.
Warto dorzucić ENT_SUBSTITUTE: bez niego htmlspecialchars() zwraca pusty ciąg, gdy dana zawiera niepoprawną sekwencję UTF-8. Puste pole zamiast wartości to błąd, którego szuka się długo.
5. Wywołanie API
Gdy tylko dane są obszerne, zmienne albo zależą od akcji użytkownika, nie mają czego szukać w HTML-u.
<?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();Wtedy kwestia escapowania HTML znika: odpowiedź nigdy nie jest interpretowana jako HTML, pod warunkiem że nagłówek Content-Type jest poprawny i towarzyszy mu nosniff.
Kosztem jest dodatkowe żądanie. Przy danych potrzebnych już przy pierwszym renderowaniu metoda 2 oszczędza tę podróż w obie strony.
6. Gromadzenie wielu wartości
Kiedy kilka części aplikacji ma po jednej wartości do przekazania, zebranie ich i wypisanie za jednym razem oszczędza rozsiewania bloków <script>. Na tej zasadzie działa Media::addJsDef() w PrestaShopie.
<?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);Trzymanie wartości w obiekcie zamiast w zmiennej globalnej eliminuje kolizje nazw i sprawia, że klasa daje się testować.
Metody do odrzucenia
Krążą dwa podejścia, których nie należy stosować.
Przekazanie przez URL. Zapis <a href="http://script.js?valeur=$v"> nie ma sensu: plik JavaScript jest serwowany bez zmian, a jego parametrów w URL-u nikt nie czyta. Naprawdę istnieje co innego, odczyt parametrów bieżącej strony po stronie klienta, ale wtedy wartość pochodzi z URL-a, a nie z PHP.
const page = new URLSearchParams(window.location.search).get('page');Przekazanie przez cookie. Ciasteczko, które ma być czytane przez JavaScript, nie może mieć atrybutu HttpOnly, a to główne zabezpieczenie przed kradzieżą sesji. Wraca też w każdym żądaniu, co obciąża cały ruch. Ciasteczko służy do trzymania stanu między żądaniami, a nie do przekazywania danej w obrębie bieżącej strony.
Uściślenie w sprawie bezpieczeństwa
Często można przeczytać, że POST jest „bezpieczniejszy” niż GET. To nieprawda. Oba przesyłają dane otwartym tekstem, jeśli połączenie nie jest szyfrowane, i oba użytkownik może zmodyfikować. Różnica leży gdzie indziej: parametry GET pojawiają się w URL-u, a więc w historii przeglądarki, logach serwera i nagłówku Referer. To kwestia wycieku przez ślady, a nie bezpieczeństwa transportu.
Zasada wspólna dla wszystkich sześciu metod: wszystko, co dociera do przeglądarki, użytkownik widzi i może zmienić. Cena, identyfikator roli czy suma przekazana do JavaScriptu musi być przy każdej akcji ponownie zwalidowana na serwerze. Żadna dana wysłana do klienta nie zasługuje na zaufanie, kiedy wraca.
Co wybrać
| Potrzeba | Metoda |
|---|---|
| Obiekt konfiguracji przy ładowaniu strony | Blok application/json |
| Kilka wartości powiązanych z elementem | Atrybuty data-* |
| Wartość wracająca razem z formularzem | Pole ukryte |
| Dane obszerne albo ładowane później | Wywołanie API |
| Wiele modułów dokładających swoje wartości | Akumulator renderowany za jednym razem |
| Restrykcyjna polityka bezpieczeństwa treści | Blok application/json, nigdy skrypt inline |
Jeśli dane pochodzą z zewnętrznego API, po stronie serwera pobiera je żądanie cURL. Pokazany wyżej akumulator stosuje zasady programowania obiektowego w PHP, a przekierowanie po przetworzeniu opisuje tworzenie przekierowania w PHP.
Zobacz też json_encode i serializację obiektów PHP oraz hub Programowanie webowe.
Częste błędy
</script> zamyka blok na poziomie parsera HTML. Efekt: błąd składni i wstrzyknięcie skryptu.</script> pojawia się dosłownie i wychodzi z bloku, także w <script type="application/json">.htmlspecialchars() zwraca pusty ciąg zamiast wartości.HttpOnly i wraca w każdym żądaniu.

