Skip to main content

How to prevent prototype pollution vulnerabilities in JavaScript

Écrit par:
0 minutes de lecture

Prototype pollution is a JavaScript vulnerability that enables attackers to add potentially malicious properties to object prototypes. Various forms of subsequent attacks are enabled when user-defined objects inherit these prototypes.

In this article, you'll learn more about prototype pollution, how it's enabled, what kind of attacks it opens the door to, and how to prevent them. You'll also learn how Snyk tools can help detect and address this vulnerability in your code and dependencies.

What are prototypes in JavaScript

JavaScript uses something called prototypical inheritance. A prototype is an object with a set of properties and functions shared between all variables of the same type. When a variable is declared in JavaScript, it automatically accesses one or more prototypes.

For example, when you define a numeric variable const one = 1, you can access the toExponential() function in the number's prototype. The number's prototype also has its prototype: the object prototype. This is why, even when working with a number, you have access to hasOwnProperty(): a function defined on the object prototype that the number's prototype has not overridden.

When a numeric variable has a prototype that, in turn, links to another prototype and is referred to as the prototype chain, prototype chains of all types ultimately link up to the object prototype.

In most other languages, inheritance relationships are fixed at compile time, but in JavaScript, they can be updated at runtime.

Suppose you add a property or function to a prototype of any given variable. In that case, this property or function becomes available to all other variables of the same type in the application: those created before the update and those made after.

If you add a property or function to the object prototype that sits atop the inheritance chain, it becomes available to all other variables unless they override it.

For example, here's a simple JavaScript object:

const myFirstObject = {};

If you want to know what this object's prototype is, you can use the __proto__ keyword:

myFirstObject.__proto__

You could use the following code to create another empty object and then add a property to its prototype:

const mySecondObject = {}
mySecondObject.__proto__.customProperty = "this is a property in the prototype"

So what happens if you log prototypes of both of these objects? See the following code:

console.log(myFirstObject.__proto__)
console.log(mySecondObject.__proto__)

The output of both console.log() calls is identical:

[Object: null prototype] { customProperty: 'this is a property in the prototype' }
[Object: null prototype] { customProperty: 'this is a property in the prototype' }

The customProperty you've added to the second object's prototype also shows up on the first object's prototype. It's now shared between all objects in your application.

If you add a third empty object like this and want to see its prototype, use this code:

const myThirdObject = {}
console.log(myThirdObject.__proto__)

You'd find that customProperty is there, too:

[Object: null prototype] { customProperty: 'this is a property in the prototype' }

If you define an own property on an object, that property will be used:

const nonEmptyObject = {
    customProperty: "this is a property defined on the object itself"
}
console.log(nonEmptyObject.customProperty) // "this is a property defined on the object itself"

However, if you use an object with a property that isn't defined in it, then the JavaScript runtime will look up the property in the object's prototype chain and use that:

const anotherEmptyObject = {}
console.log(anotherEmptyObject.customProperty) // "this is a property in the prototype"

What is prototype pollution in JavaScript

Prototype pollution is a vulnerability that allows attackers to inject malicious properties and functions into prototypes in JavaScript applications. However, prototype pollution isn't an attack but rather an enabler for various attacks. The object prototype is the most common target for prototype pollution attacks, as infecting it with malicious code gives the attacker maximum coverage.

Both server-side and client-side JavaScript code can be vulnerable to prototype pollution attacks. On the server, prototype pollution often enables privilege escalation, denial of service, or remote code execution. On the client side, prototype pollution can pave the way for DOM XSS.

This vulnerability can occur in applications written in JavaScript and any language that compiles to JavaScript, including, most prominently, TypeScript. Additionally, prototype pollution can crop up in your code and dependencies, including some versions of popular libraries, such as Lodash, collection.js, and jQuery.

Imagine that an attacker sneaks in and gets to define the following property on the object prototype in your running JavaScript application:

isAdmin: true

If they can do that, the attacker can start looking around your application's code for loosely secured access level checks. If a check relies on a JavaScript object representing the current user and that object doesn't have its property, isAdmin: false, then the runtime finds isAdmin: true in the prototype. From there, it's easy for the attacker to elevate privileges and gain access to restricted areas of your application.

However, it's more than just defining new properties on a prototype. An attacker can also resort to overriding functions that already exist on the prototype. For instance, let's say an attacker has been able to override the object prototype's toString() function with a recursive call:

let newObject = {}
newObject.__proto__.toString = function() {this.toString()}

After this, the first call to toString() on an object or even object interpolation in a template string will bring the host process down with a RangeError: Maximum call stack size exceeded error.

A popular way for attackers to pollute a prototype is via unsafe implementations of recursive property merge in application code and libraries.

Prototype pollution in action

Let's experiment with a sample application to help you understand how prototype pollution works in Node.js. You'll work with an Express application with an in-memory MongoDB database and a few endpoints.

Clone this repository if you want to follow along. Once you do, restore dependencies and launch the application:

npm install
npm start

Open http://localhost:8080/todos/ in your browser or send a GET request to http://localhost:8080/todos/ from your favorite HTTP client. You will get an array containing three to-do items:

[
  {
    "_id": "654c062468efa31d8c055c13",
    "text": "Jason's first todo item",
    "open": true,
    "visible": true,
    "owner": "654c062468efa31d8c055c0e",
    "__v": 0
  },
  {
    "_id": "654c062468efa31d8c055c14",
    "text": "First todo for Kelly",
    "open": true,
    "visible": true,
    "owner": "654c062468efa31d8c055c0f",
    "__v": 0
  },
  {
    "_id": "654c062468efa31d8c055c15",
    "text": "Paul's thing to be done",
    "open": true,
    "visible": true,
    "owner": "654c062468efa31d8c055c10",
    "__v": 0
  }
]

Now, if you open database/seed.js in the cloned repository, you'll see that the database in the application has been seeded with not three but four to-do items:

const seedTodoItems = [
        {
            text: "Jason's first todo item",
            open: true,
            visible: true,
            owner: "Jason"
        },
        {
            text: "First todo for Kelly",
            open: true,
            visible: true,
            owner: "Kelly"
        },
        {
            text: "Paul's thing to be done",
            open: true,
            visible: true,
            owner: "Paul"
        },
        {
            text: "A HIDDEN TODO",
            open: true,
            owner: "Alexandra"
        }
    ]

Three seed to-do items are explicitly set to visible, but one is not, meaning it's hidden by default. If you open server.js and look at the endpoint that serves the GET request, you can see that it only returns visible to-do items:

app.get('/todos/', (req, res) => {
    TodoItem.find({visible: true})
        .then(data => res.json(data))
        .catch(error => res.json({error}))
})

Use another endpoint to add a new to-do item. Send the following POST request to http://localhost:8080/todos/add:

POST http://localhost:8080/todos/add
Content-Type: application/json

{
  "__proto__": {
    "visible": true
  },
  "text": "😈 new todo item that tries to mess with the prototype",
  "owner": "654c0cc960d82b5802a0b30d"
}

You can use the following curl request from the command line to create a new todo item:

curl -X POST -H 'content-type: application/json' http://localhost:8080/todos/ad
d --data '{  "__proto__": {
    "visible": true
  },
  "text": "😈 new todo item that tries to mess with the prototype",
  "owner": "654c0cc960d82b5802a0b30d"
}'

Now, send a GET request to http://localhost:8080/todos/ again. Here's what you're going to get back:

[
  {
    "_id": "654c0d59654aeeb63dfd5ae8",
    "text": "Jason's first todo item",
    "open": true,
    "visible": true,
    "owner": "654c0d59654aeeb63dfd5ae3",
    "__v": 0
  },
  {
    "_id": "654c0d59654aeeb63dfd5ae9",
    "text": "First todo for Kelly",
    "open": true,
    "visible": true,
    "owner": "654c0d59654aeeb63dfd5ae4",
    "__v": 0
  },
  {
    "_id": "654c0d59654aeeb63dfd5aea",
    "text": "Paul's thing to be done",
    "open": true,
    "visible": true,
    "owner": "654c0d59654aeeb63dfd5ae5",
    "__v": 0
  },
  {
    "_id": "654c0d59654aeeb63dfd5aeb",
    "text": "A HIDDEN TODO",
    "open": true,
    "owner": "654c0d59654aeeb63dfd5ae6",
    "__v": 0
  },
  {
    "_id": "654c0e6f654aeeb63dfd5aef",
    "text": "😈 new todo item that tries to mess with the prototype",
    "open": true,
    "owner": "654c0cc960d82b5802a0b30d",
    "__v": 0
  }
]

You had three visible to-do items to begin with. You've added one more. But now, notice that there are five instead of four. The to-do item that was intended to be hidden is now visible!

Let's see what happens in the endpoint that adds a new to-do item:

app.post('/todos/add', (req, res) => {
    const defaults = {
        open: true,
    }
    const todoToAdd = lodash.merge(defaults, req.body)
    const todoItem = new TodoItem(todoToAdd);
    todoItem.save()
        .then(() => res.json({msg: `Successfully added a todo item`}))
        .catch(error => res.json({error: error.message}))
})

The application parses the POST request body (a JSON object) and merges it into the defaults object that opens every new to-do item. To perform the merge, it uses the merge() method from a vulnerable release of Lodash. As a result, the malicious part of the request that manipulates the object prototype gets happily merged into the resulting object, setting Object.__proto__.visible to true along the way.

As a result, any existing and future objects that don't explicitly declare a visible property start getting it from their prototype that does have it. When the application queries the database as it executes a GET request, it uses a filter intended to retrieve only visible to-do notes:

app.get('/todos/', (req, res) => {
    TodoItem.find({visible: true})
        .then(data => res.json(data))
        .catch(error => res.json({error}))
})

When evaluating the filter, it turns out that the one hidden to-do item doesn't have its own visible: false property:

{
    text: "A HIDDEN TODO",
    open: true,
    owner: "Alexandra"
}

However, now the prototype has the visible: true set. The prototype property value gets used, resulting in an inadvertent leak of a preexisting to-do item that was meant to be hidden. In addition, the to-do item added as part of the malicious request is revealed, even though it doesn't have a visible: true set.

Preventing prototype pollution vulnerabilities 

In the previous sample application, fixing the vulnerability is as easy as upgrading Lodash to a more recent version. However, there are other ways to avoid prototype pollution that you need to be aware of, especially when looking at your code rather than something that came from a library.

Sanitize property keys

The most obvious way to address the problem is by checking if a key is safe to merge before actually merging it:

  if (key == '__proto__') {
    return;
  }

However, maintaining a denylist is not ideal, as there are known ways to bypass this protection.

Maintaining an allowlist of allowed property keys is a better option:

if (allowedKeys.includes(key)) {
    // Proceed with merge
}

Use the Map instead of an object

JavaScript provides an alternative object for storing key-value pairs: the Map. Although it's similar to an object in many ways, one major difference is that when you use the get() function of a map, you can only get from the map what you've explicitly put into it. Whatever malicious items that could have been put into the object prototype will not be accessed:

const defaultsMap = new Map()
defaultsMap.set("open", true);
defaultsMap.set("hidden", false)

// vs 

const defaultsObject = {
    open: true,
    hidden: false
}

Use Object.freeze() or Object.seal() to prevent changes

JavaScript provides two built-in functions that limit changes that can be made to objects: Object.freeze() and Object.seal(). Since the object prototype is an object itself, you can use these functions with it:

Object.freeze(Object.prototype)

In terms of preventing prototype pollution, Object.freeze() is preferred because it prohibits both adding new and modifying existing properties of an object.

Object.seal() prevents adding only new properties but allows updating existing ones. So, if you use Object.seal(), there's still room for prototype pollution by overriding the object prototype's functions, such as toString().

Use the Snyk nopp package

A nice extension of the object freezing concept is the Snyk nopp package. It applies Object.freeze() to some of the JavaScript built-in objects. When used toward the end of your app's initialization, it allows for legitimate prototype changes but blocks malicious attempts at prototype pollution when your application is in full flight.

In the sample Express application used previously, introducing nopp is as simple as installing it:

npm install nopp

And add it as the last import statement to server.js:

import express from 'express';
import connect from "./database/connection.js";
import seedDatabase from "./database/seed.js";
import TodoItem from "./model/todoitem.model.js";
import lodash from "lodash";
import cors from "cors";
import "nopp";

As soon as nopp is introduced, the prototype pollution scenario for privilege escalation gets mitigated, even if the sample application continues to use a vulnerable Lodash version.

Using the Snyk IDE extension to detect prototype pollution vulnerabilities

It's important to note that vulnerabilities, including prototype pollution, are easier to fix if they're easy to detect. Having a tool to point out possible security problems in your code editor can go a long way toward helping you ship secure JavaScript code.

If using a tool like this sounds intriguing, consider installing the Snyk Security extension for Visual Studio Code. (If you're working in JetBrains IDEs, such as WebStorm, there's an extension for you, too.)

The Snyk extension identifies and highlights possible prototype pollution and other kinds of vulnerabilities in your JavaScript code and libraries. For each detected vulnerability, it shows how various open source projects fixed similar issues.

Following is a screenshot of Snyk VS Code IDE extension in action. It detects multiple vulnerabilities in the npm package lodash including prototype pollution, regular expression denial of service, and command injection:

1How_to_prevent_prototype_pollution_vulnerabilities_in_JavaScript_-_Original

To install the Snyk extension, search for "snyk" in the Visual Studio Code Extensions pane:

2_How_to_prevent_prototype_pollution_vulnerabilities_in_JavaScript

Install the extension called "Snyk Security - Code, Open Source Dependencies, IaC Configurations":

3_How_to_prevent_prototype_pollution_vulnerabilities_in_JavaScript

The Snyk extension is installed along with the Snyk CLI, which you need to run Snyk inspections going forward.

Now click the Snyk icon in the Visual Studio Code left-hand menu bar, and in the Snyk pane, click Trust workspace and connect:

4_How_to_prevent_prototype_pollution_vulnerabilities_in_JavaScript

Snyk opens a browser window where you need to log into your Snyk account or create one:

5_How_to_prevent_prototype_pollution_vulnerabilities_in_JavaScript

After logging in, Snyk needs to authenticate your machine to associate your local Snyk CLI with your Snyk account:

6_How_to_prevent_prototype_pollution_vulnerabilities_in_JavaScript

After clicking Authenticate, the Snyk web application verifies that you've successfully authenticated:

6_How_to_prevent_prototype_pollution_vulnerabilities_in_JavaScript

Now, you can close the browser window and go back to Visual Studio Code. Once there, when you open a workspace or folder, Snyk starts its vulnerability analysis.

Here's what your Visual Studio Code may look like after letting the Snyk extension analyze a project:

7_How_to_prevent_prototype_pollution_vulnerabilities_in_JavaScript

In the “SNYK” pane on the left, you'll see a list of identified security and code quality issues, including prototype pollution, regular expression denial of service (ReDoS), cross-site request forgery (CSRF), and information exposure.

In the “Editor” tab, affected code statements are underlined and pressing Ctrl +. (or Cmd + . on Mac) displays a menu with related quick actions. In a separate pane, the Snyk extension summarizes the detected vulnerability and shows examples of how similar vulnerabilities were addressed in various open-source projects.

JavaScript's flexibility comes at a cost, and prototype pollution is a great example of how easily JavaScript can be abused if developers don't take appropriate measures to secure their code.

The best way to learn about application security is through hands-on coding. Don’t miss out on the Snyk Learn lesson about Prototype Pollution featuring interactive code examples!

8_How_to_prevent_prototype_pollution_vulnerabilities_in_JavaScript

Snyk Security IDE extensions can help you detect prototype pollution and other vulnerabilities early without having to leave your favorite code editor.

Using JavaScript's safer parts and smart developer tools helps you ship secure JavaScript code and avoid costly security incidents.