Skip to main content

ES2015 Proxyを活用して楽しみながら成果を上げる

著者
Headshot of Alon Niv

Alon Niv

2016年8月23日

0 分で読めます

ES2015については多くのことがこれまで書かれてきました。アロー関数やスコープ付き変数宣言、賛否の分かれるクラスなどが取り上げられています。しかし、これまであまり注目されてこなかった機能があります。それがProxyです。JS開発者は、コードベース全体でトラップ機構に頼ることに慣れていませんが、これは非常に役立つ用途がいくつもあります。いくつか挙げると、私たちの

  • テスト、モック、モンキーパッチ

  • ObserverおよびVisitorデザインパターン

  • 複雑な概念を抽象化する

これまで、このような仕組みは言語に用意されていませんでした。Proxyはこの問題を解決しつつ、私たちが慣れ親しみ、愛用しているJSらしさを保っていると思います(オブジェクトに[Symbol.__setattr__]メソッドを追加する必要はありません)。この記事では、よく使われるトラップ(get、set、has、deleteProperty)の例を紹介します。Snykらしく、依存関係を題材にします。

目標

開発者がモジュールへのアクセスを特定のものに制限し、安全にrequireできるパッケージを作るのが目標です。結局のところ、関数型ユーティリティモジュールにfsを使わせるわけにはいきませんよね?

準備

Proxyコンストラクターは、2つのパラメーターを受け取ります。

  • target:プロキシの対象となるオブジェクト

  • handler:処理するトラップの仕様を含むオブジェクトです(ここでいうトラップとは、プロパティへのアクセス、設定、削除など、プロキシに特定のイベントが発生したときに呼び出される関数です)。例を見ていきましょう。

requireの仕組み

この部分を説明するのは、nodeのドキュメントがとても分かりやすく解説しているからというだけではありません。これからモジュールキャッシュを巧みに操作するので、requireの流れを明確にしておきたいのです。

  • モジュールxがモジュールyをrequireする

  • yという名前が絶対パスに解決される(fsなどのコアモジュールの場合は除く)

  • 解決された名前を、存在確認をせずにキャッシュから取得する

  • 取得した値が定義済み(Moduleオブジェクト)の場合、エクスポートされた値を返す

  • それ以外の場合、nodeは実際にファイルシステムからファイルを取得し、Moduleオブジェクトにコンパイルします。オブジェクトはキャッシュに格納され、エクスポートされた値が返されます。

このキャッシュはグローバルなシングルトンで、require('module')._cacheおよびrequire.cacheからアクセスできる、通常のJSオブジェクトです。

では、コードを書いてみましょう

簡単にするため、requireされたモジュールで使わせたくないモジュールをブラックリストに登録するインターフェースを用意するとしましょう。次のようになります。

// our code here, patent pending ;)
const snykwire = require('snykwire');
// don't allow this module access either 'fs' nor 'net' core modules
const nefarious = snykwire('nefarious', ['fs', 'net']);

では、完成形のイメージがつかめたところで、コードを書き始めましょう。

// snykwire/index.js

const Module = require('module'); // we need this to access the global cache

module.exports = (moduleName, blacklist=[]) => {
  // let's make sure we have a set of RESOLVED blacklisted
  // modules we can easily check against.
  const blackSet = new Set(blacklist.map(require.resolve));
  // we're going to save a reference to the "clean" version of the cache,
  // so we can set it right afterwards
  const cache = Module._cache;
  Module._cache = new Proxy(cache, {
    /*
     * As I mentioned before, fetching [resolvedName] from the cache is
     * the first thing attempted, so we can be sure to trap any `require` call
     * here.
     * The `get` trap accepts 2 parameters: `target` (which is the object being
     * proxied - i.e. the cache) and the property being accessed (here it's the
     * resolved name of the module being accessed).
     * If we don't declare this trap, every property accessed will be passed
     * directly to the target, as if there were no proxy at all.
     */    get(target, resolvedName) {
      if (blackSet.has(resolvedName)) {
        // we could return a dummy module here, but it's easier to just throw an error for now
        throw new Error(
          `Module '${moduleName}' has attempted to access module '${resolvedName}'`
        );
      }
      // else, just act natural
      return target[resolvedName];
    }
  });
  try {
    // let's see if we can require the module now... ^-*_*-^
    return require(moduleName);
  } finally {
    // and... let's put things back where they belong
    Module._cache = cache;
  }
};

これで完了ですね?いえ、そう簡単ではありません。悪意のあるモジュールがブラックリスト対象のモジュールをrequireできないようにはしましたが、ほかにも厄介な手口があります。

// nefarious/index.js

// require('fs').writeFileSync('/etc/passwd', 'muhahaha');
// Drat! Foiled! Let's try something else...

require.cache.fs = {
  exports: {
    readFile() {
      process.exit(1); // muhahaha!
    }
  }
}

補足:require.cacheについて:モジュールキャッシュ内の単一のモジュールを改変したい場合は、require.cacheを使います。一方、キャッシュ機構全体を差し替えたい場合は、require('module')._cache = ...を使います。この状況にも対処できるよう、コードを修正しましょう。

// ...
Module._cache = new Proxy(cache, {
  get(target, resolvedName) {
    if (blackSet.has(resolvedName)) {
      throw new Error(
        `Module '${moduleName}' has attempted to access module '${resolvedName}'`
      );
    }
    return target[resolvedName];
  },
  /*
   * The `set` trap accepts 3 parameters: `target` (which is the object being
   * proxied - i.e. the cache), the property being set (here it's the
   * resolved name of the module being accessed) and the actual Module object.
   * If we don't declare this trap, every property set will be set
   * directly to the target, as if there were no proxy at all.
   */  set(target, resolvedName, mod) {
    if (blackSet.has(resolvedName)) {
      throw new Error(
        `Module '${moduleName}' has attempted to corrupt module '${resolvedName}'`
      );
    }
    target[resolvedName] = mod;
    // the `set` trap has to return `true` if it succeeded.
    // Returning a falsy value will throw a `TypeError`.
    return true;
  }
// ...

さて……これで完了……でしょうか?いいえ。次の悪意あるコードを考えてみましょう。

// nefarious/index.js

if ('fs' in require.cache) {
  delete require.cache.fs;
  // if I can't use it, nobody can!
  // (as long as they run in strict mode...)
  Object.freeze(require.cache);
}

では、最後の仕上げを加えましょう。

// ...
Module._cache = new Proxy(cache, {
  get(target, resolvedName) {
    throwIfForbidden(blackSet, resolvedName);
    return target[resolvedName];
  },
  set(target, resolvedName, mod) {
    throwIfForbidden(blackSet, resolvedName);
    target[resolvedName] = mod;
    return true;
  },
  // This traps `resolvedName in proxy`
  has(target, resolvedName) {
    throwIfForbidden(blackSet, resolvedName);
    return resolvedName in target;
  },
  // This traps `delete proxy[resolvedName]`
  deleteProperty(target, resolvedName) {
    throwIfForbidden(blackSet, resolvedName);
    delete target[resolvedName];
    // like with the `set` trap, return `true` on success
    return true;
  },
  // Traps Object.preventExtensions, and by extension, Object.freeze
  preventExtensions(target) {
    // Let's not let anyone mess with our cache
    throw new Error(`Module '${moduleName}' tried to lock your module cache`);
  }
// ...

これで完了です。少なくとも、そう思います。

このプロキシを回避する方法を思いつきましたか?Twitterで@snyksecまでぜひ教えてください!

このPOCのリポジトリはこちらで公開されています。

Capture the Flagを始めよう

オンデマンドのバーチャル入門ワークショップを見て、Capture the Flagの課題の解き方を学びましょう。

カテゴリー:

続きを読む

Blog

Stadium Summer:Snyk Connect Fan Zone Tour

SnykのFan Zone Tourでは、8都市と3回のオンラインセッションで、AIセキュリティのワークショップやネットワーキング、楽しい競技を開催しました。参加者はスキルを磨き、アイデアを共有し、共に成長しました。

Blog

Snyk VulnBench JS 1.0:LLMは同じバグを2回見つけられるか?

Snyk VulnBench JS 1.0:300回の反復スキャンで、LLMのセキュリティ検出結果は実行ごとに異なる一方、SASTとモデルはそれぞれ異なる脆弱性の見落としを検出することが明らかに。

feature insights context
Blog

お客様と築くAIセキュリティ:Evoのデザインパートナープログラムから得た5つの教訓

SnykのEvoデザインパートナープログラムから得た5つの重要な教訓をご紹介します。AIの検出、リスクインテリジェンス、ポリシーの自動化によって、チームが生成AIを保護し、拡大するAI利用を大規模に統制する方法をご覧ください。