通過覆蓋原型屬性攔截 XMLHttpRequest 響應
在JavaScript中有兩種發起HTTP請求的API - 現代的fetch()和傳統的XMLHttpRequest。它們功能完全相同,只是語法不同。XMLHttpRequest使用回調處理響應,而fetch()返回更方便使用的Promise。
XMLHttpRequest是發起HTTP請求的主流API。在新項目中使用傳統的XMLHttpRequest是沒有意義的。
另一方面,將現有可運行的基于XMLHttpRequest的代碼升級到fetch()并不會帶來顯著好處。那些經過多年開發、擁有大量代碼庫的成功網站,沒有理由在代碼中用fetch()替換XMLHttpRequest。將他們可運行的代碼升級到fetch()只會帶來bug和風險。
我檢查了我所知道的一些流行網站的網絡活動。google、youtube、gmail、bing、linkedin、tiktok、instagram、facebook主要依賴XMLHttpRequest,也使用一些fetch()。reddit、quora則不使用XMLHttpRequest。
為什么要重寫XMLHttpRequest中的response
首先,在前端開發和調試過程中,在網頁接收到HTTP響應之前修改響應是一個有用的技術。通過重寫XMLHttpRequest,可以在不改變后端的情況下記錄、偽造或調整響應體。
這種技術也可用于網頁爬蟲,并且使一些瀏覽器擴展的功能得以實現。
但是如果網頁需要多次重新加載,比如在開發或調試期間,最好不要在控制臺執行修改響應數據的腳本,而是作為瀏覽器擴展的content script自動執行。當腳本作為content script注入時,可以方便地由小型可重用模塊組成。
攔截HTTP響應數據的示例
如上所述,許多流行網站都使用XMLHttpRequest發起HTTP請求。在這個實驗中我使用知名且信譽良好的Facebook。。Facebook的初始HTML是在服務器端渲染的,所以不能通過修改XMLHttpRequest響應來修改它。但是之后逐漸加載的內容可以在被網頁訪問之前進行修改:
- words mushroom、fungus或fungi被替換為字符串 ??????REPLACED??????
- jpg圖片的URL被替換為一個蘑菇圖片的URL
HTTP響應中的文本修改是通過以下函數完成的:
var RE = /"[^"]+\.jpg?[^"]+"/gi;
var REPLACEMENT = '"https://scontent-zrh1-1.xx.fbcdn.net/v/t39.30808-6/272917062_10157959892971991_7437132751388296237_n.jpg?_nc_cat=100&ccb=1-7&_nc_sid=127cfc&_nc_ohc=g7Qun1RfEvgQ7kNvgFVEOv6&_nc_ht=scontent-zrh1-1.xx&_nc_gid=AmiHBSQhbkAppb0buDWHP2N&oh=00_AYAYpDPV90lNRXvX2-bftFkUPHcqQJYVBmsE8BZnyNvqmg&oe=66EB3BAE"'
.replaceAll('/', '\\/');
var RE2 = /mushroom|fungus|fungi/gi;
var REPLACEMENT2 = '??????REPLACED??????';
function modifyTextResponse(val) {
if (typeof val === 'string')
return val.replaceAll(RE, REPLACEMENT).replaceAll(RE2, REPLACEMENT2);
return val;
}
下面的示例腳本使用了這個modifyTextResponse(val)函數。
這個蘑菇圖片很好看但URL很長且難看。Facebook頁面的內容安全策略(CSP)阻止從其他來源加載圖片。我本可以使用反CSP瀏覽器擴展來放寬CSP,但為了簡單起見,我遵守了CSP并使用了Facebook托管的圖片。
在視頻中,腳本在頁面加載時自動作為content script注入。
{
"name": "XMLHttpRequest",
"version": "1.0",
"manifest_version": 3,
"description": "XMLHttpRequest",
"permissions": [
"scripting"
],
"action": {},
"icons": {
"128": "icon.png"
},
"content_scripts": [
{
"matches": [
"https://*.facebook.com/*"
],
"run_at": "document_start",
"js": [
"main.js"
],
"world":"MAIN"
}
]
}
腳本必須注入到頁面上下文中,即MAIN world。重寫原生JavaScript方法是MAIN world的主要用例,否則這個world沒有擴展API,用處不大。
訪問XMLHttpRequest響應數據的唯一方式
XMLHttpRequest中有幾個提供訪問響應數據的屬性:
- response
- responseText
- responseXML
這些屬性都是getter函數。要重寫任何類型的響應,只需要重寫response getter就夠了。responseText和responseXML似乎只是通過轉換response的值來工作。
但是需要了解什么時候進行重寫。有兩個合理的選擇:
- readystatechange事件監聽器
- open()方法
XMLHttpRequest的所有可能事件
我們看看HTTP請求期間發生的所有事件。
<script src="api.js" type="module"></script>
<button type="button" id="btn">Send</button>
腳本為所有以on開頭的XMLHttpRequest屬性添加監聽器:
// api.js
const url = "https://data.cdc.gov/api/views/95ax-ymtc/rows.json";
function onEvent(e) {
console.log(e.type.padEnd(16, ' '), this.readyState, this.response.length, e.loaded);
}
function request() {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.send();
for (let k in xhr)
if (k.startsWith('on'))
xhr[k] = onEvent;
}
btn.addEventListener("click", request);
這個腳本請求一些公開可用的數據。
你可以看到,readystatechange監聽器可能被多次調用,甚至可以訪問未完全加載的數據。某些網站可能不會等待readyState===4就立即使用不完整的數據。
下面的代碼通過在XMLHttpRequest對象中創建新的response屬性來重寫prototype中的response getter:
if (!oldXHROpen)
var oldXHROpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function () {
let oldOnreadystatechange = this.onreadystatechange;
this.onreadystatechange = function () {
if (this.readyState === XMLHttpRequest.DONE) {
const txt = this.responseText;
if (txt) {
Object.defineProperty(this, 'response', { writable: true });
this.response = modifyTextResponse(txt);
}
}
if (oldOnreadystatechange)
return oldOnreadystatechange.apply(this, arguments);
};
return oldXHROpen.apply(this, arguments);
}
這種方法適用于許多網站,但在Facebook上會產生異常效果 - 關鍵詞沒有被替換,而且頁面很快就會崩潰。這可能是因為Facebook頁面在數據完全加載之前就使用了數據。因此,創建一個從原生getter讀取數據的getter而不是屬性是至關重要的。新的getter應該在所有可能的事件回調中都能正常工作。定義getter的唯一可能位置是open()方法。
代理getter轉換繼承getter返回的值
這段不會出錯的代碼定義了一個response getter函數,它首先通過在this對象上調用prototype中的response getter獲取真實的響應值,然后返回用當前響應值調用modifyTextResponse()產生的值:
function defineProxyGetter(obj, property, func) {
Object.defineProperty(obj, property, {
get() {
const val = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, property).get.call(obj);
return func(val);
}
});
}
if (!oldXHROpen)
var oldXHROpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function () {
defineProxyGetter(this, 'response', modifyTextResponse);
return oldXHROpen.apply(this, arguments);
}
在這篇文章中我修改了文本數據,因為這種修改更常見且結果容易可視化,但同樣的方法應該也適用于blob或任何類型的響應數據。當然modifyTextResponse()應該替換為合適的函數。