Alberto Gimeno Ecosystem engineer at @github. Sometimes I write about JavaScript, Node.js and frontend development.

How to protect your Node.js applications from malicious dependencies

4 min read 1140


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 audit, Snyk 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/register work.
  • Run code in a sandboxed environment with the vm module and 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 readFileSync to 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 .

Conclusions

  • Malicious code (or just vulnerable code) in our apps is a growing problem because our apps become more complex and rely on more dependencies, making the attack surface bigger and bigger
  • Services and tools such as npm audit, Snyk and/or GitHub security alerts are helpful and you can start using them right now
  • We need to mitigate the effects of an attack and Node.js needs to do something regarding that. However, the solution is not in the near future
  • If you want to go “the hardcore way”, you can! Node.js is flexible enough to allow you to do crazy stuff to protect yourself. We just demonstrated it 🙂

Plug: LogRocket, a DVR for web apps

https://logrocket.com/signup/

LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single page apps.

Try it for free.

Alberto Gimeno Ecosystem engineer at @github. Sometimes I write about JavaScript, Node.js and frontend development.

Leave a Reply