戴兜

戴兜

Coding the world.
github
bilibili
twitter

Bypass CSP restrictions on clicking page elements in Manifest V3 extension Content Script

Background#

In Manifest V3, Google has imposed stricter restrictions on CSP policies. For example, the use of the unsafe-inline directive is not allowed, which prevents extensions from executing remote code. However, this also means that Content Scripts injected into the page's isolated environment are subject to the constraints of the extension's CSP policy. Therefore, when a link in the page contains inline event handlers or the javascript: pseudo-protocol, an error occurs when attempting to click the link in the Content Scripts, as shown in the following images:

image

image

Issue 1299742

Manipulating page elements in Content Scripts is a very common requirement, and it becomes crucial to be able to click buttons normally while ensuring the legality of the extension.

Solution#

Introduction to chrome.scripting#

To achieve this, Chrome provides the ability to dynamically inject scripts into specified pages in Manifest V3 extensions through the chrome.scripting API (chrome.scripting). This API allows us to inject scripts that exist within the extension (such as JavaScript files in the extension or a specific function in a file) into the specified page.

// background.js

function someFunc() {
  // Function to be injected into the page, which can interact with the page, e.g., document.querySelector("a").remove()
}

// Get the tabId through some means
let tabId = getTabId();

chrome.scripting.executeScript({
    target: { tabId },
    function: someFunc,
    world: "MAIN",
});

The above code is an example that demonstrates the ability to inject scripts into a specified page using the executeScript method (similar to injecting Content Scripts through the Manifest file). This method provides the world parameter (requires MV3), which can have the values "ISOLATED" or "MAIN". With this parameter, developers can choose whether the script should be injected into the isolated environment or the main environment.

By default, Content Scripts are injected into the isolated environment, where they can manipulate the page and access top-level variables, but the original page cannot read the content of the Content Scripts, and the Content Scripts are subject to the constraints of the extension's CSP policy (by default, Content Scripts are injected into the isolated environment).

On the other hand, scripts injected into the main environment are subject to the CSP policy of the original page. In addition, the original page can access variables in the Content Scripts.

Implementation#

Now that we have the executeScript method, we can attempt to bypass the CSP policy restrictions of the extension by executing a click in the main environment.

The general implementation is as follows:

  1. In the Content Script in the isolated environment, send a request to the background to click the link and pass the element selector.
  2. Upon receiving the request to click the link, the background injects a script into the page in the main environment to click the corresponding button.

Example Code#

// background.js

function clickElement(elementSelector) {
  let el = document.querySelector(elementSelector);
  if (el) {
    el.click();
  }
}

chrome.runtime.onMessage.addListener(function (request, sender) {
  if (request.type === "click") {
    chrome.scripting.executeScript({
      target: { tabId: sender.tab.id },
      function: clickElement,
      args: [request.element],
      world: "MAIN",
    });
  }
});
// content.js (isolated world)

const el = document.querySelector('a[href^="javascript:"]');

const getCssPath = function (el) {
  if (!(el instanceof Element)) return;
  const path = [];
  while (el.nodeType === Node.ELEMENT_NODE) {
    var selector = el.nodeName.toLowerCase();
    if (el.id) {
      selector += "#" + el.id;
      path.unshift(selector);
      break;
    } else {
      let sib = el,
        nth = 1;
      while ((sib = sib.previousElementSibling)) {
        if (sib.nodeName.toLowerCase() == selector) nth++;
      }
      if (nth != 1) selector += ":nth-of-type(" + nth + ")";
    }
    path.unshift(selector);
    el = el.parentNode;
  }
  return path.join(" > ");
};

chrome.runtime.sendMessage({ type: "click", element: getCssPath(el) });

Future Considerations#

The above solution is not elegant and requires a utility function to generate CSS selector strings, especially for someone like me who has OCD.

I have been trying to find a more convenient way, such as passing the element object directly instead of a CSS selector string.

Initial Exploration#

The first idea was to use custom events. In theory, the detail field of a custom event can pass any type of data. Can we pass an element through the detail field of a custom event?

Let's write a simple script to test it:

// bg.js

function injectCustomEventListener() {
  window.addEventListener(
    "proxy-click",
    function (event) {
      console.log("proxy-click event received, element: ", event);
      const { detail: element } = event;
      if (element) {
        element.click();
      }
    },
    { once: true }
  );
}

chrome.runtime.onMessage.addListener(async function (
  request,
  sender,
  sendResponse
) {
  if (request.type === "injectEventListener") {
    await chrome.scripting.executeScript({
      target: { tabId: sender.tab.id },
      function: injectCustomEventListener,
      world: "MAIN",
    });
    sendResponse("done");
  }
});
// content-isolated.js
(async function () {
  const el = document.getElementById("demo-anchor-with-js-scheme");
  await chrome.runtime.sendMessage({ type: "injectEventListener" });
  window.dispatchEvent(new CustomEvent("proxy-click", { detail: { a: 123, el } }));
  window.dispatchEvent(new CustomEvent("proxy-click", { detail: { a: 123 } }));
})();

Surprisingly, the element object cannot be passed through the detail field of a custom event. The result is as follows:

image

image

The detail field containing the el field becomes null.

Note: This is likely a restriction specifically for Isolated worlds in Chrome. In a normal web page, it is possible to pass an element object.

Solution#

I also asked the developer of the Chrome Extension Samples repository about this and received a method to pass objects (which is quite strange).

It turns out that by using the relatedTarget property of a MouseEvent, we can successfully pass an element. Here's a demo:

// proxy-click.js (in MAIN world)
window.addEventListener('proxy-click', function ({ relatedTarget: element }) {
  console.log('proxy-click event received, element: ', element);
  if (element) {
    element.click();
  }
});
// content.js (in ISOLATED WORLD)

const el = document.getElementById('demo-anchor-with-js-scheme');
window.dispatchEvent(new MouseEvent('proxy-click', { relatedTarget: el }));

This approach is much more comfortable and eliminates the need for converting objects to strings and then back to objects. It looks much cleaner.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.