Hook Requests in JavaScript | 在浏览器中拦截请求

冰岩作坊 October 20, 2024

在开发浏览器插件,或者写油猴脚本的时候,需要拦截一些 ws/http 请求。
本文来简单总结一下用过的实践。

在 Chrome Extensions 中拦截请求

在插件中,可以通过 chrome.webRequest API 非常便捷地实现拦截。

webRequest - MDN

在 Manifest V3 平台下,屏蔽网络请求/重定向多个网址等需求无法再用 webRequest API。
See developer.chrome.com - 替换屏蔽型 Web 请求监听器

1
2
3
4
5
6
// manifest.json
"permissions": [
  "webRequest",
  // ...
],

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// content.js
chrome.webRequest.onBeforeRequest.addListener(
  details => {
    console.log(details.url)
  },
  { urls: ["<all_urls>"] }
)

chrome.webRequest.onCompleted.addListener(
  details => {
    console.log(details)
  },
  { urls: ["<all_urls>"] }
)

有关 details 对象,see onResponseStarted#details_2 - MDN

这一 API 可以轻松的获取请求头/请求体/响应头等信息。但是其无法获取到 response body,所以无法获得或修改响应结果。这就对需要 hook 到详细内容的功能不友好。

(这里不讨论用 devtools API 的方法,感觉不太通用)

考虑脚本注入

脚本注入即是将一段 JS 插入到页面某一位置中执行。可以通过事件系统 + 修改 window.XMLHttpRequest/fetch/WebSocket 对象的方式实现 hooks。

拦截 XHR 请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 注入
;(function () {
  function ajaxEventTrigger (event) {
    const ajaxEvent = new CustomEvent(event, { detailthis })
    window.dispatchEvent(ajaxEvent)
  }

  const OldXHR = window.XMLHttpRequest

  function NewXHR () {
    const realXHR = new OldXHR()

    realXHR.addEventListener('readystatechange'function () { ajaxEventTrigger.call(this'ajaxReadyStateChange') }, false)
    // ...
    // abort error load loadstart progress timeout loadend 事件同理
  
    const setRequestHeader = realXHR.setRequestHeader
    realXHR.requestHeader = {}
    realXHR.setRequestHeader = function (name, value) {
      realXHR.requestHeader[name] = value
      setRequestHeader.call(realXHR, name, value)
    }
    return realXHR
  }

  window.XMLHttpRequest = NewXHR
})()

1
2
3
4
5
6
7
8
// 使用
window.addEventListener('ajaxReadyStateChange', e => {
  const xhr = e.detail
  if (xhr.readyState === 4 && xhr.status === 200) {
    handleResponse(xhr)
  }
})

拦截 fetch 请求

fetch API 基于 Promise,因此拦截请求不必引入事件系统。这里给出一种方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
fetch = (...args) => {
  return Promise.resolve(args)
    .then(args => beforeHook(...args))
    .then(args => {
      const request = new Request(...args)
      return oldFetch(request)
    })
    .then(resp => afterHook(resp))
}

const beforeHook = () => { /* ... */ }
const afterHook = () => { /* ... */ }

See github.com/mlegenhausen/fetch-intercept

拦截 ws 请求

这里给出一个简化版的 ws hook。思路很简单,在原生 WS 的事件中插入一个钩子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
function MutableMessageEvent (o) {
  this.bubbles = o.bubbles || false
  this.cancelBubble = o.cancelBubble || false
  this.cancelable = o.cancelable || false
  this.currentTarget = o.currentTarget || null
  this.data = o.data || null
  this.defaultPrevented = o.defaultPrevented || false
  this.eventPhase = o.eventPhase || 0
  this.lastEventId = o.lastEventId || ''
  this.origin = o.origin || ''
  this.path = o.path || new Array(0)
  this.ports = o.parts || new Array(0)
  this.returnValue = o.returnValue || true
  this.source = o.source || null
  this.srcElement = o.srcElement || null
  this.target = o.target || null
  this.timeStamp = o.timeStamp || null
  this.type = o.type || 'message'
  this.__proto__ = o.__proto__ || MessageEvent.__proto__
}

const wsHook = {
  before: data => data,
  after: e => e
}

const _WS = WebSocket
WebSocket = function (url) {
  this.url = url
  const WSObject = new _WS(url)

  const _send = WSObject.send
  WSObject.send = function (data) {
    arguments[0] = wsHook.before(data) || data
    _send.apply(this, arguments)
  }

  WSObject._addEventListener = WSObject.addEventListener
  WSObject.addEventListener = function () {
    const eventThis = this
    // if eventName is 'message'
    if (arguments[0] === 'message') {
      arguments[1] = (function (userFunc) {
        return function instrumentAddEventListener () {
          arguments[0] = wsHook.after(new MutableMessageEvent(arguments[0]))
          if (arguments[0] === nullreturn
          userFunc.apply(eventThis, arguments)
        }
      })(arguments[1])
    }
    return WSObject._addEventListener.apply(this, arguments)
  }

  return WSObject
}

1
2
3
4
5
6
7
8
// 使用
wsHook.before = (data) => {
  console.log(data)
}
wsHook.after = (messageEvent) => {
  console.log(messageEvents)
}

在浏览器拓展中注入 hooks

由于在浏览器拓展 content_scripts 环境与真实的页面环境隔离,只能获取 DOM 等,无法直接更改 window.XMLHttpRequest/fetch/WebSocket 对象。所以必须借助 script 标签注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// manifest.json
{
  "content_scripts": [
    {
      "matches": ["http://*/*""https://*/*"],
      "js": ["content-script.js"]
    }
  ],
  "web_accessible_resources": [
    {
      "resources": ["injected-script.js"],
      "matches": ["http://*/*""https://*/*"]
    }
  ]
}

1
2
3
4
5
// content-script.js
const scriptDOM = document.createElement('script')
scriptDOM.setAttribute('src''injected-script.js')
document.head.appendChild(s)

1
2
3
4
5
6
7
8
// injected-script.js

// 修改 XHR 对象
window.XMLHttpRequest = newXHR

// 监听
window.addEventListener('ajaxReadyStateChange', handleFunc)

参考

https://github.com/debingfeng/blog/blob/master/docs/javascript/practise/JavaScript%E7%9B%91%E5%90%AC%E6%89%80%E6%9C%89Ajax%E8%AF%B7%E6%B1%82%E7%9A%84%E4%BA%8B%E4%BB%B6.md

https://github.com/mlegenhausen/fetch-intercept

https://github.com/skepticfx/wshook

#JavaScript