Blog post

Scripting Outside the Box: API Client Security Risks (1/2)

Oskar Zeino-Mahmalat, Paul Gerste photo

Oskar Zeino-Mahmalat, Paul Gerste

Vulnerability Researchers

Date

  • Code Security

Has this ever happened to you? You're looking at the documentation of a third-party API you want to integrate. You want to test the API quickly. Luckily, there are Postman and Insomnia collections ready to download that describe the API! You download and import the collection into Insomnia, an API-testing client, send a few requests, and see how the API responds.


And with that, you might have just gotten hacked! Would you have expected this to happen from this workflow?

In this two-part blog series, we take a look at the powerful scripting capabilities offered by popular API clients like Postman, Insomnia, Bruno, and Hoppscotch. We'll explore the security measures these tools employ, specifically custom JavaScript sandboxing. We then expose potential vulnerabilities that attackers could exploit within these sandboxes to achieve code execution. Finally, we provide guidance on implementing robust JavaScript sandboxing using currently available tools.

How API Clients Work

Before we dive into today's case studies, Insomnia and Postman, let's take a look at what all the API clients we investigated have in common.


First of all, they're all built on top of Electron, a framework to ship web apps as desktop applications. It consists of a built-in browser, Chromium, which has a Node.js integration to interact with the OS. There are security features such as nodeIntegration or contextIsolation that aim to restrict access to the Node.js APIs to only privileged parts of the application. This prevents Cross-Site Scripting (XSS) vulnerabilities in the web part of the application from having an impact on the whole machine. However, if these security features are not enabled, it means that there's a much bigger potential impact for attackers.


The purpose of API clients is to test and debug APIs with a user-friendly GUI. We looked at Postman, Insomnia, Bruno, and Hoppscotch, and they all have a common feature set: 

  • They allow organizing API calls into so-called collections
  • Support variables for reused values across different calls. 
  • And can include API credentials from different environments.


To cover advanced use cases, the API clients we looked into also support scripting. Such scripts can be embedded into collections and triggered by certain events, such as before sending a request. This can, for example, be used to insert credentials into a request without having to hard-code them.


These embedded scripts also pose a security risk: if a user downloads a collection from an untrusted source, the author could execute arbitrary code on the user's machine. To prevent this, the API clients we investigated implement various ways of sandboxing. Let's start with our first case study, Insomnia, to see their approach and its flaws.

Case Study 1: Insomnia

Insomnia uses scripting functionality for several features. One of them is Unit Testing, where developers can write test suites for their API. They support the Mocha JavaScript library to allow developers to write unit tests like they are used to.


However, Insomnia employs no sandboxing to isolate the test code from privileged parts of the application. Additionally, Insomnia enabled Electron's nodeIntegration while disabling contextIsolation, giving the tests access to all Node.js APIs. With this, a malicious test script could execute arbitrary system commands like this:

require("child_process").execSync("id > /tmp/pwnd")

Another less obvious Insomnia feature that supports scripting is templating, allowing developers to interpolate values into a request's body using specific syntax. This is implemented using the Nunjucks library, rendering the template each time one of the following events happens:

  • The body page is opened or edited.
  • A template literal is hovered over.
  • The request is sent.


While templating does not necessarily allow for arbitrary code execution, Nunjucks explicitly warns about this risk in their documentation:

Here, an attacker could use the templating syntax to first create a malicious JavaScript function via the Function constructor and then call it:

{{range.constructor("return require('child_process').execSync('id > /tmp/pwnd')")()}}

A third feature with scripting support is Pre-Request Scripts. Here, Insomnia implemented sandboxing to prevent these scripts from directly accessing privileged components such as Node.js APIs. They did this by moving the script execution to a separate hidden window.


When the hidden window receives a script execution request from the main window, it takes the code, creates a function from it, executes it, and sends back the result. This hidden window has contextIsolation turned on, which prevents the user script from accessing arbitrary Node.js APIs. However, there is a context bridge script that exposes a fake require function to that window, restricting which modules can be imported.


One of the allowed modules is fs, Node.js's file system access module, giving pre-request scripts fill read/write access to the user's file system. An attacker can use this to poison files such as ~/.bashrc with malicious system commands. Another technique would be to achieve code execution by writing to certain Node.js pipes.

Remediation

To fix the issue with pre-request scripts, Insomnia removed access to the fs module, preventing malicious scripts from accessing the file system. In addition to that, Insomnia added a small disclaimer to the import window, warning the user about importing files from untrusted sources:

Case Study 2: Postman

To sandbox scripts, Postman maintains and uses the uvm package, which is a wrapper for Node.js's built-in vm module. However, vm is not a security mechanism per Node.js's docs. If any reference to an outside object leaks into the VM context, sandbox escapes are possible. In theory, using vm should be safe if no references are passed, but in practice, that is rarely the case. API clients want to expose functionality inside the sandbox, like access to variables, so they have to give the sandboxed code some objects to work with.


Postman is no exception and introduces an object into scope that bridges function calls to the "outside world". They try to prevent issues with leaked references by using elaborate variable binding to hide references from being accessed by malicious code. One example is the setTimeout function. It is not available inside the VM, so it gets passed in from the outside. To prevent direct access to the function object, it is wrapped like this:

// this code runs inside the sandbox and setTimeout is passed in from outside
global.setTimeout = ((timeout) => {
    // the scope within this arrow function is the Intermediate Scope
    return (a, b) => timeout(a,b);
})(global.setTimeout);
untrustedCode(); // now only has access to the overwritten setTimeout function

First, the global.setTimeout function is the one from the outside world. Before the untrusted code executes, global.setTimeout is overwritten with an arrow function that captures the function from the outside world. This is done by wrapping it with an Immediately Invoked Function Expression (IIFE) that receives the outside function as an argument. Inside that IIFE, timeout is now the outside world's setTimeout function and the returned arrow function always uses it, unaffected by the later overwrite of global.setTimeout.


This indirection prevents access to the outside function while still allowing it to be called. However, there's a problem that's not immediately clear from looking at the code: In Node.js, the setTimeout returns a Timeout object, which is instantiated in the outside world! This gives the untrusted code inside the VM access to an outside reference:

This allows the untrusted code inside the sandbox to access the Timeout object's prototype chain and the function constructor. In JavaScript, obj.__proto__ references the object's prototype, which is like its class in other object-oriented languages. Next to the prototype, it is also possible to access the constructor function of the object's prototype via obj.__proto__.constructor, or obj.constructor as a shortcut.


Since basically everything is an object in JavaScript, the constructor function also has a prototype and a constructor, the generic Function constructor. Calling Function allows creating new JavaScript functions by passing the code as an argument. By calling obj.constructor.constructor('alert(1)'), a new function is created that will execute alert(1) when invoked.


The crucial point here is that since the Timeout object returned from setTimeout was created in the outside world, it also references the constructor from the outside world. Using the outside world's function constructor to create a new function creates it in the outside world's scope. The code in such a function therefore has access to all the objects and functions in the global scope of the outside world!


This allows the attacker to simply use the global object to access require(), import the child_process module, and execute arbitrary system commands! The latter works because Postman had nodeIntegration set to true, exposing Node.js APIs to the web portion of an Electron app.

Remediation

Postman released a fix in version 10.24.16 that now also hides Timeout objects returned into the VM. This does fix this specific vulnerability, but the approach still relies on catching all of these cases. The Postman team also needs to keep this pitfall in mind when exposing new functionality to untrusted code.

Timeline

DateAction
2024-03-19We report the issues to Insomnia and Postman via email
2024-03-19The Insomnia team asks us for more details
2024-03-19The Postman team asks us to use their bug bounty platform instead
2024-03-20We provide additional details to the Insomnia team
2024-03-21We decline Postman's request due to conflict with our disclosure policy
2024-03-22The Insomnia team informs us they validated our findings, deemed the criticality as low, and will employ remediations without a defined timeline
2024-03-28The Postman team informs us that the issue has been forwarded to the appropriate team
2024-04-03The Postman team informs us that they have started to roll out a fix

Summary

This concludes part one of our two-part blog post series on the security of API clients. We learned how these tools typically work and what is running under the hood. We've seen how security is still not a first-class citizen in some developer tools, and that it is crucial to know about the threat model of your tools. We also learned that building a safe sandbox is not an easy task and that small oversights can enable sandbox escapes.


Next time, we will see more sandbox bypasses and pitfalls, but also more holistic sandboxing approaches and fixes in response to our disclosures. We will also include a section listing good practices so you can learn how to do sandboxing correctly.


Finally, we would like to thank the Postman and Insomnia maintainers for their collaboration and communication around our disclosure.

Related Blog Posts