プロトタイプ汚染周りの提案と primordials.js
はじめに
この記事では TC39 のプロトタイプ汚染周りの提案、そしてグローバルやプロトタイプのプロパティ変更に耐えるためのコードである primordials.js について紹介します。
プロトタイプ汚染とは
JavaScript はプロトタイプベースの言語です。どのオブジェクトもプロトタイプ([[Prototype]]
内部スロット)を持っており、それを辿ることで継承を表現します(プロトタイプチェーン)。
JavaScript では基本的にどのオブジェクトも凍結されておらず、好きにプロパティを追加していくことができます。かつて Prototype JavaScript Framework や MooTools というライブラリが広く使われ、ビルトインオブジェクトのプロトタイプに独自のメソッドを追加して Web サイトを開発していました。しかしオブジェクトが直接所有するプロパティとプロトタイプチェーンの中に持つプロパティとの区別が付きづらいなどの理由からバグを引き起こすようになってしまいました。
// Object.prototype を汚染する
Object.prototype.foo = 1;
const obj = { bar: 2 };
// obj のプロトタイプチェーンの中に Object.prototype が存在する
console.log(Object.prototype.isPrototypeOf(obj)); // true
console.log(obj instanceof Object); // => true
// 値を取得する際にプロトタイプを辿る
console.log(obj.foo); // => 1
console.log(obj.bar); // => 2
// in 演算子ではオブジェクトが直接所有するプロパティかどうか判定できない
console.log("foo" in obj); // => true
console.log("bar" in obj); // => true
// Object#hasOwnProperty を使って判定できる
const hasOwnProperty = Object.prototype.hasOwnProperty;
console.log(hasOwnProperty.call(obj, "foo")); // => false
console.log(hasOwnProperty.call(obj, "bar")); // => true
// 最近は ES2022 Object.hasOwn を使うことができる
console.log(Object.hasOwn(obj, "foo")); // => false
console.log(Object.hasOwn(obj, "bar")); // => true
このようなことからプロトタイプを好き勝手に触ることをプロトタイプ汚染(Prototype Pollution)と呼ぶようになりました[1]。
今では新しい仕様で追加されたメソッドを古い実行環境でも扱えるようにする polyfill のみ追加するのが主流になったため、ライブラリを使うことによるプロトタイプ汚染はあまり聞かなくなりました[2]。
最近はどちらかといえばプロトタイプを悪用した攻撃にプロトタイプ汚染という言葉が使われています。Node.js で実行されているサーバーに __proto__
や constructor
などのプロパティを持つ JSON を送りつけることによって Object.prototype
の書き換えを試みて、想定していないプロパティを持つオブジェクトとして扱わせる攻撃が有名です。
__proto__
について詳しくは過去の記事を御覧ください。
グローバルやプロトタイプの変更に耐えうるコード
突然ですが以下のようなモジュールがあったとします。このコードは安全でしょうか。
const rainbowColors = ["red", "orange", "yellow", "green", "blue", "indigo", "violet"];
export function isRainbowColor(color) {
return rainbowColors.includes(String(color).toLowerCase());
}
このモジュールが最初に実行されたタイミングでは問題なかったとしても、isRainbowColor
が実行される頃にはグローバル変数である String
やプロトタイプメソッドである Array#includes
, String#toLowerCase
がもしかしたら別物になってしまっている可能性があります。モジュールが最初に実行されるタイミングではグローバルやプロトタイプが変更されていないという前提のもとでは以下のようなコードの方が安全と言えるでしょう。
const rainbowColors = ["red", "orange", "yellow", "green", "blue", "indigo", "violet"];
const NativeString = String;
const toLowerCase = Function.call.bind(String.prototype.toLowerCase);
const includes = Function.call.bind(Array.prototype.includes);
export function isRainbowColor(color) {
return includes(rainbowColors, toLowerCase(NativeString(color));
}
JavaScript のプロトタイプの利点を完全に失ってしまっているコードで馬鹿馬鹿しいと思ったかもしれません。しかしライブラリの中にはこのように安全側に倒して実装されているものもあります。polyfill ライブラリである core-js や es-shims がそれにあたります[3]。
Stage 1 Get Intrinsic
先程のようなコードを書くのを補助する提案 Get Intrinsic が 2021年8月の TC39 meeting で Stage 1 になりました。
この提案はあくまで補助的なものであって、将来的にはプロトタイプの変更に対しては別のアプローチを取るべきだとしています。
primordials.js
Node.js や Deno は内部コードで JavaScript を使ってユーザーに API を提供しています。その内部コードに primordials.js というものが存在し、これを使うことでありとあらゆるグローバル変数やビルトインオブジェクトのプロトタイププロパティの変更に耐えられるように実装されています。
先程のコード例を見ただけだとプロトタイプメソッドをモジュールのスコープに変数としてキャッシュしておいて、函数実行時にそっちを使えばそれだけで解決すると思うかもしれませんが、そんなに単純ではありません。突然ですが以下のコードは安全でしょうか。
const NativeSet = Set;
const forEach = Function.call.bind(Set.prototype.forEach);
const push = Function.call.bind(Array.prototype.push);
export function toUnique(array) {
const ret = [];
forEach(new NativeSet(array), (val) => push(ret, val));
return ret;
}
実は Set
のコンストラクタは内部で "add"
メソッドを使う仕様になっています。つまりビルトインの Set#add
を変更すれば簡単に挙動を変えることが出来てしまいます。また引数をイテレートするために @@iterator
メソッドが実行されてしまうことも考慮するべきでしょう。
primordials.js では Set
を継承し、プロトタイプメソッドを一つずつコピーしたSafeSet
クラスを用意することで対応されています。
このような内部コードを読んでも普段書く JavaScript には特に影響はないと思いますが、ECMAScript の仕様を眺めるのが好きな人からすると結構面白いです。例えばArray#concat
は @@isConcatSpreadable
を考慮しなければならないなどの学びがあります。
将来的にどうなるのか
今後もグローバル変数やプロトタイプの変更に耐えられるコードを書くためにこのように仕様を完全に把握し、エクストリームなコードを書くことを強制され続けてしまうのでしょうか。また最近は npm パッケージに悪意のあるコードが混入したりとセキュリティの問題が大きくなってきています。対処法はないのでしょうか。
--frozen-intrinsics
Node.js の 実は Node.js には --frozen-intrinsics
という実験的な CLI オプションがあります。これを使うことによって Object.prototype
などのビルトインオブジェクトを凍結することが出来ます。
ドキュメントに書かれているように polyfill を読み込みたい場合は --require
オプションを使用する必要があります(ES Modules では使用できないようです)。
実装は以下にあります。
Stage 1 Secure ECMAScript
Stage 1 Secure ECMAScript という提案があります。こちらもビルトインオブジェクトを凍結することが出来ます。
まだ Stage 1 なため今後どうなるかわかりませんが、lockdown
というグローバル函数を呼ぶことで --frozen-intrinsics
と同じことが実現できます。
lockdown();
Object.isFrozen(Object.prototype); // => true
Object.isFrozen(Array.prototype); // => true
最初から凍結してしまうと polyfill が全く使えなくなってしまうため、一通り信頼できる polyfill を読み込んだあとで lockdown
を呼び出す運用になるのかなと思います[4]。
以下のようなブートストラップモジュールを用意しエントリーポイントの最初に読み込むと、他の依存パッケージで悪さができなくなるため良いかなと思います。
// polyfill ライブラリを読み込む
import "core-js/stable";
// もしくは自前で polyfill を追加する
Object.defineProperty(Array.prototype, "filterReject", {
value: function filterReject(predicate, thisArg = undefined) {
// 実装略
},
});
// Object.prototype に対するプロトタイプ汚染攻撃の脅威はないが
// 作ったオブジェクトの [[Prototype]] の変更を試みる攻撃の可能性はまだある
// 消しても依存パッケージが問題なく動くなら消しておく
delete Object.prototype.__proto__;
// intrinsics を凍結する
lockdown();
ライブラリ開発者からするとビルトインオブジェクトを凍結するのは影響範囲が大きいため使えませんが、アプリケーション開発者の立場からするとこの提案はかなり嬉しいものだと思います。
この提案をしている方たちによる実験的なライブラリが公開されています。
結び
プロトタイプ汚染の話やそれに関連した提案、そして primordials.js を取り上げてみました。
セキュリティ周りの話としては Deno や WebAssembly のように明示的に権限を与えなければならないといった実行環境側の話もあります[5][6]が、言語仕様側でも議論されています。
みなさんもこのあたりを追ってみてはいかがでしょうか。
-
polyfill によって既にあるビルトインオブジェクトに対してメソッドを追加する場合はこれで問題ありませんが、
Temporal
のように言語仕様に新しいネームスペースを持った API が追加された場合は古い JavaScript エンジンがその存在を認知できないため一筋縄ではいかない問題がありそうです。 ↩︎
Discussion
deno std のディスカッションで提案しました。
内部コードで配列をイテレートする際には
SafeArrayIterator
でラップする必要があり、スプレッド構文でちゃんとラップされてるかどうかを検知するために deno_lint に PR を出しました。