🛡️

Deno に“守り”のコントリビュートをしてきた話

2022/12/29に公開約4,400字

primordials.js について

Deno が提供している API は多くが JavaScript で実装されています。もし何も考えずに単純な JavaScript のコードで API を実装してしまっていた場合、ネイティブのプロトタイプメソッドが変更されてしまったときに影響を受けてしまいます。

この問題を回避するため Deno の内部コードでは primordials.js というものが用意されてあります。一旦ネイティブのメソッドをキャッシュしておいて、通常のメソッド呼び出しを使わずにこちらを使うようになっています。

例えばコンソールで表示するために、Error オブジェクトの cause プロパティを辿って文字列化する実装の序盤を見てみましょう。

https://github.com/denoland/deno/blob/65ea554afe1ce387ea1d663e6178079ebcf0904f/ext/console/02_console.js#L983-L995

ここに出てくる ArrayPrototypeIncludesArrayPrototypePush がまさしく primordials.js から読み込んだ函数です。

もし仮に ArrayPrototypeIncludes(causes, err.cause) という処理が causes.includes(err.cause) となっていた場合、Array.prototype.includes が書き換えられるとそちらが呼び出されてしまいます。それを回避するためにこのような内部実装になっているわけです。

primordials.js について詳しくは以前の記事を参照してください。

https://zenn.dev/petamoriken/articles/2f6511742d7907

“守り”のコントリビュート

promordials.js に準拠したコードはとてもとても JavaScript らしくありません。一応 deno_lint に prefer-primordials という Deno の内部コードにのみ使われることが想定されたルールがあり、Deno に対する PR は全て CI によってチェックされるようになっていますが、それでも Deno にプロトタイプ汚染耐性のないコードが取り込まれてしまうことがあります。

このことに問題意識を持った私はプロトタイプ汚染を防ぐ“守り”のコントリビュートをしてきました。一つずつどういうことをやってきたか振り返っていきたいと思います。

1. Iterable の扱い

Deno の内部コードで普通にスプレッド構文や for-of が使われていました。しかしこれらのシンタックスは Symbol.iterator メソッドを実行してしまいます。

// Array.prototype[Symbol.iterator] を書き換える
const original = Array.prototype[Symbol.iterator];
Array.prototype[Symbol.iterator] = function () {
  console.log("bang!");
  return original.call(this);
};

[1, 2, ...[3, 4]]; // bang!

primordials.js 的には配列をイテレートする際には SafeArrayIterator クラスでラップすることになっていましたが prefer-primordials で検知できておらず、徹底されていませんでした。

// こうすると安全
[1, 2, ...new SafeArrayIterator([3, 4])];

というわけで deno_lint に PR を出し、ちゃんと prefer-primordials で検知されるようにしました。

https://github.com/denoland/deno_lint/pull/995

https://github.com/denoland/deno_lint/pull/1039

2. instanceof 演算子

instanceof 演算子は Symbol.hasInstance メソッドによってカスタマイズ可能です。

// 代入演算子では Function.prototype[Symbol.hasInstance]
// を書き換えようとして例外が発生する(書き換え不可能)
// Object.defineProperty を使えば回避できる
Object.defineProperty(Error, Symbol.hasInstance, {
  value(target) {
    // Error なのに配列かどうかチェックする処理にしてしまえる
    return Array.isArray(target);
  },
});

// true
[1, 2, 3] instanceof Error;

代わりに primordials.js の ObjectPrototypeIsPrototypeOf (Object.prototype.isPrototypeOf) を使えば安全になります。

// false
ObjectPrototypeIsPrototypeOf(Error.prototype, [1, 2, 3]);

問題提起したところ ah-yu さんが prefer-primordials で検知されるようにしてくれました。

https://github.com/denoland/deno_lint/pull/992

3. 書き換えた Promise.prototype.then が呼ばれてしまう

ある日のこと。Deno の CLI でなんとなく Promise.prototype.then を書き換えたところ、それが呼ばれてしまうことを見つけてしまいました。

Deno v1.26.1 CLI
Deno v1.26.1 CLI で書き換えた Promise.prototype.then が呼ばれている様子

これは原因がすぐにはわかりませんでしたが、ECMAScript の仕様を読みながら原因を探り、なんとか Promise.all の問題だと突き止めました。Promise.all が問答無用で引数の Promise"then" メソッドを呼び出す仕様なため、PromiseAll としてキャッシュしていても十分ではありませんでした。

PerformPromiseAllの仕様
ECMAScript の PerformPromiseAll の仕様

そこで primordials.js に新たに SafePromiseAll という函数を追加し、そちらを使うことで解決しました。

https://github.com/denoland/deno/pull/16326

4. for-in のプロパティ所有チェック漏れ

for-in を使った場合[1]、そのオブジェクトの [[Prototype]] が持つ列挙可能なプロパティも一緒に列挙してしまいます。

// Object.prototype を汚染
Object.prototype.foo = 1;

const obj = { bar: 2 };
for (const key in obj) {
  console.log(key); // foo, bar
}

そのため for-in を使う場合は一緒に Object.hasOwn を使うなどして、ちゃんとプロパティを所有しているかどうかチェックする必要があります。

for (const key in obj) {
  if (!Object.hasOwn(obj, key)) {
    continue;
  }
  console.log(key);
}

Deno の内部コードで一部このチェックが漏れている箇所を発見したため修正しました。

https://github.com/denoland/deno/pull/17139

また再発しないように deno_lint に ESLint の guard-for-in を実装しました。

https://github.com/denoland/deno_lint/pull/1105

終わりに

以上が「Deno に“守り”のコントリビュートをしてきた話」となります。

世の中、新しい機能を追加するといった所謂“攻め”のコントリビュートの方が目立ちますが、折角ならとこのような記事を書いてみました。

最後まで読んでいただきありがとうございました!

脚注
  1. 一般のアプリケーションにおいて for-in は非推奨です ↩︎

Discussion

ログインするとコメントできます