JavaScript Security 101

charlottebrf_99

Charlotte Fereday

Posted on December 7, 2020

JavaScript Security 101

This blog post was originally published in Tes Engineering blog here.

I recently completed the JavaScript Security: Best Practices course by Marcin Hoppe and wanted to share some key practical take aways I learnt on how to write more secure JavaScript.
As well as reading this blog, I'd also highly recommend completing the course. It's short and sweet and hands on!

JavaScript Threat Environments

It's worth noting that there are two different threat environments: client-side JavaScript vs server-side JavaScript. For client-side JavaScript the browser operates on a low trust & highly restricted basis, necessarily so because it works with JavaScript from uncontrolled sources by virtue of users navigating the web.
In comparison for server-side JavaScript Node.js works on a high trust & privileged basis, because it's a controlled source (i.e. Engineering teams have written the code) and it doesn't change during runtime.
There's a more detailed summary of these differing threat environments in the Roadmap for Node.js Security, and it's important to keep this difference in mind when writing JavaScript.

The dynamic nature of JavaScript on the one hand makes it incredibly versatile, and on the other creates a number of security pitfalls. Here are three key pitfalls in JavaScript and how to avoid them.

1. Comparisons & conversions abuse

TLDR;
JavaScript has a dynamic type system which can have some dangerous but avoidable consequences. Use the JavaScript Strict mode to help avoid pitfalls such as loose comparison.

Some examples...

NaN, Null & undefined

Automated conversions can lead unexpected code to be executed:

console.log(typeof NaN) // number
console.log(typeof null) // object
console.log(typeof undefined) // undefined
Enter fullscreen mode Exit fullscreen mode

For example, this calculatingStuff function relies on the input being a number. Without any validation to guard against the input being NaN, the function still runs because NaN is classed as a number.

const calculatingStuff = (num) => {
  return num * 3;
};

console.log(calculatingStuff(NaN)) // NaN
Enter fullscreen mode Exit fullscreen mode

It's important to have guard clauses and error handling in place to avoid unexpected behaviour in automated conversions. For instance in this version of calculatingStuffv2 we throw an error if the input is NaN.

const calculatingStuffv2 = (num) => {
if (isNaN(num)) {
  return new Error('Not a number!')
}
  return num * 3;
};

console.log(calculatingStuffv2(NaN)) // Error: Not a number!
console.log(calculatingStuffv2(undefined)) // Error: Not a number!
console.log(calculatingStuffv2(null)) // 0
console.log(calculatingStuffv2(2)) // 6
Enter fullscreen mode Exit fullscreen mode

The isNaN() also guards against undefined, but will not guard against null. As with everything in JavaScript, there are many ways you could write checks to guard against these NaN, null and undefined.
A more reliable approach to "catch 'em all" is to check for truthiness, as all of these values are falsy they will always return the error:

const calculatingStuffv2 = (num) => {
if (!num) {
  return new Error('Not a number!')
}
  return num * 3;
};

console.log(calculatingStuffv2(NaN)) // Error: Not a number!
console.log(calculatingStuffv2(undefined)) // Error: Not a number!
console.log(calculatingStuffv2(null)) // // Error: Not a number!
console.log(calculatingStuffv2(2)) // 6
Enter fullscreen mode Exit fullscreen mode

Loose comparison

Loose comparison is another way code could be unexpectedly executed:

const num = 0;
const obj = new String('0');
const str = '0';

console.log(num == obj); // true
console.log(num == str); // true
console.log(obj == str); // true
Enter fullscreen mode Exit fullscreen mode

Using the strict comparison === would rule out the possibility of unexpected side effects, because it always considers operands of different types to be different.

const num = 0;
const obj = new String('0');
const str = '0';

console.log(num === obj); // false
console.log(num === str); // false
console.log(obj === str); // false
Enter fullscreen mode Exit fullscreen mode

2. Injection attacks from dynamically executed code

TLDR;
Be sure to always validate data before using it in your application, and avoid passing strings as arguments to JavaScript functions which can dynamically execute code.

Some examples...

eval()

As described in the mdn docs eval 'executes the code it's passed with the privileges of the caller'.

This can become very dangerous if, for example, eval is passed an unvalidated user input with malicious code in it.

eval('(' + '<script type='text/javascript'>some malicious code</script>' + '(');
Enter fullscreen mode Exit fullscreen mode

Unsafe variants of browser APIs

Both setTimeout & setInterval have an optional syntax where a string can be passed instead of a function.

window.setTimeout('<script type='text/javascript'>some malicious code</script>', 2*1000);
Enter fullscreen mode Exit fullscreen mode

Just like the eval() example this would lead to executing the malicious code at runtime. This can be avoided by always using the passing a function as the argument syntax.

3. Attacks from Prototype pollution

TLDR;
Every JavaScript object has a prototype chain which is mutable and can be changed at runtime. Guard against this by:

  1. Freezing the prototype to prevent new properties being added or amended
  2. Create objects without a prototype
  3. Prefer Map over plain {} objects

Some examples...

Here's an example where the value of the toString function in the prototype is changed to execute the malicious script.

let cutePuppy = {name: "Barny", breed: "Beagle"}
cutePuppy.__proto__.toString = ()=>{<script type='text/javascript'>some malicious code</script>}
Enter fullscreen mode Exit fullscreen mode

A couple of approaches to mitigate this risk is to be careful when initiating new objects, to either create them removing the prototype, freeze the prototype or use Map object.

// remove
let cutePuppyNoPrototype = Object.create(null, {name: "Barny", breed: "Beagle"})

// freeze
const proto = cutePuppyNoPrototype.prototype;
Object.freeze(proto);

// Map
let puppyMap = new Map()
cutePuppyNoPrototype.set({name: "Barny", breed: "Beagle"})
Enter fullscreen mode Exit fullscreen mode

Prototypal inheritance is an underrated threat so it's definitely worth considering this to guard against JavaScript being exploited in a variety of ways.

Tooling

Finally, beyond being aware of these pitfalls of JavaScript, there are a number of tools you could use to get early feedback during development. It's important to consider security concerns for both JavaScript that you have written, and third party JavaScript introduced through dependencies.

Here are a few highlights from some great Static code analysis (SAST) tools listed in Awesome Node.js security & Guidesmiths Cybersecurity handbook.

In your code

  • Always use strict development mode when writing JavaScript
  • Use a linter, for example eslint can configured to guard against some of the pitfalls we explored above by editing the rules:
"rules": {
  "no-eval": "error",
  "no-implied-eval": "error",
  "no-new-func": "error",
}
Enter fullscreen mode Exit fullscreen mode

In your JavaScript dependencies code

  • Use npm audit to check for known vulnerabilities
  • Use lockfile lint to check changes in the package-lock.json which is typically not reviewed
  • Use trust but verify to compare an npm package with its source repository to ensure the resulting artifact is the same
💖 💪 🙅 🚩
charlottebrf_99
Charlotte Fereday

Posted on December 7, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related