背景#
Manifest V3 では、Google は CSP ポリシーの制限をより厳しくしました。例えば、unsafe-inline
ディレクティブの使用が許可されておらず、これにより拡張機能がリモートコードを実行することを防ぎますが、これはページに注入された隔離環境の Content Scripts も拡張 CSP ポリシーの制約を受けることを意味します。したがって、ページ内のリンクがインラインイベントハンドラーやjavascript:
擬似プロトコルを含む場合、Content Scripts 内でリンクをクリックしようとすると、以下の図のようにエラーが発生します。
Content Scripts 内でページ要素を操作することは非常に一般的な要求であり、拡張機能の合法性を保証しながらボタンを正常にクリックする方法が非常に重要になります。
解決策#
chrome.scriptingの紹介#
この目的を達成するために、Chrome は Manifest V3 拡張機能に動的にスクリプトを注入する能力を提供しています(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 Script から 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);
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 を通じて要素を正常に渡すことができることが分かりました。デモは以下の通りです:
// proxy-click.js (MAIN world内)
window.addEventListener('proxy-click', function ({ relatedTarget: element }) {
console.log('proxy-clickイベントを受信しました、要素: ', element);
if (element) {
element.click();
}
});
// content.js (ISOLATED WORLD内)
const el = document.getElementById('demo-anchor-with-js-scheme');
window.dispatchEvent(new MouseEvent('proxy-click', { relatedTarget: el }));
これでずっと快適になり、オブジェクト -> 文字列 -> オブジェクトの変換プロセスもなく、見た目も良くなりました。