從油猴指令碼管理器的角度審視Chrome擴充套件
在之前一段時間,我需要藉助Chrome擴充套件來完成一個需求,當時還在使用油猴指令碼與瀏覽器擴充套件之間調研了一波,而此時恰好我又有一些做的還可以的油猴指令碼 TKScript (點個star吧 😁),相對會比較熟悉指令碼管理器的能力,預估是不太能完成需求的,所以趁著這個機會,我又學習了一波瀏覽器擴充套件的能力。那麼在後來需求的開發過程中,因為有些能力是類似於指令碼管理器提供的基礎環境,致使我越來越好奇指令碼管理器是怎麼實現的,而實際上指令碼管理器實際上還是一個瀏覽器擴充套件,瀏覽器也並沒有給指令碼管理器開後門來實現相關能力,而讓我疑惑的三個問題是:
- 指令碼管理器為什麼能夠先於頁面的
JS
執行。 - 指令碼管理器是如何能夠得到頁面
window
物件。 - 指令碼管理器為什麼能夠無視瀏覽器的同源策略從而發起跨域的請求。
因此,之後調研了一波瀏覽器擴充套件能力的開發之後,總結了指令碼管理器的核心能力實現,同樣也是解答了讓我疑惑的這三個問題。
從零開始瀏覽器擴充套件的開發
Chrome
擴充套件是一種可以在Chrome
瀏覽器中新增新功能和修改瀏覽器行為的軟體程式,例如我們常用的TamperMonkey
、Proxy SwitchyOmega
、AdGuard
等等,這些擴充都是可以透過WebExtensions API
來修改、增強瀏覽器的能力,用來提供一些瀏覽器本體沒有的功能,從而實現一些有趣的事情。
實際上FireFox
是才第一個引入瀏覽器擴充套件/附加元件的主流瀏覽器,其在2004
年釋出了第一個版本的擴充套件系統,允許開發人員為FireFox
編寫自定義功能和修改瀏覽器行為的軟體程式。而Chrome
瀏覽器則在2010
年支援了擴充套件系統,同樣其也允許開發人員為Chrome
編寫自定義功能和修改瀏覽器行為的軟體程式。
雖然FireFox
是第一個引入瀏覽器擴充套件的瀏覽器,但是Chrome
的擴充套件系統得到了廣泛的認可和使用,也已經成為了現代瀏覽器中最流行的擴充套件系統之一。目前用於構建FireFox
擴充套件的技術在很大程度上與被基於Chromium
核心的瀏覽器所支援的擴充套件API
所相容,例如Chrome
、Edge
、Opera
等。在大多數情況下,為基於Chromium
核心瀏覽器而寫的外掛只需要少許修改就可以在FireFox
中執行,不過在實際測試中FireFox
對於V3
的擴充套件支援度可能並沒有那麼好,還是以V2
為主。
Manifest
我們可以先來想一下瀏覽器擴充到底是什麼,瀏覽器本身是支援了非常完備的Web
能力的,也就是同時擁有渲染引擎和Js
解析引擎,那麼瀏覽器擴充本身就不需要再去實現一套新的可執行能力了,完全複用Web
引擎即可。那麼問題來了,單純憑藉Js
是沒有辦法做到一些能力的,比如攔截請求、修改請求頭等等,這些Native
的能力單憑Js
肯定是做不到的,起碼也得上C++
直接執行在瀏覽器程式碼中才可以,實際上解決這個問題也很簡單,直接透過類似於Js Bridge
的方式暴露出一些介面就可以了,這樣還可以更方便地做到許可權控制,一定程度避免瀏覽器擴充套件執行一些惡意的行為導致使用者受損。
那麼由此看來,瀏覽器擴充套件其實就是一個Web
應用,只不過其執行在瀏覽器的上下文中,並且可以呼叫很多瀏覽器提供的特殊API
來做到一些額外的功能。那麼既然是一個Web
應用,應該如何讓瀏覽器知道這是一個擴充而非普通的Web
應用,那麼我們就需要標記和配置檔案,這個檔案就是manifest.json
,透過這個檔案我們可以來描述擴充套件的基本資訊,例如擴充套件的名稱、版本、描述、圖示、許可權等等。
在manifest.json
中有一個欄位為manifest_version
,這個欄位標誌著當前Chrome
的外掛版本,現在我們在瀏覽器安裝的大部分都是v2
版本的外掛,v1
版本的外掛早已廢棄,而v3
版本的外掛因為存在大量的Breaking Changes
,以及諸多原本v2
支援的API
在v3
被限制或移除,導致諸多外掛無法無損過渡到v3
版本。但是自2022.01.17
起,Chrome
網上應用店已停止接受新的Manifest V2
擴充套件,所以對於要新開發的擴充來說,我們還是需要使用v3
版本的受限能力,而且因為谷歌之前宣佈v2
版本將在2023
初完全廢棄,但是又因為不能做到完全相容v2
地能力,現在又延遲到了2024
年初。但是無論如何,谷歌都準備逐步廢棄v2
而使用v3
,那麼我們在這裡也是基於v3
來實現Chrome
擴充套件。
那麼構建一個擴充套件應用,你就需要在專案的根目錄建立一個manifest.json
檔案,一個簡單的manifest.json
的結構如下所示,詳細的配置文件可以參考https://developer.mozilla.org/zh-CN/docs/Mozilla/Add-ons/WebExtensions/manifest.json
:
{
"manifest_version": 3, // 外掛版本
"name": "Extension", // 外掛名稱
"version": "1.0.0", // 外掛版本號
"description": "Chrome Extension", // 外掛描述資訊
"icons": { // 外掛在不同位置顯示的圖示
"16": "icon16.png", // `16x16`畫素的圖示
"32": "icon32.png", // `32x32`畫素的圖示
"48": "icon48.png", // `48x48`畫素的圖示
"128": "icon128.png" // `128x128`畫素的圖示
},
"action": { // 單擊瀏覽器工具欄按鈕時的行為
"default_popup": "popup.html", // 單擊按鈕時開啟的預設彈出視窗
"default_icon": { // 彈出視窗按鈕圖示 // 可以直接配置為`string`
"16": "icon16.png", // `16x16`畫素的圖示
"32": "icon32.png", // `32x32`畫素的圖示
"48": "icon48.png", // `48x48`畫素的圖示
"128": "icon128.png" // `128x128`畫素的圖示
}
},
"background": { // 定義後臺頁面的檔案和工作方式
"service_worker": "background.js" // 註冊`Service Worker`檔案
},
"permissions": [ // 定義外掛需要訪問的`API`許可權
"storage", // 儲存訪問許可權
"activeTab", // 當前選項卡訪問許可權
"scripting" // 指令碼訪問許可權
]
}
Bundle
既然在上邊我們確定了Chrome
擴充套件實際上還是Web
技術,那麼我們就完全可以利用Web
的相關生態來完成外掛的開發,當前實際上是有很多比較成熟的擴充套件框架的,其中也集合了相當一部分的能力,只不過我們在這裡希望從零開始跑通一整套流程,那麼我們就自行藉助打包工具來完成產物的構建。在這裡選用的是Rspack
,Rspack
是一個於Rust
的高效能構建引擎,具備與Webpack
生態系統的互操作性,可以被Webpack
專案低成本整合,並提供更好的構建效能。選用Rspack
的主要原因是其編譯速度會快一些,特別是在複雜專案中Webpack
特別是CRA
建立的專案打包速度簡直慘不忍睹,我這邊有個專案改造前後的dev
速度對比大概是1min35s : 24s
,速度提升還是比較明顯的,當然在我們這個簡單的Chrome
擴充套件場景下實際上是區別不大。
那麼現在我們先從manifest.json
開始,目標是在右上角實現一個彈窗,當前很多擴充套件程式也都是基於右上角的小彈窗互動來控制相關能力的。首先我們需要在manifest.json
配置action
,action
的配置就是控制單擊瀏覽器工具欄按鈕時的行為,因為實際上是web
生態,所以我們應該為其配置一個html
檔案以及icon
。
"action": {
"default_popup": "popup.html",
"default_icon": "./static/favicon.png"
}
已經有了配置檔案,現在我們就需要將HTML
生成出來,在這裡就需要藉助rspack
來實現了,實際上跟webpack
差不多,整體思路就是先配置一個HTML
模版,然後從入口開始打包Js
,最後將Js
注入到HTML
當中就可以了,在這裡我們直接配置一個多入口的輸出能力,通常一個擴充套件外掛不會是隻有一個Js
和HTML
檔案的,所以我們需要配置一個多入口的能力。在這裡我們還打包了兩個檔案,一個是popup.html
作為入口,另一個是worker.js
作為後臺執行的Service Worker
獨立執行緒。
entry: {
worker: "./src/worker/index.ts",
popup: "./src/popup/index.tsx",
},
plugins: [
new HtmlPlugin({
filename: "popup.html",
template: "./public/popup.html",
inject: false,
}),
],
實際上我們的dev
模式生成的程式碼都是在記憶體當中的,而谷歌擴充套件是基於磁碟的檔案的,所以我們需要將生成的相關檔案寫入到磁碟當中。在這裡這個配置是比較簡單的,直接在devServer
中配置一下就好。
devServer: {
devMiddleware: {
writeToDisk: true,
},
},
但是實際上,如果我們是基於磁碟的檔案來完成的擴充套件開發,那麼devServer
就顯得沒有那麼必要了,我們直接可以透過watch
來完成,也就是build --watch
,這樣就可以實現磁碟檔案的實時更新了。我們使用devServer
是更希望能夠藉助於HMR
的能力,但是這個能力在Chrome
擴充套件v3
上的限制下目前表現的並不好,所以在這裡這個能力先暫時放下,畢竟實際上v3
當前還是在收集社群意見來更新的。不過我們可以有一些簡單的方法,來緩解這個問題,我們在開發擴充套件的最大的一個問題是需要在更新的時候去手動點選重新整理來載入外掛,那麼針對於這個問題,我們可以藉助chrome.runtime.reload()
來實現一個簡單的外掛重新載入能力,讓我們在更新程式碼之後不必要去手動重新整理。
在這裡主要提供一個思路,我們可以編寫一個rspack
外掛,利用ws.Server
啟動一個WebSocket
伺服器,之後在worker.js
也就是我們將要啟動的Service Worker
來連結WebSocket
伺服器,可以透過new WebSocket
來連結並且在監聽訊息,當收到來自服務端的reload
訊息之後,我們就可以執行chrome.runtime.reload()
來實現外掛的重新載入了,那麼在開啟的WebSocket
伺服器中需要在每次編譯完成之後例如afterDone
這個hook
向客戶端傳送reload
訊息,這樣就可以實現一個簡單的外掛重新載入能力了。但是實際上這引入了另一個問題,在v3
版本的Service Worker
不會常駐,所以這個WebSocket
連結也會隨著Service Worker
的銷燬而銷燬,是比較坑的一點,同樣也是因為這一點大量的Chrome
擴充套件無法從v2
平滑過渡到v3
,所以這個能力後續還有可能會被改善。
接下來,開發外掛我們肯定是需要使用CSS
以及元件庫的,在這裡我們引入了@arco-design/web-react
,並且配置了scss
和less
的相關樣式處理。首先是define
,這個能力可以幫助我們藉助TreeShaking
來在打包的時候將dev
模式的程式碼刪除,當然不光是dev
模式,我們可以藉助這個能力以及配置來區分任意場景的程式碼打包;接下來pluginImport
這個處理引用路徑的配置,實際上就相當於babel-plugin-import
,用來實現按需載入;最後是CSS
以及前處理器相關的配置,用來處理scss module
以及元件庫的less
檔案。
builtins: {
define: {
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
},
pluginImport: [
{
libraryName: "@arco-design/web-react",
customName: "@arco-design/web-react/es/{{ member }}",
style: true,
},
],
},
module: {
rules: [
{
test: /\.module.scss$/,
use: [{ loader: "sass-loader" }],
type: "css/module",
},
{
test: /\.less$/,
use: [
{
loader: "less-loader",
options: {
lessOptions: {
javascriptEnabled: true,
importLoaders: true,
localIdentName: "[name]__[hash:base64:5]",
},
},
},
],
type: "css",
},
],
},
最後,我們需要處理一下資原始檔,因為我們在程式碼中實際上是不會引用manifest.json
以及我們配置的資原始檔的,所以在這裡我們需要透過一個rspack
外掛來完成相關的功能,因為rspack
的相關介面是按照webpack5
來做相容的,所以在編寫外掛的時候實際跟編寫webpack
外掛差不多。在這裡主要是實現兩個功能,一個是監聽manifest.json
配置檔案以及資源目錄public/static
的變化,另一個是將manifest.json
檔案以及資原始檔複製到打包目錄中。
const thread = require("child_process");
const path = require("path");
const exec = command => {
return new Promise((resolve, reject) => {
thread.exec(command, (err, stdout) => {
if (err) reject(err);
resolve(stdout);
});
});
};
class FilesPlugin {
apply(compiler) {
compiler.hooks.make.tap("FilePlugin", compilation => {
const manifest = path.join(__dirname, "../src/manifest.json");
const resources = path.join(__dirname, "../public/static");
!compilation.fileDependencies.has(manifest) && compilation.fileDependencies.add(manifest);
!compilation.contextDependencies.has(resources) &&
compilation.contextDependencies.add(resources);
});
compiler.hooks.done.tapPromise("FilePlugin", () => {
return Promise.all([
exec("cp ./src/manifest.json ./dist/"),
exec("cp -r ./public/static ./dist/static"),
]);
});
}
}
module.exports = FilesPlugin;
當然如果有需要的話,透過ts-node
來動態生成manifest.json
也是不錯的選擇,因為這樣我們就可以透過各種邏輯來動態地將配置檔案寫入了,比如拿來適配Chromium
和Gecko
核心的瀏覽器。
apply(compiler) {
compiler.hooks.make.tap("ManifestPlugin", compilation => {
const manifest = this.manifest;
!compilation.fileDependencies.has(manifest) && compilation.fileDependencies.add(manifest);
});
compiler.hooks.done.tapPromise("ManifestPlugin", () => {
delete require.cache[require.resolve(this.manifest)];
const manifest = require(this.manifest);
const version = require(path.resolve("package.json")).version;
manifest.version = version;
const folder = isGecko ? "build-gecko" : "build-chromium";
return writeFile(path.resolve(`${folder}/manifest.json`), JSON.stringify(manifest, null, 2));
});
}
Service Worker
我們在Chrome
瀏覽器中開啟chrome://extensions/
,可以看到我們瀏覽器中已經裝載的外掛,可以看到很多外掛都會有一個類似於background.html
的檔案,這是v2
版本的擴充套件獨有的能力,是一個獨立的執行緒,可以用來處理一些後臺任務,比如網路請求、訊息推送、定時任務等等。那麼現在擴充套件已經發展到了v3
版本,在v3
版本中一個非常大的區別就是Service Workers
不能保證常駐,需要主動喚醒,所以在chrome://extensions/
中如果是v3
版本的外掛,我們會看到一個Service Worker
的標識,那麼在一段時間不動之後,這個Service Worker
就會標記上Idle
,在這個時候其就處於休眠狀態了,而不再常駐於記憶體。
對於這個Service Worker
,Chrome
會每5
分鐘清理所有擴充套件Service Workers
,也就是說擴充套件的Worker
最多存活5
分鐘,然後等待使用者下次啟用,但是啟用方式沒有明確的表述,那假如我們的擴充要做的工作沒做完,要接上次的工作怎麼辦,Google
答覆是用chrome.storage
類似儲存來暫存工作任務,等待下次啟用。為了對抗隨機的清理事件,出現了很多骯髒的手段,甚至有的為了保持持續後臺,做兩個擴充套件然後相互喚醒。除了這方面還有一些類似於webRequest -> declarativeNetRequest
、setTimeout/setInterval
、DOM
解析、window/document
等等的限制,會影響大部分的外掛能力。
當然如果我們想在使用者主觀執行時實現相關能力的常駐,就可以直接chrome.tabs.create
在瀏覽器Tab
中開啟擴充套件程式的HTML
頁面,這樣就可以作為前臺執行,同樣這個擴充套件程式的程式碼就會一直執行著。
Chrome
官方部落格釋出了一個宣告More details on the transition to Manifest V3
,將Manifest V2
的廢除時間從2023
年1
月向後推遲了一年:
Starting in June in Chrome 115, Chrome may run experiments to turn off support for Manifest V2 extensions in all channels, including stable channel.
In January 2024, following the expiration of the Manifest V2 enterprise policy, the Chrome Web Store will remove all remaining Manifest V2 items from the store.
再來看看兩年前對廢除Manifest V2
的宣告:
January 2023: The Chrome browser will no longer run Manifest V2 extensions. Developers may no longer push updates to existing Manifest V2 extensions.
從原本的斬釘截鐵,變成現在的含糊和留有餘地,看來強如Google
想要執行一個影響全世界65%
網際網路使用者的Breaking Change
,也不是那麼容易的。但v3
實際上並不全是缺點,在使用者隱私上面,v3
絕對是一個提升,v3
增加了很多在隱私方面的限制,非常重要的一點是不允許引用外部資源。Chrome
擴充套件能做的東西實在是太多了,如果不瞭解或者不開源的話根本不敢安裝,因為擴充套件許可權太高可能會造成很嚴重的例如使用者資訊洩漏等問題,即使是比如像Firefox
那樣必須要上傳原始碼的方式來加強稽核,也很難杜絕所有的隱患。
通訊方案
Chrome
擴充套件在設計上有非常多的模組和能力,我們常見的模組有background/worker
、popup
、content
、inject
、devtools
等,不同的模組對應著不同的作用,協作構成了外掛的擴充套件功能。
background/worker
: 這個模組負責在後臺執行擴充套件,可以實現一些需要長期執行的操作,例如與伺服器通訊、定時任務等。popup
: 這個模組是擴充套件的彈出層介面,可以透過點選擴充套件圖示在瀏覽器中彈出,用於顯示擴充套件的一些資訊或操作介面。content
: 這個模組可以訪問當前頁面的DOM
結構和樣式,可以實現一些與頁面互動的操作,但該模組的window
與頁面的window
是隔離的。inject
: 這個模組可以向當前頁面注入自定義的JavaScript
或CSS
程式碼,可以實現一些與頁面互動的操作,例如修改頁面行為、新增樣式等。devtools
: 這個模組可以擴充套件Chrome
開發者工具的功能,可以新增新的皮膚、修改現有皮膚的行為等。
https://developer.mozilla.org/zh-CN/docs/Mozilla/Add-ons/WebExtensions/Content_scripts
https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Background_scripts
https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/user_interface/Popups
https://developer.mozilla.org/zh-CN/docs/Mozilla/Add-ons/WebExtensions/user_interface/devtools_panels
https://developer.mozilla.org/zh-CN/docs/Mozilla/Add-ons/WebExtensions/manifest.json/web_accessible_resources
在外掛的能力上,不同的模組也有著不同的區別,這個能力主要在於Chrome API
、DOM
訪問、跨域訪問、頁面Window
物件訪問等。
模組 | Chrome API |
DOM 訪問 |
跨域訪問 | 頁面Window 物件訪問 |
---|---|---|---|---|
background/worker |
絕大部分API ,除了devtools 系列 |
不可直接訪問頁面DOM |
可跨域訪問 | 不可直接訪問頁面Window |
popup |
絕大部分API ,除了devtools 系列 |
能直接訪問自身的DOM |
可跨域訪問 | 能直接訪問自身的Window |
content |
有限制,只能訪問runtime 和extension 等部分API |
可以訪問頁面DOM |
不可跨域訪問 | 不可直接訪問頁面Window |
inject |
不能訪問Chrome API |
可以訪問頁面DOM |
不可跨域訪問 | 可直接訪問頁面Window |
devtools |
有限制,只能訪問devtools 、runtime 和extension 等部分API |
可以訪問頁面DOM |
不可跨域訪問 | 可直接訪問頁面Window |
對於訊息通訊,在不同的模組需要配合三種API
來實現,短連結chrome.runtime.onMessage + chrome.runtime/tabs.sendMessage
、長連結chrome.runtime.connect + port.postMessage + port.onMessage + chrome.runtime/tabs.onConnect
,原生訊息window.postMessage + window.addEventListener
,下邊的表格中展示的是直接通訊的情況,我們可以根據實際的業務來完成間接通訊方案,並且有些方法只能在V2
中使用,可以酌情參考。
background/worker |
popup |
content |
inject |
devtools |
|
---|---|---|---|---|---|
background/worker |
/ |
chrome.extension.getViews |
chrome.tabs.sendMessage / chrome.tabs.connect |
/ |
/ |
popup |
chrome.extension.getBackgroundPage |
/ |
chrome.tabs.sendMessage / chrome.tabs.connect |
/ |
/ |
content |
chrome.runtime.sendMessage / chrome.runtime.connect |
chrome.runtime.sendMessage / chrome.runtime.connect |
/ |
window.postMessage |
/ |
inject |
/ |
/ |
window.postMessage |
/ |
/ |
devtools |
chrome.runtime.sendMessage |
chrome.runtime.sendMessage |
/ |
chrome.devtools.inspectedWindow.eval |
/ |
指令碼管理器核心能力的實現
不知道大家是否有用過油猴指令碼,因為實際上瀏覽器級別的擴充套件整體架構非常複雜,儘管當前有統一規範但不同瀏覽器的具體實現不盡相同,並且成為開發者並上架Chrome
應用商店需要支付5$
的註冊費,如果我們只是希望在Web
頁面中進行一些輕量級的指令碼編寫,使用瀏覽器擴充套件級別的能力會顯得成本略高,所以在沒有特殊需求的情況,在瀏覽器中實現級別的輕量級指令碼是很不錯的選擇。
那麼在簡單瞭解了瀏覽器擴充套件的開發之後,我們回到開頭提出的那三個問題,實際上這三個問題並沒有那麼獨立,而是相輔相成的,為了清晰我們還是將其拆開來看,所以我們在看每個問題的時候都需要假設另一方面的實現,比如在解答第三個為什麼能夠跨域請求的問題時,我們就需要假設指令碼實際是執行在Inject
環境中的,因為如果指令碼是執行在Background
中的話,那麼討論跨域就沒什麼意義了。
document_start
在油猴指令碼管理器中有一個非常重要的實現是@run-at: document-start/document-end/document-idle
,特別是document-start
,試想一下如果我們能夠在頁面實際載入的時候就執行我們想執行的JS
程式碼的話,豈不是可以對當前的頁面“為所欲為了”。雖然我們不能夠Hook
自面量的建立,但是我們總得呼叫瀏覽器提供的API
,只要用API
的呼叫,我們就可以想辦法來劫持掉函式的呼叫,從而拿到我們想要的資料,例如可以劫持Function.prototype.call
函式的呼叫,而這個函式能夠完成很大程度上就需要依賴我這個劫持函式在整個頁面是要最先支援的,否則這個函式已經被呼叫過去了,那麼再劫持就沒有什麼意義了。
Function.prototype.call = function (dynamic, ...args) {
const context = Object(dynamic) || window;
const symbol = Symbol();
context[symbol] = this;
args.length === 2 && console.log(args);
try {
const result = context[symbol](...args);
delete context[symbol];
return result;
} catch (error) {
console.log("Hook Call Error", error);
console.log(context, context[symbol], this, dynamic, args);
return null;
}
};
那麼可能我們大家會想這個程式碼的實現意義在何處,舉一個簡單的實踐,在某某文庫中所有的文字都是透過canvas
渲染的,因為沒有DOM
那麼如果我們想獲得文件的整篇內容是沒有辦法直接複製的,所以一個可行的方案是劫持document.createElement
函式,當建立的元素是canvas
時我們就可以提前拿到畫布的物件,從而拿到ctx
,而又因為實際繪製文字總歸還是要呼叫context2DPrototype.fillText
方法的,所以再劫持到這個方法,我們就能將繪製的文字拿出來,緊接著就可以自行建立DOM
畫在別處,想複製就可以複製了。
那麼我們回到這個問題的實現上,如果能夠保證指令碼是最先執行的,那麼我們幾乎可以做到在語言層面上的任何事情,例如修改window
物件、Hook
函式定義、修改原型鏈、阻止事件等等等等。其本身的能力也是源自於瀏覽器擴充,而如何將瀏覽器擴充套件的這個能力暴露給Web
頁面就是指令碼管理器需要考量的問題了。那麼我們在這裡假設使用者指令碼是執行在瀏覽器頁面的Inject Script
而不是Content Script
,基於這個假設,首先我們大機率會寫過動態/非同步載入JS
指令碼的實現,類似於下面這種方式:
const loadScriptAsync = (url: string) => {
return new Promise<Event>((resolve, reject) => {
const script = document.createElement("script");
script.src = url;
script.async = true;
script.onload = e => {
script.remove();
resolve(e);
};
script.onerror = e => {
script.remove();
reject(e);
};
document.body.appendChild(script);
});
};
那麼現在就有一個明顯的問題,我們如果在body
標籤構建完成也就是大概在DOMContentLoaded
時機再載入指令碼肯定是達不到document-start
的目標的,即使是在head
標籤完成之後處理也不行,很多網站都會在head
內編寫部分JS
資源,在這裡載入同樣時機已經不合適了,實際上最大的問題還是整個過程是非同步的,在整個外部指令碼載入完成之前已經有很多JS
程式碼在執行了,做不到我們想要的“最先執行”。
那麼下載我們就來探究具體的實現,首先是V2
的擴充套件,對於整個頁面來說,最先載入的必定是html
這個標籤,那麼很明顯我們只要將指令碼在html
標籤級別插入就好了,配合瀏覽器擴充套件中background
的chrome.tabs.executeScript
動態執行程式碼以及Content Script
的"run_at": "document_start"
建立訊息通訊確認注入的tab
,這個方法是不是看起來很簡單,但就是這麼簡單的問題讓我思索了很久是如何做到的。
// Content Script --> Background
// Background -> chrome.tabs.executeScript
chrome.tabs.executeScript(sender.tabId, {
frameId: sender.frameId,
code: `(function(){
let temp = document.createElementNS("http://www.w3.org/1999/xhtml", "script");
temp.setAttribute('type', 'text/javascript');
temp.innerHTML = "${script.code}";
temp.className = "injected-js";
document.documentElement.appendChild(temp);
temp.remove();
}())`,
runAt,
});
這個看起來其實已經還不錯了,能夠基本做到document-start
,但既然都說了是基本,說明還有些情況會出問題,我們仔細看這個程式碼的實現,在這裡有一個通訊也就是Content Script --> Background
,既然是通訊那麼就是非同步處理的,既然是非同步處理就會消耗時間,一旦消耗時間那麼使用者頁面就可能已經執行了大量的程式碼了,所以這個實現會偶現無法做到document-start
的情況,也就是實際上是會出現指令碼失效的情況。
那麼有什麼辦法解決這個問題呢,在V2
中我們能夠明確知道的是Content Script
是完全可控的document-start
,但是Content Script
並不是Inject Script
,沒有辦法訪問到頁面的window
物件,也就沒有辦法實際劫持頁面的函式,那麼這個問題看起來很複雜,實際上想明白之後解決起來也很簡單,我們在原本的Content Script
的基礎上,再引入一個Content Script
,而這個Content Script
的程式碼是完全等同於原本的Inject Script
,只不過會掛在window
上,我們可以藉助打包工具來完成這件事。
compiler.hooks.emit.tapAsync("WrapperCodePlugin", (compilation, done) => {
Object.keys(compilation.assets).forEach(key => {
if (!isChromium && key === process.env.INJECT_FILE + ".js") {
try {
const buffer = compilation.assets[key].source();
let code = buffer.toString("utf-8");
code = `window.${process.env.INJECT_FILE}=function(){${code}}`;
compilation.assets[key] = {
source() {
return code;
},
size() {
return this.source().length;
},
};
} catch (error) {
console.log("Parse Inject File Error", error);
}
}
});
done();
});
這段程式碼表示了我們在同樣的Content Script
的window
物件上掛了一個隨機生成的key
,而內容就是我們實際想要注入到頁面的指令碼,但是現在雖然我們能夠拿到這個函式了,怎麼能夠讓其在使用者頁面上執行呢,這裡實際上是用到了同樣的document.documentElement.appendChild
建立指令碼方法,但是在這裡的實現非常非常巧妙,我們透過兩個Content Script
配合toString
的方式拿到了字串,並且將其作為程式碼直接注入到了頁面,從而做到了真正的document-start
。
const fn = window[process.env.INJECT_FILE as unknown as number] as unknown as () => void;
// #IFDEF GECKO
if (fn) {
const script = document.createElementNS("http://www.w3.org/1999/xhtml", "script");
script.setAttribute("type", "text/javascript");
script.innerText = `;(${fn.toString()})();`;
document.documentElement.appendChild(script);
script.onload = () => script.remove();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete window[process.env.INJECT_FILE];
}
// #ENDIF
此外我們可能納悶,為什麼指令碼管理器框架和使用者指令碼都是採用這種方式注入的,而在瀏覽器控制檯的Sources
控制皮膚下只能看到一個userscript.html?name=xxxxxx.user.js
卻看不到指令碼管理器的程式碼注入,實際上這是因為指令碼管理器會在使用者指令碼的最後部分注入一個類似於//# sourceURL=chrome.runtime.getURL(xxx.user.js)
的註釋,其中這個sourceURL
會將註釋中指定的URL
作為指令碼的源URL
,並在Sources
皮膚中以該URL
標識和顯示該指令碼,這對於在除錯和追蹤程式碼時非常有用,特別是在載入動態生成的或內聯指令碼時。
window["xxxxxxxxxxxxx"] = function (context, GM_info) {
with (context)
return (() => {
// ==UserScript==
// @name TEST
// @description TEST
// @version 1.0.0
// @match http://*/*
// @match https://*/*
// ==/UserScript==
console.log(window);
//# sourceURL=chrome-extension://xxxxxx/DEBUG.user.js
})();
};
由於實際上Chrome
瀏覽器不再允許V2
的擴充套件程式提交,所以我們只能提交V3
的程式碼,但是V3
的程式碼有著非常嚴格的CSP
內容安全策略的限制,可以簡單的認為不允許動態地執行程式碼,所以我們上述的方式就都失效了,於是我們只能寫出類似下面的程式碼。
const script = document.createElementNS("http://www.w3.org/1999/xhtml", "script");
script.setAttribute("type", "text/javascript");
script.setAttribute("src", chrome.runtime.getURL("inject.js"));
document.documentElement.appendChild(script);
script.onload = () => script.remove();
雖然看起來我們也是在Content Script
中立即建立了Script
標籤並且執行程式碼,而他能夠達到我們的document-start
目標嗎,很遺憾答案是不能,在首次開啟頁面的時候是可以的,但是在之後因為這個指令碼實際上是相當於拿到了一個外部的指令碼,因此Chrome
會將這個指令碼和頁面上其他的頁面同樣處於一個排隊的狀態,而其他的指令碼會有強快取在,所以實際表現上是不一定誰會先執行,但是這種不穩定的情況我們是不能夠接受的,肯定做不到document-start
目標。實際上光從這點來看V3
並不成熟,很多能力的支援都不到位,所以在後來官方也是做出了一些方案來處理這個問題,但是因為我們並沒有什麼辦法決定使用者客戶端的瀏覽器版本,所以很多相容方法還是需要處理的。
if (cross.scripting && cross.scripting.registerContentScripts) {
logger.info("Register Inject Scripts By Scripting API");
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/registerContentScripts
cross.scripting
.registerContentScripts([
{
matches: [...URL_MATCH],
runAt: "document_start",
world: "MAIN",
allFrames: true,
js: [process.env.INJECT_FILE + ".js"],
id: process.env.INJECT_FILE,
},
])
.catch(err => {
logger.warning("Register Inject Scripts Failed", err);
});
} else {
logger.info("Register Inject Scripts By Tabs API");
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/onUpdated
cross.tabs.onUpdated.addListener((_, changeInfo, tab) => {
if (changeInfo.status == "loading") {
const tabId = tab && tab.id;
const tabURL = tab && tab.url;
if (tabURL && !URL_MATCH.some(match => new RegExp(match).test(tabURL))) {
return void 0;
}
if (tabId && cross.scripting) {
cross.scripting.executeScript({
target: { tabId: tabId, allFrames: true },
files: [process.env.INJECT_FILE + ".js"],
injectImmediately: true,
});
}
}
});
}
在Chrome V109
之後支援了chrome.scripting.registerContentScripts
,Chrome 111
支援了直接在Manifest
中宣告world: 'MAIN'
的指令碼,但是這其中的相容性還是需要開發者來做,特別是如果原來的瀏覽器不支援world: 'MAIN'
,那麼這個指令碼是會被當作Content Script
處理的,關於這點我覺得還是有點難以處理。
unsafeWindow
這個問題也是一個非常有意思的點,關於這個問題我還在群裡提問過但是當時並沒有得到一個答案,那麼在這裡我們就研究一下,首先我們要明確的是在指令碼中是存在兩個window
的,也就是window
以及unsafeWindow
兩個物件,window
物件是一個隔離的安全window
環境,而unsafeWindow
就是使用者頁面中的window
物件。
曾經我很長一段時間都認為這些外掛中可以訪問的window
物件實際上是瀏覽器擴充的Content Scripts
提供的window
物件,而unsafeWindow
是使用者頁面中的window
,以至於我用了比較長的時間在探尋如何直接在瀏覽器擴充中的Content Scripts
直接獲取使用者頁面的window
物件,當然最終還是以失敗告終,這其中比較有意思的是一個逃逸瀏覽器擴充的實現,因為在Content Scripts
與Inject Scripts
是共用DOM
的,所以可以透過DOM
來實現逃逸,當然這個方案早已失效。
var unsafeWindow;
(function() {
var div = document.createElement("div");
div.setAttribute("onclick", "return window");
unsafeWindow = div.onclick();
})();
此外在FireFox
中還提供了一個wrappedJSObject
來幫助我們從Content Scripts
中訪問頁面的的window
物件,但是這個特性也有可能因為不安全在未來的版本中被移除。實際上就拿上邊這個逃逸方法而言,瀏覽器也是正在圍追堵截這種行為,也就是說我們可以明確的是在Content Scripts
中是拿不到瀏覽器頁面的window
物件的。
那麼最終我如何確定這兩個window
物件實際上是同一個瀏覽器環境的window
呢,主要是之前做到了需要動態渲染React
元件的需求,突然又意識到了這個問題,所以除了看開源的指令碼管理器原始碼之外我們也可以透過以下的程式碼來驗證指令碼在瀏覽器的效果,可以看出我們對於window
的修改實際上是會同步到unsafeWindow
上,證明實際上是同一個引用。
unsafeWindow.name = "111111";
console.log(window === unsafeWindow); // false
console.log(window); // Proxy {Symbol(Symbol.toStringTag): 'Window'}
console.log(window.onblur); // null
unsafeWindow.onblur = () => 111;
console.log(unsafeWindow); // Window { ... }
console.log(unsafeWindow.name, window.name); // 111111 111111
console.log(window.onblur); // () => 111
const win = new Function("return this")();
console.log(win === unsafeWindow); // true
實際上在這裡還利用了new Function
的逃逸,所以才能夠發現這倆實際上是同一個引用,那麼問題又來了,既然都是同一個window
物件,指令碼管理器是如何提供的乾淨的window
物件的,在這裡我們就得聊一個小故事了。試想一下如果我們完全信任使用者當前頁面的window
,那麼我們可能會直接將API
掛載到window
物件上,聽起來似乎沒有什麼問題,但是設想這麼一個場景,假如使用者訪問了一個惡意頁面,然後這個網頁又恰好被類似https://*/*
規則匹配到了,那麼這個頁面就可以獲得訪問我們的指令碼管理器的相關API
,這相當於是瀏覽器擴充套件級別的許可權,例如直接獲取使用者磁碟中的檔案內容,並且可以直接將內容跨域傳送到惡意伺服器,這樣的話我們的指令碼管理器就會成為一個安全隱患,再比如當前頁面已經被XSS
攻擊了,攻擊者便可以藉助指令碼管理器GM.cookie.get
來獲取HTTP Only
的Cookie
,並且即使不開啟CORS
也可以輕鬆將請求傳送到服務端。那麼顯然我們本身是準備使用指令碼管理器來Hook
瀏覽器的Web
頁面,此時反而卻被越權訪問了更高階的函式,這顯然是不合理的,所以GreaseMonkey
實現了XPCNativeWrappers
機制,也可以理解為針對於window
物件的沙箱環境。
實際上在@grant none
的情況下,指令碼管理器會認為當前的環境是安全的,同樣也不存在越權訪問的問題了,所以此時訪問的window
就是頁面原本的window
物件。此外,上邊我們也提到了驗證程式碼最後兩行我們突破了這些擴充套件的沙盒限制,從而可以在未@grant unsafeWindow
情況下能夠直接訪問unsafeWindow
,當然這並不是什麼大問題,因為指令碼管理器本身也是提供unsafeWindow
訪問的,而且如果在頁面未啟用unsafe-eval
的CSP
情況下這個例子就失效了。只不過我們也可以想一下其他的方案,是不是直接禁用Function
函式以及eval
的執行就可以了,但是很明顯即使我們直接禁用了Function
物件的訪問,也同樣可以透過建構函式的方式即(function(){}).constructor
來訪問Function
物件,所以針對於window
沙箱環境也是需要不斷進行攻防的,例如小程式不允許使用Function
、eval
、setTimeout
、setInterval
來動態執行程式碼,那麼社群就開始有了手寫直譯器的實現,對於我們這個場景來說,我們甚至可以直接使用iframe
建立一個about:blank
的window
物件作為隔離環境。
那麼我們緊接著可以簡單地討論下如何實現沙箱環境隔離,其實在上邊的例子中也可以看到直接列印window
輸出的是一個Proxy
物件,那麼在這裡我們同樣使用Proxy
來實現簡單的沙箱環境,我們需要實現的是對於window
物件的代理,在這裡我們簡單一些,我們希望的是所有的操作都在新的物件上,不會操作原本的物件,在取值的時候可以做到首先從我們新的物件取,取不到再去window
物件上取,寫值的時候只會在我們新的物件上操作,在這裡我們還用到了with
運算子,主要是為了將程式碼的作用域設定到一個特定的物件中,在這裡就是我們建立的的context
,在最終結果中我們可以看到我們對於window
物件的讀操作是正確的,並且寫操作都只作用在沙箱環境中。
const context = Object.create(null);
const global = window;
const proxy = new Proxy(context, {
// `Proxy`使用`in`運算子號判斷是否存在屬性
has: () => true,
// 寫入屬性作用到`context`上
set: (target, prop, value) => {
target[prop] = value;
return true;
},
// 特判特殊屬性與方法 讀取屬性依次讀`context`、`window`
get: (target, prop) => {
switch (prop) {
// 重寫特殊屬性指向
case "globalThis":
case "window":
case "parent":
case "self":
return proxy;
default:
if (prop in target) {
return target[prop];
}
const value = global[prop];
// `alert`、`setTimeout`等方法作用域必須在`window`下
if (typeof value === "function" && !value.prototype) {
return value.bind(global);
}
return value;
}
},
});
window.name = "111";
with (proxy) {
console.log(window.name); // 111
window.name = "222";
console.log(name); // 222
console.log(window.name); // 222
}
console.log(window.name); // 111
console.log(context); // { name: '222' }
此外即使我們完成了沙箱環境的構建,但是如何將這個物件傳遞給使用者指令碼,我們不能將這些變數暴露給網站本身,但是又需要將相關的變數傳遞給指令碼,而指令碼本身就是執行在使用者頁面上的,否則我們沒有辦法訪問使用者頁面的window
物件,所以接下來我們就來討論如何保證我們的高階方法安全地傳遞到使用者指令碼的問題。實際上在上邊的source-map
我們也可以明顯地看出來,我們可以直接藉助閉包以及with
訪問變數即可,並且在這裡還需要注意this
的問題,所以在呼叫該函式的時候透過如下方式呼叫即可將當前作用域的變數作為傳遞給指令碼執行。
script.apply(proxyContent, [ proxyContent, GM_info ]);
那麼現在到目前為止我們使用Proxy
實現了window
物件隔離的沙箱環境,總結起來我們的目標是實現一個乾淨的window
沙箱環境,也就是說我們希望網站本身執行的任何不會影響到我們的window
物件,比如網站本體在window
上掛載了$$
物件,我們本身不希望其能直接在開發者的指令碼中訪問到這個物件,我們的沙箱環境是完全隔離的,而使用者指令碼管理器的目標則是不同的,比如使用者需要在window
上掛載事件,那麼我們就應該將這個事件處理函式掛載到原本的window
物件上,那麼我們就需要區分讀或者寫的屬性是原本window
上的還是Web
頁面新寫入的屬性,顯然如果想解決這個問題就要在使用者指令碼執行之前將原本window
物件上的key
記錄副本,相當於以白名單的形式操作沙箱。同樣的相輔相成的,如果想要做到window
沙箱那麼就必須保證擴充套件的Inject Script
是最先執行的也就是document-start
,否則就很難保證使用者原本是不是在window
物件上掛載了內容,導致汙染了沙箱環境。
xmlHttpRequest
接著我們來聊最後一個問題,指令碼管理器是如何做到的可以跨域請求,實際上因為在前邊我們明確了使用者指令碼是在瀏覽器當前的頁面執行的,那麼理所當然的就會存在同源策略的問題,然後在指令碼管理器中只要宣告瞭連結的域名,就可以逃脫這個限制,這又是一件很神奇的事情。
那麼解決這個問題的方式也比較簡單,很明顯在這裡發起的通訊並不是直接從頁面的window
發起的,而是從瀏覽器擴充套件發出去的,所以在這裡我們就需要討論如何做到在使用者頁面與瀏覽器擴充套件之間進行通訊的問題。在Content Script
中的DOM
和事件流是與Inject Script
共享的,那麼實際上我們就可以有兩種方式實現通訊,首先我們常用的方法是window.addEventListener + window.postMessage
,只不過這種方式很明顯的一個問題是在Web
頁面中也可以收到我們的訊息,即使我們可以生成一些隨機的token
來驗證訊息的來源,但是這個方式畢竟能夠非常簡單地被頁面本身截獲不夠安全,所以在這裡通常是用的另一種方式,即document.addEventListener + document.dispatchEvent + CustomEvent
自定義事件的方式,在這裡我們需要注意的是事件名要隨機,透過在注入框架時於background
生成唯一的隨機事件名,之後在Content Script
與Inject Script
都使用該事件名通訊,就可以防止使用者截獲方法呼叫時產生的訊息了。
// Content Script
document.addEventListener("xxxxxxxxxxxxx" + "content", e => {
console.log("From Inject Script", e.detail);
});
// Inject Script
document.addEventListener("xxxxxxxxxxxxx" + "inject", e => {
console.log("From Content Script", e.detail);
});
// Inject Script
document.dispatchEvent(
new CustomEvent("xxxxxxxxxxxxx" + "content", {
detail: { message: "call api" },
}),
);
// Content Script
document.dispatchEvent(
new CustomEvent("xxxxxxxxxxxxx" + "inject", {
detail: { message: "return value" },
}),
);
只不過因為這邊涉及到的通訊會比較多,透過Content Script
中轉來將訊息傳送到Background/Worker
來最終實現的請求,實際控制起來還是有些麻煩的,需要比較好的設計來處理各個模組的通訊與事件觸發。
總結
最終在這裡我們可能已經明確了瀏覽器擴充套件的一些非常Hack
能力的實現,同時可能也會發現瀏覽器擴充套件的許可權是真的非常高,在V2
版本中甚至連HTTP Only
的Cookie
都可以拿到,在V3
中限制就多了起來,但是整體的許可權還是非常高的,所以在選擇瀏覽器擴充套件時還是需要謹慎,要不就選擇使用者比較多的,要不就選擇開源的。不過之前類似EDGE
擴充套件的事件還是不容易避免,簡單來說就是EDGE
開放了擴充套件,然後有些不懷好意的人將開源的擴充套件封裝了廣告程式碼然後提交到擴充套件市場,最終使用擴充的我們還是要關注一下這些問題的,最後提供一些指令碼管理器可以參考學習。
- GreaseMonkey: 俗稱油猴,最早的使用者指令碼管理器,為
Firefox
提供擴充套件能力,採用MIT license
協議。 - TamperMonkey: 俗稱篡改猴,最受歡迎的使用者指令碼管理器,能夠為當前主流瀏覽器提供擴充套件能力,開源版本採用
GPL-3.0 license
協議。 - ViolentMonkey: 俗稱暴力猴,完全開源的使用者指令碼管理器,同樣能夠為當前主流瀏覽器提供擴充套件能力,採用
MIT license
協議。 - ScriptCat: 俗稱指令碼貓,完全開源的使用者指令碼管理器,同樣能夠為當前主流瀏覽器提供擴充套件能力,採用
GPL-3.0 license
協議。