Node.js で最近変わりそうな Permission Policy について

さてさて、 25日目の Node.js アドベントカレンダーです。もう年の瀬ですね。振り返りシーズンなんで色々書きたかったんですが、ネタを見つけているうちにこの日になってしまいました。

Permission Policy とは

Node.js に新しく Permission を提供しようという試みです。元々 Node.js では同じプロセス内で動いてしまえば どんなモジュールであろうと同じ権限で色々できますね。外部ネットワークにアクセスしたり、ファイルを読み書きしたり。

プロセスに元から許可されている権限は全てできてしまいます。これが今まででは普通でしたが、今後はもしかしたら変わるかも?という話です。

権限に関して制限をかけて、拒否させることが可能です。

以下のような要領で拒絶させることができるようになります。

$ node --policy-deny=net

上のオプションでプロセス内のネットワークアクセスを拒否させることが可能です。lint や formatter のときはネットワークアクセスとか不要なのでこのようなオプションで動かすほうが望ましいかも知れませんね。

元々 policy が去年入ってた のですが、それをエンハンスするような形ですね。

ちなみにまだ議論真っ最中です。

github.com

Options

下記のオプションを設定することが可能です (現時点での Permission の実装では)。

  • fs ファイル読み書き
  • fs.in ファイル読み込み
  • fs.out ファイル書き込み
  • net ネットワークアクセス
  • net.in ネットワーク入力
  • net.out ネットワーク出力
  • process 子プロセス起動
  • signal プロセスシグナル送信(プロセスの強制終了などを許可するか否か)
  • env 環境変数読み込み
  • worker ワーカースレッド起動
  • wasi WASI の可否
  • timing 高精度タイマーを拒否(おそらく process.hrtime が使えなくなる、サイドチャネル攻撃対策)
  • addon ネイティブアドオンの実行拒否 (native addon から何でもやられてしまうのを防ぐ目的)

などなど、どう使うのか使途がよくわからないものもありますが、 上記の処理に制限をかけることが可能です。

この他にも、 Permission Policy の機能にはそもそも policy.json で細かく設定できるように予定されているものもあり、例えば port 番号で制限するとかネットワーク接続先で制限するとかの今後の展望は用意されています。 ブラウザの Content-Security-Policy のような allow/deny list で管理するとかも検討が進めばできるようになるかもしれません。

内部実装

ここは内部実装向けなので、興味のある方だけで良いです。

Node.js が v8 を起動する際に permission が設定されます。Permissionチェックは runInPrivilegedScope 関数が用意され、その関数の中で実行した場合のみ、権限チェックが行われます。その関数のコールバックの外側では権限チェックがされないので、内部の fs, net などのコアパッケージでは権限チェックをするところと権限チェックが行われない特権実行の両方が行われます。これは Node.js 内部関数で全てを制限されてしまうと何もできなくなるため、特権を持った関数実行と特権を持たないユーザーが設定した Permission とを区別するために行われています。

function foo(a, b, c) {
  return a + b + c;
}

const privilegedFoo = runInPrivilegedScope.bind(this, foo); // この中でのみ権限チェックされる

console.log(privilegedFoo(1, 2, 3));

// この外では権限チェックされない。

これって deno の permission と一緒?

そう思った方は deno を追いかけてる方ですね!

FAQ に書いてありますが、その回答としては Yes でもあり No でもあるとのことです。

元々の発想は deno から来ていますが、実装は物凄くシンプルに作られていて、 Node.js の internal API 部分で制御しています。 deno はセキュリティモデルからして node とは違うので同じものとは考えにくいです。 deno は特権が必要なときに (OS がカーネルに伺いを立てるかのごとく) 特権を要求するように設計されています。そのタイミングで制限をかけるようになっています。またデフォルトでは制限されていて、 opt-in で権限を付ける所が deno のがセキュリティとしては強いものであると言えるでしょう。

node はデフォルトでは全ての実行が許可されています。制限を後からかけるように設計されています。後から追加した機能なので、ある程度徐々に制限を厳しくできるように設計されたものと言えるでしょう。

package 毎の制御はできるのか?

これを思った方は deno を追いかけてる(以下略

今はできないと思います。 deno もできないのですが、本来はより粒度を細かい単位で実現できるできないの制御をしたいですよね。一律プロセス全体で ネットワーク接続できる、できない、ではなく、このパッケージではできるけど、このパッケージでは許可しない、といったような制御がしたいですよね。

deno も検討中ですが、まだできてないですね。

ただし、面白そうな議論は既に上がっています。

github.com

permission フィールドを package.json に追加させるようにして、その単位で制限をかけられないか?という npm の RFC ですね。 npm のスクリプト実行時に net へのアクセスが必要ならそのタイミングで --grant=net のような許可を要求できるようにしたい、というものです。

まだ全然議論が始まったばかりですが、このあたりは非常に注目していくほうが良いでしょう。

Deno 化する Node.js / Node.js 化する Deno

Node.js に ESM が入り、 top-level await などのモダンな機能が追加されていき、パーミッションの実行までできるようになってくると徐々に deno との差別化ポイントは薄くなっていきます。

Deno は Deno で Node.js との完璧な互換性を求めていないものの、 std:node のような標準モジュールを用意し、 compatible にできるところは合わせていく流れもあったりします。

github.com

結局エコシステムがどういう機能を求めるか次第で JavaScript の世界の API や環境は変わっていくので、どちらもそこまでの差がなくなっていくようにも思えました。

筆者はよく「これからは Deno を使ったほうが良いのでしょうか?」という旨の質問を受けることがあります。本質問に対しての筆者の意見を書いておきます。

Deno も Node.js もどちらも Web コミュニティの中にあるものであり、 Web コミュニティの進化に合わせて機能が作られていくという意味では外側の API 面ではあまり変わらないものになっていくと思われます。 Hello World サーバは Deno でも Node.js でもほとんど同様の書き方で提供されています。

Deno

import { serve } from "https://deno.land/std@0.82.0/http/server.ts";
const s = serve({ port: 8000 });
for await (const req of s) {
  req.respond({ body: "Hello World\n" });
}

Node.js

import { createServer } from 'http';
import { on } from 'events';
const reqs = on(
  createServer().listen(3000), 
  'request'
);
for await (const [_, res] of reqs) {
  res.end('Hello World\n');
}

外側に大きな差異がないのであれば、非機能要件での進化、パフォーマンス、セキュリティ、運用継続性などの観点で選べばよいと思っています。そのうちどちらも変わらない書き方で提供されるようになれば移行もそこまで難しくはなくなると思います。

開発者のタイミングで Deno を使うか Node.js を使うかを選べば良いのではないかと思っています。