
用 Chrome 擴展實現修改 ajax 請求的響應
背景
Fiddler 和 Charles 是常見的 HTTP 調試器,它們會在本地運行一個代理服務器,可以查看瀏覽器或其它客戶端軟件通過這個代理發起的請求和服務器的響應,也可以在請求提交到服務器之前和服務器返回響應之后設置斷點,手工修改請求、響應的內容。另外,兩個軟件都可以以“中間人攻擊”的形式,解密 HTTPS 通信。
某天,我在調試某個網站的過程中,希望把“修改 ajax 請求的響應”這一功能持久化下來方便使用。Fiddler 提供了自定義腳本的功能,但它的 macOS 版本和我用 Homebrew 安裝的 Mono 不兼容,而且我也不希望我的所有網絡流量都經過 Fiddler 代理。另外考慮到將來可能把這個功能小范圍發布出來供其它人使用,那么 Fiddler 或者 Charles 這種“重量級”解決方案就被否定了,所以我很自然地想到了使用 Chrome 擴展來完成此任務。
目標
實現一個 Chrome 擴展,自動修改特定網站的 ajax 請求的響應。網站是一個重度使用 ajax 的網站,並且帶有比較嚴格的“反作弊”限制,對 Cookie、HTTP Header(Referer等)甚至多個 ajax 請求的順序都有要求,如果出錯就無法繼續。
方案1:WebRequest API 修改響應
從 Fiddler 這種方案自然而然地想到,如果 Chrome 也提供類似的“斷點”機制,外加 JavaScript 腳本,上述問題就解決了。於是搜了一下 “chrome extension hook ajax”,找到了Is there a hook for when an AJAX call returns? 第一個回答便是使用 WebRequest API 。

於是開始學習 WebRequest API 的文檔,它能監聽的事件如上圖所示,最有可能滿足條件的就是 onResponseStarted 了,但仔細一看發現它是一個異步事件,並不能實現“斷點”,也不能做任何改動(This event is informational and handled asynchronously. It does not allow modifying or cancelling the request)
換個關鍵詞 “webrequest api modify response” 再次搜索,找到了和我的需求十分吻合的問題和答案:chrome extension - modifying HTTP response ,其中有三個有用的信息:
- Chrome 的 issue:allow extension to edit response body - chromium - Monorail 意味着上述想法並不能實現
- 可以替換頁面上的 XMLHttpRequest
- 用 WebRequest API 將請求重定向到 data:-URL
方案2:WebRequest API 重定向
我想的方案是:在背景頁中以 blocking 模式注冊監聽 onBeforeRequest 事件,在事件處理函數中,重新發起這個 ajax 請求。此處要注意兩點:1.要在原頁面的環境中請求,大致思路是在頁面中注入腳本,背景頁使用消息機制和頁面腳本通信,2.“重新發起”的 ajax 請求依然會被 onBeforeRequest 事件攔截,需要做額外的處理。“重新發起”的 ajax 請求拿到數據后,進行修改,最后編碼為 data:-URL 作為 blockingResponse 返回。
背景頁的代碼大致如下:
chrome.webRequest.onBeforeRequest.addListener( function(details) { // 如果請求帶有特殊標記,則不進行修改 if (details.url.endsWith("#do_not_modify_this_request")) { return {} } // 使用消息機制將請求傳遞給頁面再發起 ajax,而不是在背景頁中發起 chrome.tabs.sendMessage(details.tabId, details, function(response) { // 此處可以修改response... redirectUrl = "data:application/json;charset=UTF-8;base64," + Base64.encode(newResponse) }); return {redirectUrl: ...}; }, {urls: [...]}, ["blocking", "requestBody"] );
chrome.runtime.onMessage.addListener( function(request, sender, sendResponse) { // 重新發起的請求要做標記,避免無限循環 var settings = { url: request.url + "#do_not_modify_this_request", method: request.method, dataType: "text" }; if (request.requestBody && request.requestBody.formData) { settings.data = request.requestBody.formData; } $.ajax(settings).done(function(data) { sendResponse(data); }); // 由於 sendResponse 是異步調用的,需要返回 true return true; } );
前途是光明的,道路是曲折的。在這里我就遇到了“暫時的困難”,寫完上面的代碼,我竟然不知道怎么寫下去了。前面說的方案,到這里似乎已經完成了 90% 了:背景頁攔截了 ajax 請求,在頁面注入腳本重新發起了請求,拿到了結果,進行了修改,編碼成 data:-URL。但差就差在最后一步上,這個 data:-URL 是在回調函數里拼出來的,而執行回掉函數的時候,外層的事件處理函數早就應該返回啦。並沒有任何機制可以在返回前“等一下” sendMessage。
繼續上搜索引擎,我看到了 54257 - The absence of synchronous message API make impossible to pass options to scripts that are loaded before the page to block content. - chromium - Monorail 這又是一個 issue ,而且結論還是 WontFix 。
其中有人提到,用 storage API 可以解決,但下一條回復反駁了他,因為 storage API 本身也是異步的。於是這個想法又一次失敗了。
方案2.5:不完美的嘗試
我把背景頁改了一下,頁面腳本保持不變:
chrome.webRequest.onBeforeRequest.addListener( function(details) { // 發起 ajax 請求的部分不變,不再處理響應 if (details.url.endsWith("#do_not_modify_this_request")) { return {} } chrome.tabs.sendMessage(details.tabId, details, function(response) {}); // 直接生成新頁面,進行重定向 content = "......" return {redirectUrl: "data:application/json;charset=UTF-8;base64," + Base64.encode(content)}; }, {urls: [...]}, ["blocking", "requestBody"] );
方法簡單粗暴,ajax 請求照常發起(因為服務端的限制,如果不發起這個 ajax 請求的話,下一步其它 ajax 必然會返回錯誤,功能就無法使用了),但是結果直接忽略,用預先准備好的假頁面直接返回。
這個方案實際執行時“時好時壞”,原因是,事件處理函數返回后,頁面的下一個 ajax 請求就會發起,如果模擬 ajax 請求先於它發生,則結果正常。反之,如果下一個 ajax 請求發起時,我的模擬請求尚未發送,那服務端就直接拒絕執行,返回“服務器繁忙”的錯誤,其實這“服務器繁忙”就是“發現你在作弊”的意思。
方案3:替換 XMLHttpRequest
最后,只剩下這一個方案了,上面那個答案說得很復雜,但搜一下還是能找到“成品”方案。How can I modify the XMLHttpRequest responsetext received by another function? 中已經寫好了代碼,略做修改如下:
function modifyResponse(response) { var original_response, modified_response; if (this.readyState === 4) { // 使用在 openBypass 中保存的相關參數判斷是否需要修改 if (this.requestUrl ... && this.requestMethod ...) { original_response = response.target.responseText; Object.defineProperty(this, "responseText", {writable: true}); modified_response = JSON.parse(original_response); // 根據 sendBypass 中保存的數據修改響應內容 this.responseText = JSON.stringify(modified_response); } } } function openBypass(original_function) { return function(method, url, async) { // 保存請求相關參數 this.requestMethod = method; this.requestURL = url; this.addEventListener("readystatechange", modifyResponse); return original_function.apply(this, arguments); }; } function sendBypass(original_function) { return function(data) { // 保存請求相關參數 this.requestData = data; return original_function.apply(this, arguments); }; } XMLHttpRequest.prototype.open = openBypass(XMLHttpRequest.prototype.open); XMLHttpRequest.prototype.send = sendBypass(XMLHttpRequest.prototype.send);
這段代碼會替換 XMLHttpRequest 中的 open 和 send 函數,在 open 中優先注冊 readystatechange 的事件監聽,以便在原頁面代碼執行前修改 responseText 的內容。
這段代碼不能直接注入頁面,因為 Chrome 擴展的 Content Script 會運行在隔離環境中,直接注入的話,並不能影響到頁面原有的 XMLHttpRequest。想要實現我們想要的功能,可以參考Building a Chrome Extension - Inject code in a page using a Content script 的做法。再寫一個文件:
var s = document.createElement("script"); s.src = chrome.extension.getURL("xmlhttp.js"); s.onload = function() { this.remove(); }; (document.head || document.documentElement).appendChild(s);
這個功能一看就知道,在頁面上增加一個 <script> 標簽,src 屬性指向插件中的 xmlhttp.js。為了讓這個文件能在頁面內被引用,需要在 manifest.json 里加一行:
"web_accessible_resources": ["xmlhttp.js"]
需要注意的是:用這種方法插入的腳本,是無法控制在何時執行的(不像 Content Script,可以設置 document_start、document_end、document_idle),而在此文件執行前發起的 ajax 是無法被修改的。幸好在我的需求中,這些 ajax 請求都是由用戶點擊觸發的,在那之前時間充足,足夠我動手腳了。
綜上所述,一個能修改 ajax 響應的 Chrome 擴展就寫好了。