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
でリストを返すオブジェクトもリストに変換されます- PHP 8.1以上ではSplFixedArrayが該当
- PHPは配列(リスト)と連想配列を型レベルで区別せず、どちらも
- 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>
は終端してしまうので、この挙動によって抑止できるということです
-
<script>要素の構文で説明されるように、HTML仕様では文字列の中でも
-
JSON_UNESCAPED_SLASHES
で/
のエスケープを無効化できますが、特別な理由がなければこのままでもよいでしょう
- この挙動により文字列の一部に
-
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT
- HTMLで特別な意味をもつ
<>
'
&
"
をそれぞれエスケープすることで<script>
タグ内に展開する際にさらにXSSの潜在的なリスクを軽減します
- HTMLで特別な意味をもつ
- デフォルトでは文字列に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年中に滅び去るようなことも葬送ないでしょう。
ここではよく知られた方法のうち、極力リスクが低めであると考えられるものを紹介します。
どれが最善であるという判断は下さないので、自分が納得して使えるものを選択してください。
<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(...) ?>
というパターンを逸脱している
<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から属性値としてJSONを展開し、JavaScriptからはdata属性で取得する
- 長所
- ブラウザで実行されるスクリプトは単なる代入よりは複雑だが、比較的シンプル
- 展開箇所がHTMLの属性値なので、普通の変数展開と変わらずに記述できる
- スクリプトの動的生成を避けられる
-
h(json_encode($data))
のように書くことで(json_encode()
の長大なフラグよりは)記述を減らせる - DOM上にJSONデータを載せることで
<script>
タグ外からアクセスできる
- 短所
- JavaScriptのコードを動的生成することになる
<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オブジェクトへの展開機能
- JSONをレンダリングする流れで書かれているが、純粋なJSONではなくJSONをデコードするJavaScript式として出力されるので注意
-
Blade組み込みのJavaScriptオブジェクトへの展開機能
- 長所
- ブラウザで実行されるスクリプトは単なる代入よりは複雑だが、記述がシンプル
- DOM上にJSONデータを載せる必要がない
- 短所
- JavaScriptのコードを動的生成することになる
-
<script>
タグ内にしか展開できない
まとめ
この記事はPHPのXSSの話を書いている途中で話がとっちらかってきたので切り出した感じです。
勢いに任せて適当に書いたのでとりとめのない記事ですが、割と普段意識されなくてなあなあで済まされてることにも言及できたのではないでしょうか。
メリークリスマス よいお年を