JSON を ECMAScript の構文サブセットにする (別名 JSON ⊂ ECMAScript)

公開日: · タグ: ES2019

JSON ⊂ ECMAScript プロポーザルにより、JSON は ECMAScript の構文サブセットとなります。これが既にそうなっていなかったことに驚いたなら、あなたは一人ではありません!

従来の ES2018 の挙動 #

ES2018 では、ECMAScript の文字列リテラルには、エスケープされていない U+2028 ラインセパレータと U+2029 パラグラフセパレータを含めることができませんでした。なぜなら、これらはそのコンテキストでも行終端記号と見なされるからです。

// A string containing a raw U+2028 character.
const LS = '
';
// → ES2018: SyntaxError

// A string containing a raw U+2029 character, produced by `eval`:
const PS = eval('"\u2029"');
// → ES2018: SyntaxError

これは、JSON 文字列にはこれらの文字が含めることができるため、問題となります。その結果、開発者は、有効な JSON を ECMAScript プログラムに埋め込む際に、これらの文字を処理するための特別な後処理ロジックを実装する必要がありました。このようなロジックがないと、コードに微妙なバグ、あるいはセキュリティ上の問題が発生する可能性がありました!

新しい挙動 #

ES2019 では、文字列リテラルに生の U+2028 文字と U+2029 文字を含めることができるようになり、ECMAScript と JSON の間の混乱を招く不一致が解消されました。

// A string containing a raw U+2028 character.
const LS = '
';
// → ES2018: SyntaxError
// → ES2019: no exception

// A string containing a raw U+2029 character, produced by `eval`:
const PS = eval('"\u2029"');
// → ES2018: SyntaxError
// → ES2019: no exception

この小さな改善により、開発者のメンタルモデルが大幅に簡素化され(覚えるべきエッジケースが 1 つ減りました!)、有効な JSON を ECMAScript プログラムに埋め込む際の特別な後処理ロジックの必要性が軽減されます。

JSON を JavaScript プログラムに埋め込む #

このプロポーザルの結果、JSON.stringify を使用して、有効な ECMAScript 文字列リテラル、オブジェクトリテラル、および配列リテラルを生成できるようになりました。また、別の整形式の JSON.stringify プロポーザルにより、これらのリテラルは UTF-8 やその他のエンコーディングで安全に表現できます(ディスク上のファイルに書き込もうとしている場合に便利です)。これは、JavaScript ソースコードを動的に作成してディスクに書き込むなど、メタプログラミングのユースケースに非常に役立ちます。

JSON 構文が ECMAScript のサブセットになったことを利用して、特定のデータオブジェクトを埋め込んだ有効な JavaScript プログラムを作成する例を次に示します。

// A JavaScript object (or array, or string) representing some data.
const data = {
LineTerminators: '\n\r

',
// Note: the string contains 4 characters: '\n\r\u2028\u2029'.
};

// Turn the data into its JSON-stringified form. Thanks to JSON ⊂
// ECMAScript, the output of `JSON.stringify` is guaranteed to be
// a syntactically valid ECMAScript literal:
const jsObjectLiteral = JSON.stringify(data);

// Create a valid ECMAScript program that embeds the data as an object
// literal.
const program = `const data = ${ jsObjectLiteral };`;
// → 'const data = {"LineTerminators":"…"};'
// (Additional escaping is needed if the target is an inline <script>.)

// Write a file containing the ECMAScript program to disk.
saveToDisk(filePath, program);

上記のスクリプトは、以下のコードを生成します。これは、同等のオブジェクトに評価されます。

const data = {"LineTerminators":"\n\r

"};

JSON.parse を使用して JSON を JavaScript プログラムに埋め込む #

JSON のコストで説明されているように、データを JavaScript オブジェクトリテラルとしてインライン化する代わりに、次のようにします。

const data = { foo: 42, bar: 1337 }; // 🐌

...データは JSON 文字列化された形式で表現し、実行時に JSON 解析することで、大きなオブジェクト (10 kB 以上) の場合のパフォーマンスを向上させることができます。

const data = JSON.parse('{"foo":42,"bar":1337}'); // 🚀

実装例を次に示します。

// A JavaScript object (or array, or string) representing some data.
const data = {
LineTerminators: '\n\r

',
// Note: the string contains 4 characters: '\n\r\u2028\u2029'.
};

// Turn the data into its JSON-stringified form.
const json = JSON.stringify(data);

// Now, we want to insert the JSON into a script body as a JavaScript
// string literal per https://v8.dokyumento.jp/blog/cost-of-javascript-2019#json,
// escaping special characters like `"` in the data.
// Thanks to JSON ⊂ ECMAScript, the output of `JSON.stringify` is
// guaranteed to be a syntactically valid ECMAScript literal:
const jsStringLiteral = JSON.stringify(json);
// Create a valid ECMAScript program that embeds the JavaScript string
// literal representing the JSON data within a `JSON.parse` call.
const program = `const data = JSON.parse(${ jsStringLiteral });`;
// → 'const data = JSON.parse("…");'
// (Additional escaping is needed if the target is an inline <script>.)

// Write a file containing the ECMAScript program to disk.
saveToDisk(filePath, program);

上記のスクリプトは、以下のコードを生成します。これは、同等のオブジェクトに評価されます。

const data = JSON.parse("{\"LineTerminators\":\"\\n\\r

\"}");

JSON.parse と JavaScript オブジェクトリテラルを比較した Google のベンチマークは、ビルドステップでこの手法を活用しています。Chrome DevTools の「JS としてコピー」機能は、同様の手法を採用することで大幅に簡素化されました。

セキュリティに関する注意 #

JSON ⊂ ECMAScript は、特に文字列リテラルの場合における JSON と ECMAScript の不一致を軽減します。文字列リテラルは、オブジェクトや配列などの他の JSON でサポートされているデータ構造内に現れる可能性があるため、上記のコード例が示すように、これらのケースにも対応します。

ただし、U+2028 と U+2029 は、ECMAScript 文法の他の部分では、依然として行終端記号として扱われます。つまり、JSON を JavaScript プログラムに挿入するのが安全でない場合がまだあります。サーバーが JSON.stringify() を実行した後、ユーザー提供のコンテンツを HTML レスポンスに挿入する次の例を考えてみましょう。

<script>
// Debug info:
// User-Agent: <%= JSON.stringify(ua) %>
</script>

JSON.stringify の結果は、スクリプト内の一行コメントに挿入されることに注意してください。

上記の例のように使用すると、JSON.stringify() は 1 行を返すことが保証されます。問題は、「1 行」の構成がJSON と ECMAScript で異なることです。ua にエスケープされていない U+2028 または U+2029 文字が含まれている場合、一行コメントから抜け出して、ua の残りの部分を JavaScript ソースコードとして実行します。

<script>
// Debug info:
// User-Agent: "User-supplied string<U+2028> alert('XSS');//"
</script>
<!-- …is equivalent to: -->
<script>
// Debug info:
// User-Agent: "User-supplied string
alert('XSS');//"
</script>

注: 上記の例では、生のエスケープされていない U+2028 文字は、わかりやすくするために <U+2028> として表されています。

JSON ⊂ ECMAScript はここでは役に立ちません。文字列リテラルにのみ影響を与えるためです。この場合、JSON.stringify の出力は、JavaScript 文字列リテラルを直接生成しない位置に挿入されます。

これらの 2 文字に対する特別な後処理が導入されない限り、上記のコードスニペットはクロスサイトスクリプティングの脆弱性 (XSS) を示します!

注: コンテキストに応じて、特殊文字シーケンスをエスケープするために、ユーザー制御の入力を後処理することが非常に重要です。この特定のケースでは、<script> タグに挿入しているため、</script<script、および <!-​- もエスケープする必要があります。

JSON ⊂ ECMAScript のサポート #