背景#
在 Manifest V3 中,谷歌對 CSP 策略的限制變得更加嚴格。例如,不允許使用unsafe-inline
指令,這避免擴展執行遠程代碼,然而,這也意味著注入到頁面中隔離環境的 Content Scripts 受到了擴展 CSP 策略的約束。因此,當頁面中的鏈接包含內聯的事件處理器 /javascript:
偽協議時,如果嘗試在 Content Scripts 中點擊鏈接,將發生錯誤,如下圖所示:
在 Content Scripts 中,操縱頁面元素是一個非常常見的需求,如何在保證擴展合法的情況下,正常進行按鈕的點擊,便變得十分重要。
解決方案#
chrome.scripting介紹#
為了達成這一目的,Chrome 在 ManifestV3 擴展中提供了動態注入腳本的能力(chrome.scripting)。這個接口能夠讓我們向指定的頁面注入擴展中存在的腳本(擴展內的 js 文件、文件中存在的某一函數)。
// 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 環境的腳本用於點擊對應的按鈕
示例代碼#
// 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) });
日後談#
上面的解決方案並不優雅,需要一個工具函數來生成 CSS 選擇器字符串,尤其是對於我這個強迫症來說。
我一直在嘗試尋找一種更加方便的途徑,例如 —— 能否直接傳遞元素對象而非 CSS 選擇器字符串?
初探索#
首先想到了自定義事件 —— 理論上 detail 能傳遞任何類型,能否通過自定義事件的 detail 字段傳遞元素?
不妨先寫個簡單的腳本驗證一下:
// 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 } }));
})();
出人意料的是,元素對象並不能通過自定義事件的 detail 字段傳遞,結果如下:
包含 el 字段的 detail 直接變成了 null
注:這應該是 Chrome 專門對 Isolated world 的限制,正常網頁中能傳遞元素對象
解決#
我也為此詢問了 Chrome Extension Samples 倉庫的開發者,得到了一種能夠傳遞對象的方法 (就很邪門)
通過 MouseEvent 中的 relatedTarget 能夠正常傳遞元素,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 }));
這樣就舒服多了,也沒有 對象 -> 字符串 -> 對象 的轉換過程,看著也舒服。