Looking under the hood of a Chrome extension - Part 1

In a recent conversation with a friend, I learned that there's a Chrome browser extension that purges Slack chat messages from the currently selected channel, and I got curious about its internals since Slack doesn't seem to offer this capability out of the box. In this series, I won't focus on the extension itself, but on the steps I took to figure out how it works, which can be helpful to understand runtime behaviours of web applications in general.

I created a brand new Slack org, a couple of test users and populated a channel with messages from these test users, enough to generate a few scrollable pages. Then I installed and used the extension to start deleting messages.

Observing the behaviour

The first thing I noticed was that only the messages displayed on screen were deleted, and there was a delay of roughly 1 second between each deletion request - we'll get to this later. I also opened Chrome DevTools in the Network tab and found the request that was created for every deletion - screenshot below:

Screenshot from Chrome DevTools showing the request generated by the browser extension to delete a message, including its parameters

Slack sends a lot of requests constantly, so for me to pinpoint which one I was interested in, I tried to filter by anything that had the word "delete" in it, as you can see in the search bar of Chrome DevTools screenshot above. Then I proceeded to inspect the request's contents and found out that it's a regular POST with a "Form Data" payload, which means the data is passed as if it was a regular form being posted, not as a JSON object. From my initial assessment, it looked like channel, ts and token were the important fields, the first pointing to the ID of the channel I was in, the second to the timespan of the message being deleted and the third to an authentication token.

The request had a token in its payload, and considering that most modern web applications send authentication tokens in an authorization header, I went to confirm whether the request had an actual authorization header and it didn't, which led me to believe Slack used a custom authentication mechanism. I googled Slack chat.delete and found the API documentation, which confirmed my assumptions regarding the authentication mechanism used in their API and gave me another important piece of information, that Stack API requests are rate limited, meaning you can't just send a burst of requests or Slack will start blocking them. This could be the reason why the extension was slow in sending the requests to delete the messages, still, it felt like it was too slow and it could have been be optimized to delay requests only when the rate limit was reached.

The next piece of the puzzle was the scrolling behaviour. I knew that once all messages on the screen were deleted, you had to scroll the page for the extension to pick the next ones. The extension also took ~1 second to detect the new messages - it looked like the code was scheduling the check for new messages through a setInterval or setTimeout and continuously querying the DOM for the message nodes. To validate this assumption, I needed to intercept the DOM queries and check what was being returned, which I did with this code:

// storing the original implementationsglobalThis.originalQuerySelector = document.querySelector;globalThis.originalQuerySelectorAll = document.querySelectorAll;// wraps the object in a Proxy to monitor the property readsfunction observe(someObject) {  const handler = {    get: (target, prop, receiver) => {      console.log(target, `Reading property: [${prop}]`);      // use `Reflect.get` to return the actual property value      return Reflect.get(target, prop, receiver);    }  };  return new Proxy(someObject, handler);}document.querySelector = (selector) => {  console.log(`Searching for a single element with the selector [${selector}]`);  const result = originalQuerySelector.call(document, ...arguments);  if (result) {    return observe(result);  }  return result;};document.querySelectorAll = (selector) => {  console.log(`Searching for multiple elements with the selector [${selector}]`);  const result = originalQuerySelectorAll.call(document, ...arguments);  if (result) {    return Array.from(result).map(item => observe(item));  }  return result;};

The core concept of this code is the usage of monkey patching to replace the implementations of document.querySelector and document.querySelectorAll with custom functions that allowed me to intercept the calls, inspect the selectors used, invoke the original implementations, wrap the result objects in Proxy instances and return them to the caller - which in this case is the extension. The proxied objects log every time a property is read and behave exactly like the original ones, allowing me to probe the extension's behaviour without breaking its implementation.

In the next part, I will break down the data I uncovered from intercepting the DOM queries, how I localized where the token was stored (and other critical parameters used in the requests) and prepare the path to build the same functionality, without relying on any DOM queries.