Skip to main content

How to protect your Node.js applications from malicious dependencies

You have probably heard about a recent incident where a popular npm package, event-stream, included malicious code that could have affected thousands of apps (or more!). Hopefully, the attack was tailored to affect only a specific project.
The original author of the library was the victim of a social engineering attack and a malicious hacker gained publishing permissions. Many people argue that the original author should have been more cautious.
But that’s not the real problem.
Why?
Because the original author of the library could have published the malicious code intentionally, anyone who owns a library could publish malicious code at any time. A lot of us are relying on the honor system, hoping that no one will publish malicious code.
How can we prevent that?
Well, there’s always going to be multiple ways of hacking the system and injecting malicious code into our apps. Not only through dependencies but also through unintentional vulnerabilities.
However, we can still think about how to prevent these things from happening but more importantly, we need to think about ways of mitigating their effects.

Prevention

There are some preventative actions you can take right now:
  • Lock your dependencies. Use package-lock.json or yarn.lockto prevent getting automatic updates when deploying (when doing npm/yarn installin your server). At least this way you will get fewer chances of getting a malicious update that the npm team hasn’t cleaned up yet. However, this wouldn’t have prevented the event-stream from affecting you since the malicious code was available in the npm registry for weeks. But it probably would have prevented you from a separate incident back in July.
  • Use npm auditSnyk and/or GitHub security alerts to be notified when any of your dependencies could contain security vulnerabilities.

Mitigation

Now, how can we mitigate the effects of an attack once it’s triggered?
Well, most attacks consist of stealing data, mining and sending back the results to a server, etc. So you could execute your Node.js with a user with very limited permissions: restrict filesystem access, configure iptables to restrict the application to only connect to certain domains, etc. The problem is that in the era of cloud services you probably can’t do that in your cloud provider.
Is there anything we can do inside Node.js?
The Node.js contributors have already started thinking about a Node.js Security Model. So, we can expect different levels of security to be implemented inside Node.js in the future.
I personally would love a permissions system where you could define what things you need to access in your package.json. For example:
{
  "permissions": {
    "fs": {
      "directories": {
        "$TEMP": "rw",
        "$SRC_ROOT": "r"
      }
    },
    "network": {
      "tcp": {
        "v4:*:$PORT": "LISTEN"
      }
    }
  }
}
This would be something like the Content Security Policy we have in modern browsers.
But of course, this is just my suggestion and the Node.js Security Model idea is just starting to be evaluated. Don’t expect an implementation in the near future.
So, is there something we can do right now? And more specifically, is there anything we can do in Userland without changing the Node.js internals?
The answer is yes!

Sandboxing your app — the hardcore way

Thanks to the dynamic nature of JavaScript that Node.js also follows, we are able to hack the runtime. We can:
  • Hijack the require() calls and manipulate the code that’s inside. That’s how ts-node/register and @babel/registerwork.
  • Run code in a sandboxed environment with the vm moduleand pass a custom require function that prevents accessing certain modules, or wraps core modules to prevent accessing certain things.
OR
  • Just override the core modules, directly. Let’s look at how we can do this:
I’m going to show a proof of concept of overriding readFileSyncto prevent accessing files in a specific directory. In practice, we should override a few other functions and we also have the option of whitelisting instead of blacklisting certain directories.
But as an example, I just want to prevent malicious code:
// malicious.js
const fs = require('fs')
const secrets = fs.readFileSync('/system/secrets.txt', 'utf8')
console.log(secrets);
I’m going to implement a cage.js file that overrides the fs core module and I’m going to intercept that function and prevent accessing files inside /system/:
// cage.js
const fs = require('fs')
const path = require('path')
const wrap = (module, name, wrapper) => {
  const original = module[name]
  module[name] = wrapper(original)
}
wrap(fs, 'readFileSync', (readFileSync) => (...args) => {
  const [filepath] = args
  const fullpath = path.resolve(filepath)
  if (fullpath.startsWith('/system/')) {
    throw new Error('You do not have permissions to access this file')
  }
  return readFileSync(...args)
})
// Prevent further changes
Object.freeze(fs)
Voilá! There it is. Now if we run the malicious code directly:
node malicious.js
We will see the contents of that file printed to the stdout. But if we tell Node.js to first run cage.js like this:
node -r cage.js malicious.js
We will see that the malicious code was not able to access the content of the file and an error was thrown.
Obviously, this is just a proof of concept. The next step would be to override more functions, make it configurable instead of hardcoding file paths, and, ideally, do the same with other core modules. For example overriding http(s).request .

Comments

Popular posts from this blog

4 Ways to Communicate Across Browser Tabs in Realtime

1. Local Storage Events You might have already used LocalStorage, which is accessible across Tabs within the same application origin. But do you know that it also supports events? You can use this feature to communicate across Browser Tabs, where other Tabs will receive the event once the storage is updated. For example, let’s say in one Tab, we execute the following JavaScript code. window.localStorage.setItem("loggedIn", "true"); The other Tabs which listen to the event will receive it, as shown below. window.addEventListener('storage', (event) => { if (event.storageArea != localStorage) return; if (event.key === 'loggedIn') { // Do something with event.newValue } }); 2. Broadcast Channel API The Broadcast Channel API allows communication between Tabs, Windows, Frames, Iframes, and  Web Workers . One Tab can create and post to a channel as follows. const channel = new BroadcastChannel('app-data'); channel.postMessage(data); And oth...

Certbot SSL configuration in ubuntu

  Introduction Let’s Encrypt is a Certificate Authority (CA) that provides an easy way to obtain and install free  TLS/SSL certificates , thereby enabling encrypted HTTPS on web servers. It simplifies the process by providing a software client, Certbot, that attempts to automate most (if not all) of the required steps. Currently, the entire process of obtaining and installing a certificate is fully automated on both Apache and Nginx. In this tutorial, you will use Certbot to obtain a free SSL certificate for Apache on Ubuntu 18.04 and set up your certificate to renew automatically. This tutorial will use a separate Apache virtual host file instead of the default configuration file.  We recommend  creating new Apache virtual host files for each domain because it helps to avoid common mistakes and maintains the default files as a fallback configuration. Prerequisites To follow this tutorial, you will need: One Ubuntu 18.04 server set up by following this  initial ...

Working with Node.js streams

  Introduction Streams are one of the major features that most Node.js applications rely on, especially when handling HTTP requests, reading/writing files, and making socket communications. Streams are very predictable since we can always expect data, error, and end events when using streams. This article will teach Node developers how to use streams to efficiently handle large amounts of data. This is a typical real-world challenge faced by Node developers when they have to deal with a large data source, and it may not be feasible to process this data all at once. This article will cover the following topics: Types of streams When to adopt Node.js streams Batching Composing streams in Node.js Transforming data with transform streams Piping streams Error handling Node.js streams Types of streams The following are four main types of streams in Node.js: Readable streams: The readable stream is responsible for reading data from a source file Writable streams: The writable stream is re...