⛓️

プロトタイプ汚染周りの提案と primordials.js

2021/10/30に公開
2

はじめに

この記事では TC39 のプロトタイプ汚染周りの提案、そしてグローバルやプロトタイプのプロパティ変更に耐えるためのコードである primordials.js について紹介します。

プロトタイプ汚染とは

JavaScript はプロトタイプベースの言語です。どのオブジェクトもプロトタイプ([[Prototype]] 内部スロット)を持っており、それを辿ることで継承を表現します(プロトタイプチェーン)。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Inheritance_and_the_prototype_chain

JavaScript では基本的にどのオブジェクトも凍結されておらず、好きにプロパティを追加していくことができます。かつて Prototype JavaScript FrameworkMooTools というライブラリが広く使われ、ビルトインオブジェクトのプロトタイプに独自のメソッドを追加して 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__ について詳しくは過去の記事を御覧ください。

https://zenn.dev/petamoriken/articles/a211183011cd58

グローバルやプロトタイプの変更に耐えうるコード

突然ですが以下のようなモジュールがあったとします。このコードは安全でしょうか。

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-jses-shims がそれにあたります[3]

Stage 1 Get Intrinsic

先程のようなコードを書くのを補助する提案 Get Intrinsic が 2021年8月の TC39 meeting で Stage 1 になりました。

https://github.com/ljharb/proposal-get-intrinsic

この提案はあくまで補助的なものであって、将来的にはプロトタイプの変更に対しては別のアプローチを取るべきだとしています。

primordials.js

Node.js や Deno は内部コードで JavaScript を使ってユーザーに API を提供しています。その内部コードに primordials.js というものが存在し、これを使うことでありとあらゆるグローバル変数やビルトインオブジェクトのプロトタイププロパティの変更に耐えられるように実装されています。

https://github.com/nodejs/node/blob/e937662dec4adc6ac1cd65afd4cbda79703d5ecc/lib/internal/per_context/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 パッケージに悪意のあるコードが混入したりとセキュリティの問題が大きくなってきています。対処法はないのでしょうか。

Node.js の --frozen-intrinsics

実は Node.js には --frozen-intrinsics という実験的な CLI オプションがあります。これを使うことによって Object.prototype などのビルトインオブジェクトを凍結することが出来ます。

https://nodejs.org/dist/latest-v16.x/docs/api/cli.html#--frozen-intrinsics

ドキュメントに書かれているように polyfill を読み込みたい場合は --require オプションを使用する必要があります(ES Modules では使用できないようです)。

実装は以下にあります。

https://github.com/nodejs/node/blob/f8035ecbbd9f32ec5ae398495cd645f00c4b0c51/lib/internal/freeze_intrinsics.js

Stage 1 Secure ECMAScript

Stage 1 Secure ECMAScript という提案があります。こちらもビルトインオブジェクトを凍結することが出来ます。

https://github.com/tc39/proposal-ses

まだ 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();

ライブラリ開発者からするとビルトインオブジェクトを凍結するのは影響範囲が大きいため使えませんが、アプリケーション開発者の立場からするとこの提案はかなり嬉しいものだと思います。

この提案をしている方たちによる実験的なライブラリが公開されています。

https://github.com/endojs/endo/tree/master/packages/ses

結び

プロトタイプ汚染の話やそれに関連した提案、そして primordials.js を取り上げてみました。

セキュリティ周りの話としては Deno や WebAssembly のように明示的に権限を与えなければならないといった実行環境側の話もあります[5][6]が、言語仕様側でも議論されています。

みなさんもこのあたりを追ってみてはいかがでしょうか。

脚注
  1. この反動から全ての函数を $ ネームスペースで提供する jQuery が生まれたと言われています。 ↩︎

  2. MDN にも polyfill 以外でプロトタイプを拡張するのは設計ミスだと記述されています↩︎

  3. 拙作の float16 もそのように実装しました。 ↩︎

  4. polyfill によって既にあるビルトインオブジェクトに対してメソッドを追加する場合はこれで問題ありませんが、Temporal のように言語仕様に新しいネームスペースを持った API が追加された場合は古い JavaScript エンジンがその存在を認知できないため一筋縄ではいかない問題がありそうです。 ↩︎

  5. Deno では CLI オプションでパーミッションを明示的に指定する必要があります↩︎

  6. WebAssembly ではモジュール単位で扱える API を明示的に与えてあげる必要があります↩︎

Discussion