Manifest V3扩展Content Script绕过CSP限制点击页面内元素

背景

在 Manifest V3 中，谷歌对 CSP 策略的限制变得更加严格。例如，不允许使用unsafe-inline指令，这避免扩展执行远程代码，然而，这也意味着注入到页面中隔离环境的 Content Scripts 受到了扩展 CSP 策略的约束。因此，当页面中的链接包含内联的事件处理器 /javascript:伪协议时，如果尝试在 Content Scripts 中点击链接，将发生错误，如下图所示：

Issue 1299742

在 Content Scripts 中，操纵页面元素是一个非常常见的需求，如何在保证扩展合法的情况下，正常进行按钮的点击，便变得十分重要。

解决方案

chrome.scripting介绍

为了达成这一目的，Chrome 在 ManifestV3 扩展中提供了动态注入脚本的能力（chrome.scripting）。这个接口能够让我们向指定的页面注入扩展中存在的脚本（扩展内的 js 文件、文件中存在的某一函数）。

Copy
// background.js

function someFunc() {
// 将被注入页面的函数，函数能够与页面交互，例如 document.querySelector("a").remove()
}

// 通过某种方式获取 tabId
let tabId = getTabId();

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

上面是一段示例代码，executeScript方法为我们提供了向指定页面注入脚本的能力（和通过 Manifest 文件注入 Content Scripts 是相同的），该方法提供了world参数（需要 mv3），可以取值 ISOLATED 和 MAIN。通过这个参数，开发者就能够自由选择应该将脚本注入 isolated 环境还是 main 环境。

isolated 就是默认情况下 Content Scripts 被注入的环境，在该环境下，Content Scripts 能够操作页面、访问页面顶层变量，但是原页面没有办法读取 Content Scripts 的内容，且 Content Scripts 受扩展 CSP 策略约束（默认情况下 Content Scripts 被注入到 isolated 环境）

相反地，被注入到 main 环境的脚本受到原页面 CSP 策略的限制。此外，原页面可以访问 Content Scripts 中的变量。

实现方式

现在有了executeScript方法，我们就可以尝试通过在 main 环境中执行click来绕过扩展的 CSP 策略限制。

大概的实现方式如下：

在 isolated 环境下的 Content Stript 中向 background 发起点击链接的请求，并传递元素选择器

background 收到点击链接的请求后，向页面注入一个 main 环境的脚本用于点击对应的按钮

示例代码

Copy
// 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",
});
}
});

Copy
// 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) });

日后谈

上面的解决方案并不优雅，需要一个工具函数来生成 CSS 选择器字符串，尤其是对于我这个强迫症来说。

我一直在尝试寻找一种更加方便的途径，例如 —— 能否直接传递元素对象而非 CSS 选择器字符串？

初探索

首先想到了自定义事件 —— 理论上 detail 能传递任何类型，能否通过自定义事件的 detail 字段传递元素？

不妨先写个简单的脚本验证一下：

Copy
// 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");
}
});

Copy
// 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 } }));
})();

出人意料的是，元素对象并不能通过自定义事件的 detail 字段传递，结果如下：

包含 el 字段的 detail 直接变成了 null

注：这应该是 Chrome 专门对 Isolated world 的限制，正常网页中能传递元素对象

解决

我也为此询问了 Chrome Extension Samples 仓库的开发者，得到了一种能够传递对象的方法 （就很邪门）

通过 MouseEvent 中的 relatedTarget 能够正常传递元素，demo 如下：

Copy
// 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();
}
});

Copy
// content.js (in ISOLATED WORLD)

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

这样就舒服多了，也没有 对象 -> 字符串 -> 对象 的转换过程，看着也舒服。