
Using ES2015 Proxy for fun and profit
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
andVisitor
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 aroundhandler
: 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
require
s moduley
- Name
y
is resolved to an absolute path (or not, if it’s a core module, such asfs
) - 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,
some magic happensnode 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