🥽

JavaScriptクイズ: JSON評価時の挙動

2023/12/30に公開

Xで出したクイズの解説です。

https://twitter.com/qnighy/status/1740899788588208419

問題は次の通りです。

以下のJavaScriptの比較は常にtrueになるでしょうか? それともfalseになる可能性があるでしょうか? (そして、あるとしたらそれはどのような場合でしょうか?)

JSON.stringify(
  eval(
    "(" + JSON.stringify(x) + ")"
  )
) === JSON.stringify(x)

ただし、以下の条件を仮定します。

  • Objectなどのビルトインの定義は汚染されていないものとし、evalはglobalThis.evalを指しているものとします
  • 内側のstringifyがエラーになる場合は除外します

答え

この出題をわざわざすることからだいたい予想がついてしまうと思いますが、これは「falseになる可能性がある」というのが正しいです。

問題は、具体的にどのような場合があるかです。

これについて、当初想定していた解は2種類ありましたが、興味深い非作為解もいくつかあったので一緒に紹介します。

非作為解1: 副作用

JSON.stringify(x) が2回評価されていることに着目した解法です。 JSON.stringify 内でユーザー定義の処理が呼ばれる機会はいくつかあり、その中で副作用を発行することで問題の定義を満たしたまま出力を変えることができます。

たとえばtoJSONを利用すれば以下のようにすることができます。

const x = { toJSON() { delete this.toJSON; } };

https://twitter.com/rithmety/status/1740951059097702497

https://twitter.com/cm_ayf/status/1741006015137792134

以降では JSON.stringify(x) を1回しか評価しないようにしても成立する解答を考えます。

非作為解2: スコープ

補足条件でスコープの汚染を部分的に禁止していましたが、以下の2つの汚染を禁止していませんでした。

  • JSON
  • undefined

これらがスコープ内の同名の別の変数で隠されている場合は、異なる挙動をさせることができます。

JSON については言うまでもありませんが、 undefined の上書きでも異なる挙動をさせることができます。これは以下のメカニズムによるものです。

  • JSON.stringify(undefined)undefined
  • "(" + undefined + ")""(undefined)"
  • "(undefined)" をevalすると通常は undefined に評価されるが、別の変数に隠されている場合は別の値になる可能性がある。

https://twitter.com/kazatsuyu/status/1741011908080398380

https://twitter.com/uhyo_/status/1741016724819017860

想定解1: __proto__

JavaScriptのオブジェクト式の評価では __proto__ に関する特別ルールがあります。それは、Computed Property Nameを使わずに定義されたプロパティの名前が __proto__ である場合、それは __proto__ というプロパティの作成ではなく [[Prototype]] 内部スロットの設定として振る舞うというものです。

なお、 JSON.parse() の挙動は対応するJavaScript式の評価とほぼ同じものとして定義されていますが、この場合に限り __proto__ に関する特別ルールは明示的に無効化するように定義されているため、これは eval 特有の問題ということになります。これが作題意図の想定解です。

const x = { ["__proto__"]: {} };

https://twitter.com/uhyo_/status/1741013203268886565

https://twitter.com/rithmety/status/1741013888886542835

想定解2: キーの順序

JavaScriptのオブジェクトは通常、キーを挿入順で保ちます。したがってほとんどの場合はJSONからパースしたキー順序はオブジェクト内でも保たれます。

しかし、オブジェクトのデフォルト挙動では"0", "1", "2", ... のようなキーについては他のキーより前に数値の昇順で並べるという特別なルールがあり、これを利用することが可能です。

> { 2: 2, 1: 1}
{ '1': 1, '2': 2 }

つまり、以下のように整数キーの順序が逆転した状態のJSONをパースさせれば、JSON上の順序を忘れさせることができます。

> JSON.parse("{\"2\":2,\"1\":1}")
{ '1': 1, '2': 2 }

問題は、そのようなJSONを JSON.stringify にどのように生成させるからです。当然、普通のオブジェクトの挙動とは異なる [[OwnPropertyKeys]] を渡さなければ、このような特殊なJSONを生成させることはできません。

そこで使えるのがProxyです。

Proxyの [[OwnPropertyKeys]] は、handlerが返したキーの配列をフィルタリングして返す形で定義されており、順序はオリジナルのオブジェクトではなくhandlerの指定が優先されます。したがって、ここで明示的に逆転したキー順序を指定すれば、通常は作れないようなJSONを生成させることができることになります。

こちらの想定解は、作題後の別解検討時に出てきたものです。正解した皆さん、おめでとうございます 🎉

const x = new Proxy({ 1: 1, 2: 2 }, { ownKeys() { return ["2", "1"]; } });

https://twitter.com/kurgm/status/1741046430289265121

このクイズを見た人へのおすすめ

こちらもどうぞ

https://twitter.com/qnighy/status/1595986363631144961

Discussion