Musings of the Tes Engineering Team
December 07, 2020
by Charlotte Fereday
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!
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.
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...
Automated conversions can lead unexpected code to be executed:
console.log(typeof NaN) // number
console.log(typeof null) // object
console.log(typeof undefined) // undefined
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
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
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
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
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
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...
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>' + '(');
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);
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.
TLDR; Every JavaScript object has a prototype chain which is mutable and can be changed at runtime. Guard against this by:
{}
objectsSome 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>}
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"})
Prototypal inheritance is an underrated threat so it's definitely worth considering this to guard against JavaScript being exploited in a variety of ways.
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.
use strict
development mode when writing JavaScriptUse 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",
}
package-lock.json
which is typically not reviewed