111
108

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

PHPAdvent Calendar 2022

Day 23

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

Posted at

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

仕様について

JSON(JavaScript Object Notation)はJavaScriptのオブジェクトとリテラル記法に由来する部分集合サブセットとして定義されました。JSONの仕様はJSON.orgの説明RFC 8259などで定義されています。ざっくりとした理解はWikipediaなんかを見てもらうといいでしょう。

データ型

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()関数で連想配列とリストを判別できます
      • array_values()で連想配列をリストに変換できます
      • array_filter()の結果はフィルタされたキーがスキップされるのでリストではありませんが、結果をarray_values()に通すことでリストにできます
    • JsonSerializableでリストを返すオブジェクトもリストに変換されます
  • JSONのobjectに対応する型はPHPのarrayまたはobjectです
    • stdClass
    • array のうち、リストでないもの
    • 前述の通り[]はリストなので、(object)[]のようにキャストすることで空オブジェクト{}に変換できます
  • true / false / null についてはPHPとJSON・JavaScript間で注意すべき点は特にありません

json_encode()

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

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

  • json_encode()の第2引数はビットフラグで渡します
    • フラグはJSON_PRETTY_PRINT | JSON_THROW_ON_ERRORのようにビット和で同時指定できます
  • デフォルトでは/\/にエスケープされます
    • この挙動により文字列の一部に "</script>" が含まれていても "<\/script>"に変換されるのでJavaScript内に展開しても任意コードがインジェクションされるリスクが軽減します
      • <script>要素の構文で説明されるように、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のデータ構造を目視確認したいときには役立つ可能性があります

基本的には 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を指定しても構いません。

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

値の受け渡し

最近ではSPAが普及して従来のサーバーサイドアプリケーションでHTMLを動的に組み立ててレスポンスする構造はレトロニムとしてMPAと呼ばれることもありますが、いまどきではないと言えど2023年中に滅び去るようなことも葬送ないでしょう。

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

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(...) ?> というパターンを逸脱している
属性値に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>

  • 特徴
  • 長所
    • ブラウザで実行されるスクリプトは単なる代入よりは複雑だが、比較的シンプル
    • 展開箇所がHTMLの属性値なので、普通の変数展開と変わらずに記述できる
    • スクリプトの動的生成を避けられる
    • h(json_encode($data)) のように書くことで(json_encode()の長大なフラグよりは)記述を減らせる
    • DOM上にJSONデータを載せることで<script>タグ外からアクセスできる
  • 短所
    • JavaScriptのコードを動的生成することになる
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>
  • 特徴
  • 長所
    • ブラウザで実行されるスクリプトは単なる代入よりは複雑だが、記述がシンプル
    • DOM上にJSONデータを載せる必要がない
  • 短所
    • JavaScriptのコードを動的生成することになる
    • <script> タグ内にしか展開できない

まとめ

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

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

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

111
108
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
111
108

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?