用 Chrome 扩展实现修改
用 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 扩展就写好了。