從油猴腳本管理器的角度審視Chrome擴展

從油猴腳本管理器的角度審視Chrome擴展

在之前一段時間,我需要藉助Chrome擴展來完成一個需求,當時還在使用油猴腳本與瀏覽器擴展之間調研了一波,而此時恰好我又有一些做的還可以的油猴腳本 TKScript (點個star吧 😁),相對會比較熟悉腳本管理器的能力,預估是不太能完成需求的,所以趁着這個機會,我又學習了一波瀏覽器擴展的能力。那麼在後來需求的開發過程中,因爲有些能力是類似於腳本管理器提供的基礎環境,致使我越來越好奇腳本管理器是怎麼實現的,而實際上腳本管理器實際上還是一個瀏覽器擴展,瀏覽器也並沒有給腳本管理器開後門來實現相關能力,而讓我疑惑的三個問題是:

  1. 腳本管理器爲什麼能夠先於頁面的JS運行。
  2. 腳本管理器是如何能夠得到頁面window對象。
  3. 腳本管理器爲什麼能夠無視瀏覽器的同源策略從而發起跨域的請求。

因此,之後調研了一波瀏覽器擴展能力的開發之後,總結了腳本管理器的核心能力實現,同樣也是解答了讓我疑惑的這三個問題。

從零開始瀏覽器擴展的開發

Chrome擴展是一種可以在Chrome瀏覽器中添加新功能和修改瀏覽器行爲的軟件程序,例如我們常用的TamperMonkeyProxy SwitchyOmegaAdGuard等等,這些拓展都是可以通過WebExtensions API來修改、增強瀏覽器的能力,用來提供一些瀏覽器本體沒有的功能,從而實現一些有趣的事情。

實際上FireFox是才第一個引入瀏覽器擴展/附加組件的主流瀏覽器,其在2004年發佈了第一個版本的擴展系統,允許開發人員爲FireFox編寫自定義功能和修改瀏覽器行爲的軟件程序。而Chrome瀏覽器則在2010年支持了擴展系統,同樣其也允許開發人員爲Chrome編寫自定義功能和修改瀏覽器行爲的軟件程序。

雖然FireFox是第一個引入瀏覽器擴展的瀏覽器,但是Chrome的擴展系統得到了廣泛的認可和使用,也已經成爲了現代瀏覽器中最流行的擴展系統之一。目前用於構建FireFox擴展的技術在很大程度上與被基於Chromium內核的瀏覽器所支持的擴展API所兼容,例如ChromeEdgeOpera等。在大多數情況下,爲基於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支持的APIv3被限制或移除,導致諸多插件無法無損過渡到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的相關生態來完成插件的開發,當前實際上是有很多比較成熟的擴展框架的,其中也集合了相當一部分的能力,只不過我們在這裏希望從零開始跑通一整套流程,那麼我們就自行藉助打包工具來完成產物的構建。在這裏選用的是RspackRspack是一個於Rust的高性能構建引擎,具備與Webpack生態系統的互操作性,可以被Webpack項目低成本集成,並提供更好的構建性能。選用Rspack的主要原因是其編譯速度會快一些,特別是在複雜項目中Webpack特別是CRA創建的項目打包速度簡直慘不忍睹,我這邊有個項目改造前後的dev速度對比大概是1min35s : 24s,速度提升還是比較明顯的,當然在我們這個簡單的Chrome擴展場景下實際上是區別不大。

那麼現在我們先從manifest.json開始,目標是在右上角實現一個彈窗,當前很多擴展程序也都是基於右上角的小彈窗交互來控制相關能力的。首先我們需要在manifest.json配置actionaction的配置就是控制單擊瀏覽器工具欄按鈕時的行爲,因爲實際上是web生態,所以我們應該爲其配置一個html文件以及icon

"action": {
  "default_popup": "popup.html",
  "default_icon": "./static/favicon.png"
}

已經有了配置文件,現在我們就需要將HTML生成出來,在這裏就需要藉助rspack來實現了,實際上跟webpack差不多,整體思路就是先配置一個HTML模版,然後從入口開始打包Js,最後將Js注入到HTML當中就可以了,在這裏我們直接配置一個多入口的輸出能力,通常一個擴展插件不會是隻有一個JsHTML文件的,所以我們需要配置一個多入口的能力。在這裏我們還打包了兩個文件,一個是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,並且配置了scssless的相關樣式處理。首先是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也是不錯的選擇,因爲這樣我們就可以通過各種邏輯來動態地將配置文件寫入了,比如拿來適配ChromiumGecko內核的瀏覽器。

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 WorkerChrome會每5分鐘清理所有擴展Service Workers,也就是說擴展的Worker最多存活5分鐘,然後等待用戶下次激活,但是激活方式沒有明確的表述,那假如我們的拓展要做的工作沒做完,要接上次的工作怎麼辦,Google答覆是用chrome.storage類似存儲來暫存工作任務,等待下次激活。爲了對抗隨機的清理事件,出現了很多骯髒的手段,甚至有的爲了保持持續後臺,做兩個擴展然後相互喚醒。除了這方面還有一些類似於webRequest -> declarativeNetRequestsetTimeout/setIntervalDOM解析、window/document等等的限制,會影響大部分的插件能力。

當然如果我們想在用戶主觀運行時實現相關能力的常駐,就可以直接chrome.tabs.create在瀏覽器Tab中打開擴展程序的HTML頁面,這樣就可以作爲前臺運行,同樣這個擴展程序的代碼就會一直運行着。

Chrome官方博客發佈了一個聲明More details on the transition to Manifest V3,將Manifest V2的廢除時間從20231月向後推遲了一年:

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/workerpopupcontentinjectdevtools等,不同的模塊對應着不同的作用,協作構成了插件的擴展功能。

  • background/worker: 這個模塊負責在後臺運行擴展,可以實現一些需要長期運行的操作,例如與服務器通信、定時任務等。
  • popup: 這個模塊是擴展的彈出層界面,可以通過點擊擴展圖標在瀏覽器中彈出,用於顯示擴展的一些信息或操作界面。
  • content: 這個模塊可以訪問當前頁面的DOM結構和樣式,可以實現一些與頁面交互的操作,但該模塊的window與頁面的window是隔離的。
  • inject: 這個模塊可以向當前頁面注入自定義的JavaScriptCSS代碼,可以實現一些與頁面交互的操作,例如修改頁面行爲、添加樣式等。
  • 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 APIDOM訪問、跨域訪問、頁面Window對象訪問等。

模塊 Chrome API DOM訪問 跨域訪問 頁面Window對象訪問
background/worker 絕大部分API,除了devtools系列 不可直接訪問頁面DOM 可跨域訪問 不可直接訪問頁面Window
popup 絕大部分API,除了devtools系列 能直接訪問自身的DOM 可跨域訪問 能直接訪問自身的Window
content 有限制,只能訪問runtimeextension等部分API 可以訪問頁面DOM 不可跨域訪問 不可直接訪問頁面Window
inject 不能訪問Chrome API 可以訪問頁面DOM 不可跨域訪問 可直接訪問頁面Window
devtools 有限制,只能訪問devtoolsruntimeextension等部分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標籤級別插入就好了,配合瀏覽器擴展中backgroundchrome.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 Scriptwindow對象上掛了一個隨機生成的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.registerContentScriptsChrome 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 ScriptsInject 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 OnlyCookie,並且即使不開啓CORS也可以輕鬆將請求發送到服務端。那麼顯然我們本身是準備使用腳本管理器來Hook瀏覽器的Web頁面,此時反而卻被越權訪問了更高級的函數,這顯然是不合理的,所以GreaseMonkey實現了XPCNativeWrappers機制,也可以理解爲針對於window對象的沙箱環境。

實際上在@grant none的情況下,腳本管理器會認爲當前的環境是安全的,同樣也不存在越權訪問的問題了,所以此時訪問的window就是頁面原本的window對象。此外,上邊我們也提到了驗證代碼最後兩行我們突破了這些擴展的沙盒限制,從而可以在未@grant unsafeWindow情況下能夠直接訪問unsafeWindow,當然這並不是什麼大問題,因爲腳本管理器本身也是提供unsafeWindow訪問的,而且如果在頁面未啓用unsafe-evalCSP情況下這個例子就失效了。只不過我們也可以想一下其他的方案,是不是直接禁用Function函數以及eval的執行就可以了,但是很明顯即使我們直接禁用了Function對象的訪問,也同樣可以通過構造函數的方式即(function(){}).constructor來訪問Function對象,所以針對於window沙箱環境也是需要不斷進行攻防的,例如小程序不允許使用FunctionevalsetTimeoutsetInterval來動態執行代碼,那麼社區就開始有了手寫解釋器的實現,對於我們這個場景來說,我們甚至可以直接使用iframe創建一個about:blankwindow對象作爲隔離環境。

那麼我們緊接着可以簡單地討論下如何實現沙箱環境隔離,其實在上邊的例子中也可以看到直接打印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 ScriptInject 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 OnlyCookie都可以拿到,在V3中限制就多了起來,但是整體的權限還是非常高的,所以在選擇瀏覽器擴展時還是需要謹慎,要不就選擇用戶比較多的,要不就選擇開源的。不過之前類似EDGE擴展的事件還是不容易避免,簡單來說就是EDGE開放了擴展,然後有些不懷好意的人將開源的擴展封裝了廣告代碼然後提交到擴展市場,最終使用拓展的我們還是要關注一下這些問題的,最後提供一些腳本管理器可以參考學習。

  • GreaseMonkey: 俗稱油猴,最早的用戶腳本管理器,爲Firefox提供擴展能力,採用MIT license協議。
  • TamperMonkey: 俗稱篡改猴,最受歡迎的用戶腳本管理器,能夠爲當前主流瀏覽器提供擴展能力,開源版本採用GPL-3.0 license協議。
  • ViolentMonkey: 俗稱暴力猴,完全開源的用戶腳本管理器,同樣能夠爲當前主流瀏覽器提供擴展能力,採用MIT license協議。
  • ScriptCat: 俗稱腳本貓,完全開源的用戶腳本管理器,同樣能夠爲當前主流瀏覽器提供擴展能力,採用 GPL-3.0 license協議。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章