Skip to main content

Using ES2015 Proxy for fun and profit

著者:
Alon Niv
Alon Niv

2016年8月23日

0 分で読めます

Much has been written about ES2015 - with its arrow functions, scoped variable declarations and controversial classes. However, a certain feature has received little love so far: the Proxy.As JS developers, we’re not used to relying on trapping mechanisms throughout our codebase, but they have several very useful applications. To name a few:

  • Testing, mocking and monkeypatching

  • The Observer and Visitor design patterns

  • Abstractions over complicated concepts

Until now, the language hasn’t provided us with any such mechanism.I feel like Proxy solves this problem, while keeping the feel of the JS we know and love (i.e. no [Symbol.__setattr__] methods for our objects).In this post, I’ll give an example for the most common traps (get, set, has and deleteProperty), and in keeping with the Snyk spirit, it’s going to be about dependencies.

Our goal

We want to create a package that allows a developer to safely require modules, limiting their access to specific modules. After all, we wouldn’t want to trust a functional utilities module with our fs, right?

Setting the stage

The Proxy constructor accepts 2 parameters:

  • target: this is the Object we want to proxy around

  • handler: this is an Object containing the spec for the traps we want to handle (a trap, in this sense, is a function that is being called on certain events happening to the proxy, such as a property being accessed, set or deleted) - examples follow.

How require works

I’m only including this part because, while node’s docs do a pretty great job explaining this, we’re about to do some nifty things to the modules cache, so I want to make sure the require flow is clear:

  • Module x requires module y

  • Name y is resolved to an absolute path (or not, if it’s a core module, such as fs)

  • The resolved name is fetched from the cache, without checking for its existence

  • If the fetched value is defined (a Module object), the exported values are returned

  • Otherwise, node actually fetches the file from the FS, and compiles it into a Module object, which is put into the cache, and has its exported values returned.

This cache is a global singleton, accessible via require('module')._cache and require.cache, and is a plain JS Object.

Finally, some code

For simplicity’s sake, let’s assume we just want to provide a blacklisting interface for modules that shouldn’t be used by our required module. It’s going to look like this:

// 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']);

Okay, so now we have a feel for what it’s going to look like, let’s start coding:

// 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;
  }
};

Cool, we’re done, right?Well, not exactly. Yes, we made sure our nefarious module can’t require a blacklisted module, but there are other dirty tricks it can pull off:

// 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!
    }
  }
}

Side-note about require.cache: if you want to corrupt a single module in the module cache, use require.cache.If, however, you want to switch out the entire caching mechanism, use require('module')._cache = ....Let’s fix our code to handle this situation:

// ...
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;
  }
// ...

And… now we’re done… right? Nope. Let’s consider this nefarious code:

// 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);
}

Let’s just add some last touches, then:

// ...
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`);
  }
// ...

And now we’re done. Or, at least, I think so.

Do you have an idea how to circumvent this proxy? Share them with us on Twitter @snyksec!

The repo for this POC is available here.

カテゴリー: