Stage 3 Iterator Helpers におけるハッキーな Break the Web 解決策
Stage 3 Iterator Helpers による Break the Web
2023年12月現在 Stage 3 Iterator Helpers という提案が進行中です。
さて、この Iterator Helpers で残念なことに Break the Web が起きてしまいました。その原因が複雑で、なおかつその解決策もハッキーで面白いものだったため解説しようと思います。
Break the Web とは
JavaScript の言語仕様である ECMAScript の提案プロセスにはステージがあります。仕様書が完成し Stage 3 になると JavaScript エンジンが実装し始め、各ブラウザベータ版に搭載されます。そこで特に問題なければ言語仕様に入りますが、いくつかのサイトが正しく動作しない場合 Break the Web として提案が差し戻されることがあります。
例えば2023年11月に Stage 4 となり ES2024 の仕様となった Array Grouping の提案は元々 Array.prototype.group
メソッドを追加する提案でしたが、Break the Web を引き起こしたため Object.groupBy
と Map.groupBy
のスタティックメソッドに改められました。
一方で影響が小さい場合は無視されることもあります。ES2019 Array.prototype.flat
では highchart.js のラベルが正常に表示されない問題が報告されましたが、大きい問題ではないとされそのまま進められました。
Override Mistake と呼ばれる仕様バグ
そもそも ECMAScript には Override Mistake と呼ばれる仕様バグが存在します。これはオブジェクトの [[Prototype]]
が上書き不可能なデータプロパティを持つとき、それと同じキーを持つプロパティを代入演算子で定義しようとする際に発生します。
// オブジェクト prototype を作成
const prototype = {};
// オブジェクト prototype に上書き不可能な foo プロパティを定義する
Object.defineProperty(prototype, "foo", {
value: "bar",
writable: false,
configurable: true,
enumerable: true,
});
// prototype を [[Prototype]] にもつオブジェクト obj を作成
const obj = Object.create(prototype);
// obj に代入演算子を作って foo プロパティの定義を試みると例外発生
obj.foo = "baz"; // => throws TypeError
// Object.defineProperty を使えば例外は発生しない
Object.defineProperty(obj, "foo", {
value: "baz",
writable: true,
configurable: true,
enumerable: true,
});
// 既に定義されているプロパティを変更する際には例外は発生しない
obj.foo = "fuga";
この仕様バグは一見問題にならなそうな軽微なものに見えます。しかし例えばプロトタイプ汚染を防止するために Object.prototype
をフリーズした際に Object.prototype
が元々持つデータプロパティが上書き不可能となり顕在化します。
Object.freeze(Object.prototype);
const obj = {};
obj.constructor = function () {}; // throws TypeError
Normative Change として修正を試みられましたが、残念ながらその修正自体に Web 互換性の問題があるということで却下されています。
この Override Mistake は Stage 1 Secure ECMAScript という提案でも言及されています。
%IteratorPrototype%
にプロパティを追加
Iterator Helpers が Array.prototype.values
などのメソッドや Generator Functions からイテレーターを作った場合、そのオブジェクトは %IteratorPrototype%
というオブジェクトを継承します。
// %ArrayIteratorPrototype%
const ArrayIteratorPrototype = Object.getPrototypeOf([1, 2, 3].values());
// %ArrayIteratorPrototype% の [[Prototype]] は %IteratorPrototype% になっている
const IteratorPrototype = Object.getPrototypeOf(ArrayIteratorPrototype);
この %IteratorPrototype%
は現状 Symbol.iterator
プロパティしか持っていませんが、Iterator Helpers の提案によって Iterator.prototype
としてアクセス出来るようになり constructor
(と Symbol.toStringTag
)プロパティが定義されることになっています。
これによりユーザーが定義するクラスで Iterator
クラスを継承できるようになり、map
や filter
などのメソッドを扱えるようになります。
class MyIterator extends Iterator {
next() {}
return() {}
}
問題となったサイトの構成
問題となったサイトは airgap.js によってプロトタイプ汚染を避けるために %IteratorPrototype%
をフリーズしていました。
これにより Iterator Helpers をブラウザベータ版で有効化した際に新たに定義された %IteratorPrototype%
の constructor
プロパティが上書き不可能となります。
そして Babel などで Generator Functions をトランスパイルするためによく使われていた regenerator-runtime の古いバージョンが使われていました。そのバージョンでは %IteratorPrototype%
がフリーズされることが考慮されておらず、Override Mistake の対応が入っていませんでした。
const Gp = GeneratorFunctionPrototype.prototype =
Generator.prototype = Object.create(IteratorPrototype);
// constructor プロパティで Override Mistake が発生!!
GeneratorFunction.prototype = Gp.constructor = GeneratorFunctionPrototype;
GeneratorFunctionPrototype.constructor = GeneratorFunction;
これらの合わせ技によって Break the Web が起きてしまいました。
解決策
Iterator Helpers が %IteratorPrototype%
に新たにデータプロパティとして constructor
(と Symbol.toStringTag
)を定義したことによって Override Mistake を引き起こしてしまいました。提案として constructor
プロパティを定義しないわけにはいきませんが、フリーズされたときに上書き不可能にされてしまうと困ります。
ここでデータプロパティではなくアクセサプロパティとしてこの constructor
を定義することを考えてみます。フリーズされてもアクセサプロパティは上書き不可能にはならないため例外は投げません。そしてセッターが実行されたときの挙動を制御することで他のデータプロパティと近い動作をさせることが出来ます。
……というわけで Normative Change が出されました。
この Normative Change は2023年11月の TC39 meeting で承認され、Iterator Helpers の提案に取り込まれました。
締め
Web 互換性のためにかなりハッキーで面白い修正が取り込まれたので取り上げてみました。
私は個人的に Scrapbox で TC39 の動向を追っています。ECMAScript の提案を追うのは割と楽しいので皆さんもよかったらどうぞ。
Discussion