Przekazywanie zmiennych z PHP do JavaScriptu: 6 metod

Przekazywanie zmiennych z PHP do JavaScriptu: 6 metod
Szybka odpowiedź

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:

php
$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.

php
// Tak nie rób
<script>
  let d = JSON.parse('<?php echo json_encode($donnee); ?>');
</script>

To, co dostaje przeglądarka:

javascript
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.

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

Wynik:

javascript
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ą.

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

<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.

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() z ENT_QUOTES jest obowiązkowe. Bez niego wartość zawierająca cudzysłów wychodzi poza atrybut:

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

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.

php
<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.

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

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.

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

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.

javascript
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

JSON.parse('<?= json_encode($x) ?>') Apostrof w danych zamyka ciąg JavaScriptu, a sekwencja </script> zamyka blok na poziomie parsera HTML. Efekt: błąd składni i wstrzyknięcie skryptu.
Zapomniany JSON_HEX_TAG Bez niego </script> pojawia się dosłownie i wychodzi z bloku, także w <script type="application/json">.
Atrybut bez htmlspecialchars Wartość zawierająca cudzysłów wychodzi poza atrybut i dokłada własny handler zdarzenia.
Brak ENT_SUBSTITUTE Przy niepoprawnej sekwencji UTF-8 htmlspecialchars() zwraca pusty ciąg zamiast wartości.
POST przedstawiany jako bezpieczniejszy od GET Nieprawda. Oba lecą otwartym tekstem bez TLS. Różnica dotyczy śladów: URL trafia do historii, logów i nagłówka Referer.
Cookie użyte do przekazania danej na stronę Ciasteczko czytelne dla JavaScriptu nie może mieć HttpOnly i wraca w każdym żądaniu.
Dana z klienta uznana za wiarygodną Cenę albo identyfikator roli przekazane do JavaScriptu da się zmienić. Każdą wracającą wartość trzeba zwalidować po stronie serwera.

JavaScriptJSONPHPSécuritéXSS

Damien Flandrin Web developer od 2010 roku, twórca Gekkode i Email Impact. Każdy artykuł jest sprawdzany na prawdziwym projekcie przed publikacją. Kontakt
Newsletter

Nowe testy, poradniki i projekty — e-mailem.

Powtarzalne testy, wersjonowany kod, datowane wyniki. Nigdy spamu.