Web 終極攔截技巧(全是騷操作)

ITPUB社群發表於2024-02-07

來源:嗶哩嗶哩技術


攔截的價值


電腦科學領域的任何問題都可以透過增加一箇中間層來解決。—— Butler Lampson

如果系統的控制權、程式碼完全被掌控,很容易新增中間層;
現實情況我們往往無法控制系統的所有細節,所以需要使用一些 “非常規”(攔截) 手段來增加中間層

常見的場景有

  • 自動上報未捕獲的錯誤,進行錯誤監控

  • 攔截網路請求(fetch、xhr)進行介面效能統計、統一錯誤碼處理、遠端 debug 介面

  • 構造執行第三方程式碼、微應用必須的沙盒環境



攔截方法


攔截/覆寫瀏覽器 API


最常見的場景有透過攔截 console 實現錯誤上報。











const _error = console.error;console.error = (...args) => {  _error.apply(console, args);   console.info('在此處上報錯誤資訊...');}; // 其它程式碼列印錯誤console.error('error message');


專案中通常會基於 axios 此類的網路庫,做一些統一處理邏輯

但在某些場景,我們無法修改專案程式碼,就能透過攔截 fetch, xhr 來達到目的。












// 介面效能監控,開啟 , 在控制檯執行以下程式碼const _fetch = window.fetch;window.fetch = (...args) => {  const startTime = performance.now();  return _fetch(...args).finally(() => {    console.info('介面耗時:', Math.round(performance.now() - startTime), 'ms');  });}; await fetch('//example.com');


你可以選擇第三方庫(比如 xhook:)來快速實現 fetch, xhr 攔截功能。

瀏覽器中大多數 API 都是可以覆寫的,開啟腦洞,可以實現非常多的神奇功能:

  • 網路 API (xhr, fetch, WebSocket)

  • 效能監控、統一錯誤碼處理

  • 新增額外 HTTP 引數(header, query)實現介面染色功能

  • 修改 Host 將介面自動轉向代理服務,實現遠端除錯介面、Mock 資料

  • 修改原型 (Array.prototype.at = ...)

  • polyfill 庫的必備手段

  • 頁面跳轉 API (window.open, history.go back pushState)

  • 修改跳轉的目的頁面

  • 自動新增頁面跳轉埋點

  • 刪除特定 API 禁用瀏覽器功能

  • 禁止 js 訪問攝像頭 navigator.mediaDevices.getDisplayMedia = null

  • 禁止 p2p 連線 window.RTCPeerConnection = null


事件、DOM 元素


瀏覽器也會提供一些具備攔截性質的 API,允許開發者實現特定功能。

一個 DOM 元素經常會繫結許多事件,如果你想讓特定的事件回撥函式先執行,以便新增一些前置邏輯或取消後續事件的執行;

可以瞭解 addEventListener#usecapture (opens new window)https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#usecapture)的用法。











// 禁止響應頁面的所有點選事件(危險⚠️),第三個引數(usecapture)設為 truedocument.body.addEventListener(  'click',  (evt) => {    evt.preventDefault();    evt.stopPropagation();  },  true);


許多 DOM 元素都是在執行時動態建立的,如果需要修改動態建立的 DOM 元素可使用 MutationObserver(opens new window)https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver

比如,攔截所有超連結(a 標籤),給目標連結新增 _source 引數


















const observer = new MutationObserver((mutationsList) => {  for (const mutation of mutationsList) {    if (mutation.type !== 'childList' || !mutation.addedNodes) return;    mutation.addedNodes.forEach((item) => {      if (!item.nodeName === 'A') return;      const targetUrl = new URL(item.href, location.href);      targetUrl.searchParams.append('_source', 'any string');      item.href = targetUrl.href;    });  }});observer.observe(document.body, {  attributes: true,  childList: true,  subtree: true,});


TIP:MutationObserver 同樣適應於修改 iframe, img 的連結,或其它任意 DOM 元素的屬性


除錯小技巧


如果你的頁面因未知程式碼陷入了快速重新整理的死迴圈,可在專案中新增以下以下程式碼;

頁面重新整理前會進入 debug 狀態,在 devtools 中檢視呼叫堆疊(call stack)即可瞭解重新整理的原因





window.addEventListener('beforeunload', () => {  debugger;});


TIP:http 302 屬於非程式碼導致頁面跳轉,上述程式碼無法攔截

當除錯第三方程式碼時,需要監聽某個不符合期望的物件屬性值

















// debug 狀態下任意可訪問物件const obj = { prop: 1 }; // 在 devtools -> console 中執行以下程式碼_obj_prop = obj.prop;Object.defineProperty(obj, 'prop', {  set: (v) => {    _obj_prop = v;    // 每次賦值都會進入 debug 狀態    debugger;  },  get: () => _obj_prop,});// 試試執行 obj.prop = 2// 後續可在 console 中隨時訪問 _obj_prop 的當前值


如果需要監聽某個物件所有屬性值被讀寫的訊息,可以使用 Proxy















const obj = { prop: 1 }; const obj2 = new Proxy(obj, {  get(target, key, receiver) {    return Reflect.get(target, key, receiver);  },  set(target, key, value, receiver) {    debugger;    return Reflect.set(target, key, value, receiver);  },}); // 試試執行 obj2.abc = 2


注意差異

  • Object.defineProperty 沒有改變 obj 的引用,Proxy 生成了新物件 obj2

  • 使用 Proxy 可以監聽物件(obj2)所有屬性的讀寫,而 Object.defineProperty 一次只能監聽一個屬性(prop)


ServiceWorker 攔截


前端可能會使用 ServiceWorker 來實現離線可用、快取資源、加速頁面訪問等功能。


















// 安裝時快取資源self.addEventListener('install', (event) => {  event.waitUntil(    caches      .open('v1')      .then((cache) => cache.addAll(['/index.html', '/style.css', '/app.js']))  );});// 攔截頁面資源請求,使用快取響應(也可使用自定義內容響應請求)self.addEventListener('fetch', (event) => {  event.respondWith(    caches.match(event.request).then((response) => {      return response;    })  );});


ServiceWorker 是前端頁面到伺服器之間的中間層,它能攔截同域名下的所有請求,快取或篡改請求結果,能實現的功能遠不止離線或加速訪問這麼簡單。

後文會從 WebContainer 原理分析 ServiceWorker 的高階玩法。


伺服器攔截


前面介紹的技巧都侷限在客戶端(瀏覽器)中,如果你掌握了真正的服務控制權,即配置伺服器和內網 DNS 域名等許可權(一般由公司內的工程效率團隊或運維負責),再配合前面介紹的瀏覽器攔截技巧,可玩性將大大增加。

一個 HTTP 請求往往會經過多個伺服器節點,每個節點就是一箇中間層


Web 終極攔截技巧(全是騷操作)


1.DNS 決定 HTTP 請求由哪個閘道器(Nginx)處理

a.如果你能控制 DNS 服務,則可以在閘道器之前再加一箇中間層

2.伺服器節點(閘道器、Service)節點能獲取、篡改 HTTP 請求中的所有資訊:Header、Cookie、Body

a.根據 HTTP 資訊,可將請求轉發到本地目錄(靜態資源),或轉發到其他遠端服務
b.新增 Cookie 追蹤使用者,實現灰度、AB 實驗分流等功能
c.實現業務層無感知注入程式碼
d.動態篡改資料,實現 Mock 功能


如何注入程式碼


前文介紹的客戶端攔截技巧都需要在瀏覽器中注入程式碼,以下列舉注入程式碼的方式,根據目的和能獲取的許可權決定採用哪種方式。

1.原始碼注入

  • 如果你有原始碼控制權,那你可以對專案做任何事情,確保攔截程式碼優先執行即可

  • 優點:靈活可控;缺點:通用性不好,侵入業務

2.構建、推送服務注入

  • 工程團隊提供構建、推送服務,可編寫指令碼在構建產物中注入程式碼(比如 html 中新增 script 標籤)

  • 優點:業務無感知,通用性好;缺點:不一定有許可權

3.閘道器注入

  • Nginx 向 html 中注入 script 也很簡單

  • 優點同上,一般限於在開發、測試環境,不會上生產環境

4.瀏覽器外掛、devtools 注入

  • 如果你啥許可權都沒有(普通使用者),又想幹一些“壞事”,則可選擇使用外掛注入程式碼,或臨時在 devtools 的 console 皮膚直接寫程式碼

  • 優點:萬能注入、無法阻擋;缺點:只能影響當前瀏覽器,難以跨瀏覽器、裝置、使用者

  • 常用的指令碼管理外掛:篡改猴 (opens new window)),也可考慮自己寫瀏覽外掛

儘量讓被注入的程式碼早於業務程式碼執行,比如實現攔截並上報錯誤資訊,如果被注入的程式碼執行時機較晚,則會丟失執行前的錯誤資訊。


案例分析


WebContainer 原理


WebContainer (opens new window)是一種基於瀏覽器的執行時,可完全在瀏覽器標籤頁內執行 Node.js 應用程式和作業系統命令。

驚豔的地方有兩點:

  1. 能在瀏覽器中執行 Node 服務,居然還能啟動 DevServer “監聽埠”

  2. 離線後 IDE 開發中的頁面也能正常開發

以  (opens new window)為例


Web 終極攔截技巧(全是騷操作)


核心部分是將 Node 編譯成 WASM,然後 Mock 檔案系統、底層網路模組 使其能在瀏覽器中執行。

然後使用 Node 啟動 devServer 服務,監聽埠。

  • dev 頁面發起的請求被 ServerWorker 攔截對映成本地 Mock 的檔案

  • 本地 Mock 的檔案由 IDE 原始碼使用 Node 編譯而成

  • 原始碼的依賴包被對映成 http 請求從遠端獲取

比如安裝依賴 react-dom 對應的網路請求是:
(opens new window)

在瀏覽器中執行 DevServer 時,當然無法監聽 TCP 埠。

巧妙的地方是將埠對映成唯一的域名,透過 ServiceWorker 攔截域名下的所有請求,關聯到 Mock 的檔案系統。

詳情請看 WebContainer 原理分析(opens new window)


沙盒


沙盒(sandbox,又譯為沙箱)是一種安全機制,為執行中的程式提供隔離環境。通常是作為一些來源不可信、具破壞力或無法判定程式意圖的程式提供實驗之用。

假設你需要執行一段第三方程式碼,安全要求禁止訪問 document,以及 window 下的 open、location。

嘗試複製以下程式碼在 console 中執行

















function safeExec(code) {  const proxyWindow = new Proxy(window, {    get(target, key, receiver) {      if (['open', 'location', 'document'].includes(key))        throw new Error(`禁止訪問 key: ${key}`);      if (key === 'window') return proxyWindow;       return Reflect.get(target, key, receiver);    },    // set() {}  });  new Function('window', `with(window) { ${code} }`)(proxyWindow, null);}// Error 禁止訪問 key: opensafeExec(`window.open('//danger.com')`);


沙盒也是前端微應用的核心技術,透過 Proxy 監聽 window 的讀寫記錄,可以隔離多個微應用的執行環境,解除安裝某個微應用後也能將環境(window 物件)重置回該應用載入前的狀態。


通用域名服務


一般 Web 開發者使用會使用 localhost:8080、192.168.0.1:8080 這個訪問開發環境;

如果使用真實域名來轉發請求到開發環境(https://ff-dev.bilibili.com?_ip_=192.168.0.1&_port_=8080),能解決一些常見的問題:

  • 輕易實現共享域名(bilibili.com)的登入態 cookie

  • 無需配置 Web 服務的 https 證書

  • 僅限於安全上下文的特性(https://developer.mozilla.org/zh-CN/docs/Web/Security/Secure_Contexts/features_restricted_to_secure_contexts

  • 在手機上使用 https 協議訪問開發服務,避免 http 協議導致許多 Web API 不可用

  • 該服務是一個天然的中間層,可無感注入程式碼實現效率工具,比如:

  • 遠端網路抓包、Mock

  • 移動端控制檯(eruda)

  • 遠端程式碼除錯(chii)

  • 切換後端介面環境、介面染色


Web 終極攔截技巧(全是騷操作)


真實域名轉發請求,能解決部分場景的問題,也會帶來一些新問題需要解決:

  • 靜態資源(/index.js)不帶引數,無法轉發到目的地,無法初始化頁面

  • 頁面跳轉後,域名或引數(_ip_, _port_)會丟失

  • HMR 的 WebSocket(192.168.0.1:4000/socket.io) 連線失敗,無法自動熱更新

解決這問題確實有一些難度,好在前文已經介紹了對應的攔截技巧;

一旦解決問題部署通用域名服務後,就能給許多人提供便捷服務。


實現原理


Web 終極攔截技巧(全是騷操作)


1.載入 HTML

  • 部署一個 Nginx 服務,併為其註冊域名(ff-dev.bilibili.com

  • 在 Nginx 中讀取 http 請求的 query 引數(_ip_, _port_),將請求轉發到對應目標

  • 目標響應後,將引數也寫入到 Response 的 cookie 中

  • 如果是 html 請求,則在 body 中注入 js

  • <body>...<body> 替換為<body><script> src="ff-dev-sdk.js"/>...</body>

2.載入靜態資源

靜態資源(/index.js)不帶引數,Nginx 改成從 cookie 中讀取引數,然後將請求轉發到目的地

3.解決頁面跳轉引數丟失

  • ff-dev-sdk.js 攔截所有 a 標籤、重寫 open 方法,在頁面跳轉的 url 新增引數(_ip_, _port_);

  • 頁面跳轉後,新頁面也能轉發到正確的目的地

4.解決 HMR 無法熱更新

ff-dev-sdk.js 覆寫 WebSocket 的實現,將連結 192.168.0.1:4000/socket.io 替換為 ff-dev.bilibili.com/socket.io?_ip_=192.168.0.1&_port_=4000 繼續由 Nginx 轉發

以上步驟使用的技巧在前文都有介紹

  • 注入 js 程式碼到 html 中,寫入引數(_ip_, _port_)到 cookie 中

  • 攔截 a 標籤

  • 覆寫 API:open,WebSocket


總結


應用中間層思路的經驗


  • “新增一箇中間層”是一種有效且通用的解決問題的思路

  • 根據需要解決的問題,思考中間層的位置、以及一個能注入程式碼時機,並讓程式碼儘早執行

  • 原始碼注入指令碼

  • 構建、推送服務注入

  • 閘道器注入

  • 瀏覽器外掛、devtools 注入

  • Web 技術棧擁有非常大的可操作空間

  • 利用 js 的動態特性,覆寫系統 API 實現攔截

  • 靈活使用具有攔截性質的 API

  • ServiceWorker

  • 瞭解 HTTP 請求的構成(Header、Cookie、Body),以及它流轉的節點

  • 多種攔截技巧靈活搭配,釋放更強大的力量

  • 能力越強(攔截範圍越大)、責任越大,注意安全


 安全邊界


從前文看由於 js 靈活特性,甚至看起來有點不安全(覆寫系統 API),但恰恰相反,Web 平臺常與“安全”一起出現。

js 的動態靈活是執行在 Web 安全邊界(同源策略:https://developer.mozilla.org/zh-CN/docs/Web/Security/Same-origin_policy、內容安全策略:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CSP )構建的“大沙盒”內的。

新手開發者常碰到的跨域問題,就是因為碰到了安全邊界,安全邊界是 Web 平臺能力受限(相對 Native)的重要原因之一。

Web 平臺中的所有技巧策略都必須符合安全規則;

所以,建議大家在學習攔截技巧的同時,瞭解同源策略、內容安全策略(CSP) 等安全知識。


太騷了,接受不了?


從《通用域名服務》章節的原理來看,似乎有點過於“騷操作”了;

從效率角度出發,不要在乎騷不騷、髒不髒,有價值就幹。

解決“規範與效率”之間的衝突:隔離髒程式碼,不拉低系統整體程式碼質量。


附錄


  • addEventListener#usecapture:https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#usecapture

  • MutationObserver:https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver

  • ServiceWorker:https://developer.mozilla.org/zh-CN/docs/Web/API/ServiceWorker

  • WebContainers:

  • WebContainer 原理分析:

  • 瀏覽器的同源策略:https://developer.mozilla.org/zh-CN/docs/Web/Security/Same-origin_policy

  • 內容安全策略(CSP):https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CSP

來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/70024420/viewspace-3006582/,如需轉載,請註明出處,否則將追究法律責任。

相關文章