通过chrome扩展程序获取responseBody的更优方案——改写XHR(背景原理篇)

原文地址
​​​​​​​https://zhuanlan.zhihu.com/p/579188026
 

通过chrome扩展程序获取responseBody的更优方案——改写XHR(背景原理篇)

通过chrome扩展程序获取responseBody的更优方案——改写XHR(背景原理篇)

wildman

半路出家的前端狗

​关注

5 人赞同了该文章

系列文章:
通过chrome扩展程序获取responseBody的更优方案——改写XHR(背景原理篇)
通过chrome扩展程序获取responseBody的更优方案——改写XHR(使用示例篇)

项目仓库地址: request-retransmission-chrome-extension

这个是几年前写的一个项目了,当时技术还比较稚嫩,因个人水平有限, 如代码中有bug, 或存在可以优化的内容, 欢迎指正和issue. 如果该项目对你有所帮助,欢迎Star~

背景

这个是几年前的一个项目. 当时我在一家做智能仓储机器人的公司, 当时我司与一个客户达成初步合作意向, 打算在客户的仓库使用我司的仓储管理方案. 那个客户是菜鸟平台的WMS(仓库管理系统), 原本他们的业务逻辑是:

原流程

所以要建立合作,前提是需要实现一个需求: 需要将菜鸟平台的实时订单推送到我司自身的WMS系统, 然后才好进行数据处理.

理想流程

不难想到, 这种情况最简单的解决办法是: 使用菜鸟WMS的账号密码, 调用菜鸟WMS的登录接口, 获取到它的的登录凭证(cookie/session等), 然后用这个凭证直接调用菜鸟WMS的订单列表接口来拉取订单:

理想方案

但实测发现, 菜鸟WMS的登录认证比想象中的复杂(不愧是大厂出品), 似乎有校验IP或定位之类的机制, 别说通过后端去拉接口, 就连在异地浏览器上按常规流程输入账号密码都无法登录 (隔的时间有点久了, 具体报错记不清了).

所以这个方案否了. 只能另想方案.

最终流程

经过商讨, 我们决定先采用一种比较简单粗暴的解决办法来拉取订单进行测试, 等到确定了最终合作意向, 再由合作方去向菜鸟申请专门的账号给我司使用. 该方案如下:

  1. 我司开发一个Chrome扩展程序, 功能是拦截特定域名和链接, 然后转发到另一个链接;
  2. 在合作方的电脑上安装该扩展程序, 当操作人员在他们公司操作菜鸟wms时, 该扩展程序将拦截订单列表接口的请求, 并将response转发到我司系统上;
  3. 我司系统依据接受到的订单列表中的SKU信息进行后续操作.

after.png

虽然之前没有开发过Chrome扩展程序,但这个需求功能相对单一,而且相关文档齐全,想来不难,那就开搞把。

获取ResponeseBody的难题

然而实际开发时很快就遇到了一个巨大的卡点: chrome扩展程序的webRequest - API, 压根不支持获取 responseBody(吐血)... 能获取到responseHeaders/requestBody, 能获取到statusCode, 但偏偏没有把responseBody暴露出来. 原因不难猜, 估计是又出于安全的考虑了.

blog-noway-to-get-resposeBody.jpg

(有兴趣的同学可以围观Chromium论坛上对于这个问题的吐槽:Chromium帖子. )

从网上查到的信息看,遇到这个问题的同学似乎不少,而常用的解决方案主要有两种:

  • 方案1:使用 chrome.debugger API开启调试模式。这也是大多数网友选择的方案。调试模式下允许获取responseBody,唯一缺点是会出现一个浏览器顶部会出现一个难看的调试横条,如下图:

chrome-debugger-bar.png

  • 方案2:使用chrome.devtools API。这种方法更不适合,需要一直开启Chrome Devtools才能实现。

以上两种方案虽然可行,特别是方案1,除了有个提示条也没发现什么别的毛病。但老板不同意,原因是这种提示条显得业余(捂脸),也会让人担心有隐私泄露风险而不放心使用;用于demo还可以,但对于面向客户的成熟商业产品则不太适合。

只能另想方案。

最优方案:注入脚本

后来查了大半天的资料,终于在 这篇文章 中找到一个有点hack,但真正实用的答案:

可以通过扩展程序injected_script注入,改写XMLHttpRequest对象,从而在Ajax响应时将responseBody发射出来

这是一种猴子补丁(Monkey patch)的思路,虽然现代的Javascript规范一般都不推荐使用这种修复原型的方法,一般业务功能开发我也非常不推荐这样做,但对于工具类库来说,hack一点也无可厚非。
对猴子补丁不熟悉的同学可以看看这篇文章: https://davidwalsh.name/monkey-patching

拦截原理

XMLHttpRequest的替换逻辑

众所周知,Ajax使用XMLHttpRequest发送请求,最简单的例子:

const xhr = new XMLHttpRequest();
xhr.onload=function(){
  if(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304){
    alert(xhr.reaponseText);
  }
};
xhr.open('get', 'example.com/url', true);
xhr.send(null);

从上述示例可知,只要能进入xhr.onload的函数内部,就能拿到responseBody了。通常情况下这个并不容易实现,不过刚好chrome扩展程序提供了注册脚本的现成的方法, 直接拿来用就行,宾狗~

注入脚本

chrome扩展程序的Content Script API提供了往一个网站注入一段脚本的功能,这给我们提供了注入patch脚本的途径。

chrome-extension-basic.png

通过contentScript将我们对XMLHttpRequest的改写逻辑注入特定网页,使用“猴子补丁”的思路,对XMLHttpRequestsend方法进行patch,然后就能拦截到responseBody了

个人猜测鼎鼎大名的油猴插件(tempermonkey)也是通过这种方式注入自定义脚本的吧。

实现逻辑

常规的chrome扩展程序主要包含以下内容: - manifect.json: 配置json - background.js: 基础脚本 - options.jsoptions.html: 选项页面代码及脚本 - popup.html: 按钮下拉菜单页面,本项目没有 - contentScript相关

这里只说contentScript相关代码. 其他的代码比较简单,详见github仓库

  1. 首先实现XMLHttpRequest的补丁:
// myXHRScript.js
(function(xhr) {
  var XHR = xhr.prototype;
  var open = XHR.open;
  var send = XHR.send;

  // 对open进行patch 获取url和method
  XHR.open = function(method, url) {
    this._method = method;
    this._url = url;
    return open.apply(this, arguments);
  };
  // 同send进行patch 获取responseData.
  XHR.send = function(postData) {
    this.addEventListener('load', function() {
      var myUrl = this._url ? this._url.toLowerCase() : this._url;
      if(myUrl) {
        if ( this.responseType != 'blob' && this.responseText) {
          // responseText is string or null
          try {
            var arr = this.responseText;

            // 因为inject_script不能直接向background传递消息, 所以先传递消息到content_script
            window.postMessage({'url': this._url, "response": arr}, '*');
          } catch(err) {
            console.log(err);
            console.log("Error in responseType try catch");
          }
        }
      }
    });
    return send.apply(this, arguments);
  };
})(XMLHttpRequest);

2. 因为inject_script不能直接与background.js交流,所以借助content_script.js实现转接逻辑,接受responseBody,并转发到background.js

// content_script.js
let targetUrl = '';
let targetOrigin = '';

$(function(){
  chrome.storage.sync.get(['targetUrl','targetOrigin', 'requestUrl'], function(data) {
    ...
    targetUrl = data.targetUrl;
    targetOrigin = data.targetOrigin;

    // content_script与inject_script的消息通知通过postMessage进行
    // 监听inject_script发出的消息
    window.addEventListener("message", (e) => {
      if(!e.data || Object.keys(e.data).length === 0 ){
        return;
      }
      // 检查收到的message是否是要监听的
      if(!targetOrigin
        || e.origin.indexOf(targetOrigin) === -1
        || !targetUrl
        || !e.data.url
        || e.data.url.indexOf(targetUrl) === -1
      ){
        return;
      }
      let responseDataList = null;
      // 使用try-catch兼容接收到的message格式不是对象的异常情况
      try{
        responseDataList = JSON.parse(e.data.response);
        // 发消息给background.js,并接收其回复
        chrome.runtime.sendMessage({data: responseDataList}, {}, function(res){
          // 收到回复后在页面弹出提醒
          createContentMsgNotice(res.type, res.message);
        })
      }catch(e){
        alert('获取的数据有误,请联系管理员!');
      }
    }, false);
  });
});

3. 在background.js接受到responseBody后,将数据转发到特定地址:

// background.js
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
  $.ajax({
    url: requestUrl,
    type: requestMethod || 'POST',
    contentType: "application/json",
    data: JSON.stringify(request.data),
    success: (msg) => {
      console.log(msg);
      // 使用sendResponse向消息源回传响应消息
      sendResponse({
        type: Number(msg.code) === 200 ? 'success' : 'danger',
        message: msg.message
      });
    },
    error: (xhr, errorType, error) => {
      sendResponse({
        type: 'danger',
        message: `${errorType}: ${error}`,
      });
    }
  });
  // 异步响应sendMessage的写法:异步接收要求返回turn,从而使sendMessage可以异步接收回应消息
  return true;
});

4. 最后,再将相关的几个js文件注册到manifest.json:

{
  "manifest_version": 2,
  ...
  "background": {
    "scripts": ["zepto.min.js", "background.js"]
  },
 ...
  "content_scripts": [
    {
     "matches": ["*://*/*"],
      "run_at": "document_start",
      "js": ["zepto.min.js","inject.js", "msgNotice.js","content_script.js"],
      "css": ["msgNotice.css"]
    }
  ],
  // 注入inject脚本
  "web_accessible_resources": ["myXHRScript.js"]
}

然后就OK了,responseBody拦截完成。

用这个扩展程序拦截豆瓣网站的示例:

动图封面

display-douban.gif

关于该示例的具体介绍和使用步骤,后面我再开一文写一下,以方便有需求的同学直接安装并调试具体代码。

总结

这个项目是个功能单一的扩展程序,大概细节如上述,具体代码详见仓库。从上述的示例可知,在浏览器上安装扩展程序存在一定的安全隐患,特别是非官方来源的。即使Chrome以及对扩展程序的权限做了种种限制(比如禁止webRequest API读取responseBody),但仍然有漏洞可以钻。像用户订单这么隐私信息都能通过上述方法轻而易举的获取到。所以我们日常使用浏览器最好只从官网下载并安装。

另有一些需注意的点:

  1. 由于采用的是改写XMMHttpReques请求,所以只适用于走Ajax方式获取的请求,也就是说后端渲染的页面(前后端未分离、SSR等)无法通过这种方法拦截。上面的例子使用WY严选来作为示范,也是出于这个原因——因为阿里、京东等老牌电商都是后端渲染的,不好拿...
  2. 这是个几年前的项目了(大概19年的样子),最近写这篇文章的时候试了一下功能还是正常的,但最近的浏览器兼容性不敢保证。听说最近chrome扩展程序的manifest已经升级到V3了,因为时间有限,没有去做新版迁移,有兴趣的同学可以自己尝试。
  3. 最近发现这种需求现在貌似可以借助"油猴(tempermonkey)"来注入脚本实现,不需要专门开发一个扩展程序.当时为啥没用油猴脚本呢?一个是当时好像还没有油猴扩展程序(或者名气还不显,我没听说),二是商业合作用油猴脚本会给人一直不专业的感觉,还是开发一个扩展程序逼格高.另外扩展程序上可以设置在特定拦截页面高亮, 这样也专业点。
  4. 本扩展程序因产品要求,在触发转发时右侧会给出MessageBox进行提醒,以便告知用户我们正在转发他的数据,免得他们担心我们会监听额外的请求。那个弹框写的比较糙,不需要的可以去掉。

发布于 2022-10-31 23:18

Chrome 扩展程序

前端开发

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值