PHPからJavaScriptにデータを受け渡すときに考えること

PHPからJavaScriptにデータを受け渡す需要は多くあるので、把握しておくべきことをまとめます。
原則としてはPHPの値をJSONに変換することです。

## 仕様について

**JSON**(JavaScript Object Notation)はJavaScriptのオブジェクトとリテラル記法に由来する<ruby>部分集合<rt>サブセット</ruby>として定義されました。JSONの仕様は[JSON.orgの説明](https://www.json.org/json-ja.html)と[RFC 8259](https://www.rfc-editor.org/rfc/rfc8259)などで定義されています。ざっくりとした理解はWikipediaなんかを見てもらうといいでしょう。

https://ja.wikipedia.org/wiki/JavaScript_Object_Notation

## データ型

PHPとJSONは基本的には似たようなデータ型を備えていますが、細かい点で違いがあります。

| データの種類  | PHPの型名        | JSONの型名     |
|---------------|------------------|----------------|
| 文字列        | `string`         | `string`       |
| 数値          | `int`/`float`    | `number`       |
| 配列(リスト)  | `array` (list)   | `array`        |
| 連想配列(辞書) | `array`/`object` | `object`       |
| 真理値        | `bool`           | `true`/`false` |
| null          | `null`           | `null`         |

 * PHPの`string`は任意のバイト列を扱えますが、JavaScript/JSONはUnicodeで扱える文字しか扱えません
 * PHPの`int` / `float`はプラットフォーム依存ですが、JavaScriptの`number`は整数と小数を型レベルで区別しません
 * JSONの`array`に対応する型はPHPの`array`のうちリストであるものです
     * PHPは配列(リスト)と連想配列を型レベルで区別せず、どちらも`array`です
         * リストはキーが0からの抜けがない連番になっている要素が0個以上の配列です
         * [`array_is_list()`](https://www.php.net/array_is_list)関数で連想配列とリストを判別できます
         * [`array_values()`](https://www.php.net/array_values)で連想配列をリストに変換できます
         * [`array_filter()`](https://www.php.net/array_values)の結果はフィルタされたキーがスキップされるのでリストではありませんが、結果を`array_values()`に通すことでリストにできます
     * `JsonSerializable`でリストを返すオブジェクトもリストに変換されます
         * PHP 8.1以上では[SplFixedArray](https://www.php.net/manual/ja/class.splfixedarray.php)が該当
 * JSONの`object`に対応する型はPHPの`array`または`object`です
     * [`stdClass`](https://www.php.net/stdClass)
     * `array` のうち、リストでないもの
     * 前述の通り`[]`はリストなので、`(object)[]`のようにキャストすることで空オブジェクト`{}`に変換できます
 * `true` / `false` / `null` についてはPHPとJSON・JavaScript間で注意すべき点は特にありません

## json_encode()

`json_encode()`は値をJSON文字列に変換するPHPの標準関数です。

```php
json_encode(mixed $value, int $flags = 0, int $depth = 512): string|false
```

https://www.php.net/manual/ja/function.json-encode.php

 * `json_encode()`の第2引数はビットフラグで渡します
     * フラグは`JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR`のように[ビット和](https://www.php.net/manual/ja/language.operators.bitwise.php)で同時指定できます
 * デフォルトでは`/`は`\/`にエスケープされます
     * この挙動により文字列の一部に `"</script>"` が含まれていても `"<\/script>"`に変換されるのでJavaScript内に展開しても任意コードがインジェクションされるリスクが軽減します
         * [\<script\>要素の構文](https://zenn.dev/qnighy/articles/4f6c728d452295)で説明されるように、HTML仕様では文字列の中でも `</script>` が登場してしまうとその時点で`<script>`は終端してしまうので、この挙動によって抑止できるということです
     * `JSON_UNESCAPED_SLASHES`で`/`のエスケープを無効化できますが、特別な理由がなければこのままでもよいでしょう
 * `JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT`
     * HTMLで特別な意味をもつ `<>` `'` `&` `"` をそれぞれエスケープすることで`<script>`タグ内に展開する際にさらにXSSの潜在的なリスクを軽減します
 * デフォルトでは文字列にUTF-8として不正なシーケンスが含まれる場合はエラーとして`false`を返します
     * `JSON_INVALID_UTF8_IGNORE` で不正なシーケンスを削除します
     * `JSON_INVALID_UTF8_SUBSTITUTE` で不正なシーケンスを�に置き換えます
 * デフォルトではマルチバイト値は`\uXXXX`形式にエスケープされます
     * `JSON_UNESCAPED_UNICODE`を指定することでエスケープせず、そのまま出力します
 * デフォルトではエンコード失敗は`false`を返しますが、`JSON_THROW_ON_ERROR`フラグで例外送出されます
 * `JSON_FORCE_OBJECT` は基本的に**使いません**
     * 空配列`[]`と空オブジェクト`{}`は上記のように`array_values()`または`(object)`キャストで区別できるからです
 * `JSON_PRETTY_PRINT` も基本的には使いません
     * JSONのデータ構造を目視確認したいときには役立つ可能性があります

:::note
基本的には `JSON_THROW_ON_ERROR` を指定した方がエラーをハンドリングしやすくなるので推奨です。
HTML内に展開する際には`JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT`を指定するとXSS耐性が上がります。独立したJSONレスポンスでは`JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE`を指定しても構いません。
:::

:::note warn
**考えるべきXSS耐性**
上記や以下の項目で説明している`json_encode()`のオプション、あるいは`htmlspecialshars()`でのエスケープは原則としては必要なものだけを行えば用は足ります。しかしながら、何らかの不具合やコーディングミスによって `"` や `</script>` の対応が崩れてしまった場合などXSSが発火しないようにエスケープの指定は外しすぎないのが良いでしょう。
:::

## 値の受け渡し

最近では**SPA**が普及して従来のサーバーサイドアプリケーションでHTMLを動的に組み立ててレスポンスする構造は[レトロニム](https://ja.wikipedia.org/wiki/%E3%83%AC%E3%83%88%E3%83%AD%E3%83%8B%E3%83%A0)として**MPA**と呼ばれることもありますが、いまどきではないと言えど2023年中に滅び去るようなことも葬送ないでしょう。

:::note warn
ここではよく知られた方法のうち、極力リスクが低めであると考えられるものを紹介します。
どれが最善であるという判断は下さないので、自分が納得して使えるものを選択してください。
:::

```php:JavaScript変数にJSONを直代入する方法
<script>
document.addEventListener('DOMContentLoaded', (event) => {
  // h() が抜けているのは誤記ではない。scriptタグ内はHTMLでありながらJavaScriptの世界なのでHTMLエスケープは不適
  const data = <?= json_encode($data, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?>;
  const h1 = document.getElementById('title');
  const body = document.getElementById('body');
  h1.textContent = data.title;
  body.textContent = data.body;
});
</script>
```

 * 特徴
     * JSONがJavaScriptのサブセットであるという性質を活かして直接代入
     * `json_encode()` にフラグ `JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT` を指定することで、万が一 `<script>` の展開が破損してもXSSを避けられる
 * 長所
     * ブラウザで実行されるスクリプトがシンプル
 * 短所
     * JavaScriptのコードを動的生成することになる
     * `json_encode()` のオプションが複雑
     * **`<?= h(...) ?>` というパターンを逸脱している**

```php:属性値にJSONを展開してJSON.parseする
<script>
document.addEventListener('DOMContentLoaded', (event) => {
  const data = JSON.parse(document.getElementById('data').dataset.json);
  const h1 = document.getElementById('title');
  const body = document.getElementById('body');
  h1.textContent = data.title;
  body.textContent = data.body;
});
</script>
<body data-json="<?= h(json_encode($data)) ?>">
</body>
```

https://codepen.io/zonuexe/pen/vYaKBvg

 * 特徴
     * HTMLから属性値としてJSONを展開し、JavaScriptからは<a href="https://developer.mozilla.org/ja/docs/Learn/HTML/Howto/Use_data_attributes"><ruby>data属性<rt>データアトリビュート</ruby></a>で取得する
 * 長所
     * ブラウザで実行されるスクリプトは単なる代入よりは複雑だが、比較的シンプル
     * 展開箇所がHTMLの属性値なので、普通の変数展開と変わらずに記述できる
     * スクリプトの動的生成を避けられる
     * `h(json_encode($data))` のように書くことで(`json_encode()`の長大なフラグよりは)記述を減らせる
     * DOM上にJSONデータを載せることで`<script>`タグ外からアクセスできる
 * 短所
     * JavaScriptのコードを動的生成することになる

```php:LaravelのIlluminate\Support\Jsを使う
<script>
document.addEventListener('DOMContentLoaded', (event) => {
  const data = {{ Illuminate\Support\Js::from($data) }};
  const h1 = document.getElementById('title');
  const body = document.getElementById('body');
  h1.textContent = data.title;
  body.textContent = data.body;
});
</script>
```

 * 特徴
     * [Blade組み込みのJavaScriptオブジェクトへの展開機能](https://readouble.com/laravel/9.x/ja/blade.html#:~:text=%E5%87%BA%E5%8A%9B%20%2D%2D%3E%0A%40if()-,JSON%E3%81%AE%E3%83%AC%E3%83%B3%E3%83%80,-JavaScript%E5%A4%89%E6%95%B0%E3%82%92)
         * JSONをレンダリングする流れで書かれているが、純粋なJSONではなく**JSONをデコードするJavaScript式**として出力されるので注意
 * 長所
     * ブラウザで実行されるスクリプトは単なる代入よりは複雑だが、記述がシンプル
     * DOM上にJSONデータを載せる必要がない
 * 短所
     * JavaScriptのコードを動的生成することになる
     * `<script>` タグ内にしか展開できない

## まとめ

この記事はPHPのXSSの話を書いている途中で話がとっちらかってきたので切り出した感じです。

勢いに任せて適当に書いたのでとりとめのない記事ですが、割と普段意識されなくてなあなあで済まされてることにも言及できたのではないでしょうか。

メリークリスマス :tada: よいお年を