Web 拦截技巧
Web 拦截技巧
决明1. 拦截的价值
计算机科学领域的任何问题都可以通过增加一个中间层来解决。 —— Butler Lampson
如果系统的控制权、代码完全被掌控,很容易添加中间层;
现实情况我们往往无法控制系统的所有细节,所以需要使用一些 “非常规”(拦截) 手段来增加中间层。
2. 拦截的方法
2.1 拦截/覆写 浏览器 API
最常见的场景有通过拦截 console
实现错误上报。
const _error = console.error |
项目中通常会基于 axios 此类的网络库,做一些统一处理逻辑
但在某些场景,我们无法修改项目代码,就能通过拦截 fetch, xhr 来达到目的。
// 接口性能监控,打开 https://example.com/, 在控制台执行以下代码 |
你可以选择第三方库(比如 xhook)来快速实现 fetch, xhr 拦截功能。
浏览器中大多数 API 都是可以覆写的,打开脑洞,可以实现非常多的神奇功能:
- 网络 API (
xhr, fetch, WebSocket
)- 性能监控、统一错误码处理
- 添加额外 HTTP 参数(header, query)实现接口染色功能
- 修改 Host 将接口自动转向代理服务,实现远程调试接口、Mock 数据
- 修改原型 (
Array.prototype.at = …
)- polyfill 库的必备手段
- 页面跳转 API (
window.open, history.go back pushState
)- 修改跳转的目的页面
- 自动添加页面跳转埋点
- 删除特定 API 禁用浏览器功能
- 禁止 js 访问摄像头
navigator.mediaDevices.getDisplayMedia = null
- 禁止 p2p 连接
window.RTCPeerConnection = null
- 禁止 js 访问摄像头
2.2 拦截/篡改 事件、DOM 元素
浏览器也会提供一些具备拦截性质的 API,允许开发者实现特定功能。
一个 DOM 元素经常会绑定许多事件,如果你想让特定的事件回调函数先执行,以便添加一些前置逻辑或取消后续事件的执行;
可以了解 addEventListener#usecapture的用法。
// 禁止响应页面的所有点击事件(危险⚠️),第三个参数(usecapture)设为 true |
许多 DOM 元素都是在运行时动态创建的,如果需要修改动态创建的 DOM 元素可使用 MutationObserver
比如,拦截所有超链接(a 标签),给目标链接添加 _source
参数
const observer = new MutationObserver(mutationsList => { |
TIP >
MutationObserver
同样适应于修改iframe, img
的链接,或其它任意 DOM 元素的属性
2.3 调试小技巧
如果你的页面因未知代码陷入了快速刷新的死循环,可在项目中添加以下以下代码;
页面刷新前会进入 debug 状态,在 devtools 中查看调用堆栈(call stack)即可了解刷新的原因
window.addEventListener('beforeunload', () => { |
TIP
http 302 属于非代码导致页面跳转,上述代码无法拦截
当调试第三方代码时,需要监听某个不符合期望的对象属性值
// debug 状态下任意可访问对象 |
如果需要监听某个对象所有属性值被读写的消息,可以使用 Proxy
const obj = { prop: 1 } |
注意差异
Object.defineProperty
没有改变obj
的引用,Proxy
生成了新对象obj2
- 使用
Proxy
可以监听对象(obj2
)所有属性的读写,而Object.defineProperty
一次只能监听一个属性(prop
)
2.4 ServiceWorker 拦截
前端开发者可能会使用 ServiceWorker 来实现离线可用、缓存资源、加速页面访问等功能。
// 安装时缓存资源 |
ServiceWorker 不仅是一个具备拦截性质的 API,它还是独立 js 运行环境,是前端页面到服务器之间的中间层,它能拦截同域名下的所有请求,缓存或篡改请求结果,能实现的功能远不止离线或加速访问这么简单。
后文会从 WebContainer 原理分析 ServiceWorker 的高端玩法。
2.5 服务器拦截
前面介绍的技巧都局限在客户端(浏览器)中,如果你掌握了真正的服务控制权,即配置服务器和内网 DNS 域名等权限(一般由公司内的工程效率团队或运维负责),再配合前面介绍的浏览器拦截技巧,可玩性将大大增加。
一个 HTTP 请求往往会经过多个服务器节点,每个节点就是一个中间层。
- DNS 决定 HTTP 请求由哪个网关(Nginx)处理
- 如果你能控制 DNS 服务,则可以在网关之前再加一个中间层
- 服务器节点(网关、Service)节点能获取、篡改 HTTP 请求中的所有信息:Header、Cookie、Body
- 根据 HTTP 信息,可将请求转发到本地目录(静态资源),或转发到其他远程服务
- 添加 Cookie 追踪用户,实现灰度、AB 实验分流等功能
- 实现业务层无感知注入代码
- 动态篡改数据,实现 Mock 功能
拦截篡改 HTTP Response
Nginx 修改 html Response body,注入一个脚本 ff-sdk.js
sub_filter <head> "<head><script src='/ff-sdk.js'></script>"; |
TIP
- 注入到 head 标签后,确保脚本优先执行
- 可通过脚本实现任意功能
3. 如何注入拦截代码
前文介绍的客户端拦截技巧都需要向浏览器中注入代码,原理是修改 html 或 js 资源的内容。
有以下方式注入代码在浏览器环境中运行
- 向 html 中添加一个 script 标签,src 指向特定的 js 地址(前文的
/ff-sdk.js
)- 简单易维护,优先使用该方法
- 向 html 注入一个完整的 script 标签
- 如 Nginx 注入
sub_filter “< script>console.log(‘注入成功’)</>”
- 如 Nginx 注入
- 修改 js 资源内容,在开始位置插入代码
‘;(function(){ alert(‘注入成功’) })();’ + < js file body>
- 注入代码包裹在自执行函数中,注意前后加上分号(去掉 < js 中间的空格)
以下列举注入代码的时机,根据目的和能获取的权限决定中哪个阶段注入
-
源码注入
- 如果你有源码控制权,那你可以对项目做任何事情,确保拦截代码优先执行即可
- 优点:灵活可控;缺点:通用性不好,侵入业务
-
构建、推送服务注入
- 工程团队提供构建、推送服务,可编写脚本在构建产物中注入代码(比如 html 中添加 script 标签)
- 优点:业务无感知,通用性好;缺点:不一定有权限
-
网关注入
- Nginx 向 html 中注入 script 也很简单(参考前文 拦截篡改 HTTP Response)
- 优点同上,一般限于在开发、测试环境,不会上生产环境
-
浏览器插件、devtools 注入
- 如果你啥权限都没有(普通用户),又想干一些“坏事”,则可选择使用插件注入代码,或临时在 devtools 的 console 面板直接写代码
- 优点:万能注入、无法阻挡;缺点:只能影响当前浏览器,难以跨浏览器、设备、用户
- 常用的脚本管理插件:篡改猴 ,也可考虑自己写浏览插件
尽量让被注入的代码早于业务代码执行,比如实现拦截并上报错误信息,如果被注入的代码执行时机较晚,则会丢失执行前的错误信息。
4. 案例分析
4.1 WebContainer 原理
WebContainer是一种基于浏览器的运行时,可完全在浏览器标签页内执行 Node.js 应用程序和操作系统命令。
惊艳的地方有两点
- 能在浏览器中运行 Node 服务,居然还能启动 DevServer “监听端口”
- 离线后 IDE 开发中的页面也能正常开发
以 https://stackblitz.com/edit/nextjs-9hpfxj?file=package.json 为例
核心部分是将 Node 编译成 WASM,然后 Mock 文件系统、底层网络模块 使其能在浏览器中运行。
然后使用 Node 启动 devServer 服务,监听端口。
- dev 页面发起的请求被 ServerWorker 拦截映射成本地 Mock 的文件
- 本地 Mock 的文件由 IDE 源码使用 Node 编译而成
- 源码的依赖包被映射成 http 请求从远端获取
比如安装依赖 react-dom 对应的网络请求是:https://t.staticblitz.com/w/v13/h/e/react-dom@18.2.0
在浏览器中运行 DevServer 时,当然无法监听 TCP 端口。
巧妙的地方是将端口映射成唯一的域名,通过 ServiceWorker 拦截域名下的所有请求,关联到 Mock 的文件系统
WebContainer 启动过程
下面的 URL 是动态生成,内容在本地的 ServiceWorker 中,不可访问,仅作为理解结构的示例
- 浏览器加载 IDE 页面,注册 IDE ServiceWorker
- 初始化 WebContainer,IDE 与 WebContainer 通过 API 通信,IDE 将代码传入容器(挂载)
- WebContainer 的网络模块被 Mock,网络请求(install dependencies)通过 IDE ServiceWorker 发出去并缓存
- Node(WASM)运行代码,将端口映射成唯一 URL
- 例:
https://nextjs9hpfxj-lfw4--3000.local-credentialless.webcontainer.io
- 例:
- iframe 发一起 relay html 请求,并在请求中注册 DevServer ServiceWorker (这步需要访问互联网)
- html:
https://nextjs9hpfxj-lfw4--3000.local-credentialless.webcontainer.io/.localservice@relay.88d3a49b591611919d7d64c44b90ff5f8e939906.html
- DevServer ServiceWorker :
https://nextjs9hpfxj-lfw4--3000.local-credentialless.webcontainer.io/.localservice@service.88d3a49b591611919d7d64c44b90ff5f8e939906.worker.js
- html:
- iframe 加载主 HTML,该请求被 DevServer ServiceWorker 拦截,转发到 IDE ServiceWorker,使用文件系统(Mock)中资源作为 response
- 主 HTML 是 步骤 4 生成的 URL
- 主 HTML 引入的 js、css 也会被 DevServer ServiceWorker 拦截并转发,所以离线也能正常访问
实际情况肯定非常复杂, Service Worker 玩得太 6 了。
4.2 沙盒
沙盒(sandbox,又译为沙箱)是一种安全机制,为执行中的程序提供隔离环境。通常是作为一些来源不可信、具破坏力或无法判定程序意图的程序提供实验之用。
假设你需要执行一段第三方代码,安全要求禁止访问 document
,以及 window
下的 open、location
。
尝试复制以下代码在 console 中执行
function safeExec(code) { |
沙盒也是前端微应用的核心技术,通过 Proxy 监听 window 的读写记录,可以隔离多个微应用的运行环境,卸载某个微应用后也能将环境(window 对象)重置回该应用加载前的状态。
4.3 通用域名服务
一般 Web 开发者使用会使用 localhost:8080、192.168.0.1:8080
这个访问开发环境;
如果使用真实域名来转发请求到开发环境(https://ff-dev.bilibili.com?_ip_=192.168.0.1&_port_=8080
),能解决一些常见的问题:
- 轻易实现共享域名(bilibili.com)的登录态 cookie
- 在远程设备(手机、测试设备)调试本地开发中服务
- 无需配置 Web 服务的 https 证书
- 使用 https 协议访问开发服务,避免 http 协议导致许多 Web API 不可用
- 该服务是一个天然的中间层,可无感注入代码实现效率工具,比如:
- 远程网络抓包、Mock
- 移动端控制台(eruda)
- 远程代码调试(chii)
- 切换后端接口环境、接口染色
真实域名转发请求,能解决部分场景的问题,也会带来一些新问题需要解决:
- 静态资源(
/index.js
)不带参数,无法转发到目的地,无法初始化页面 - 页面跳转后,域名或参数(
_ip_, _port_
)会丢失 - HMR 的 WebSocket(
192.168.0.1:4000/socket.io
) 连接失败,无法自动热更新
解决这问题确实有一些难度,好在前文已经介绍了对应的拦截技巧;
一旦解决问题部署通用域名服务后,就能给许多人提供便捷服务。
实现原理
- 加载 HTML
- 部署一个 Nginx 服务,并为其注册域名(
ff-dev.bilibili.com
) - 在 Nginx 中读取 http 请求的 query 参数(
_ip_, _port_
),将请求转发到对应目标- 目标响应后,将参数也写入到 Response 的 cookie 中
- 如果是 html 请求,则在 body 中注入 js
<body>...</body>
替换为<body><script src="ff-dev-sdk.js"/>...</body>
- 部署一个 Nginx 服务,并为其注册域名(
- 加载静态资源
- 静态资源(
/index.js
)不带参数,Nginx 改成从 cookie 中读取参数,然后将请求转发到目的地
- 静态资源(
- 解决页面跳转参数丢失
ff-dev-sdk.js
拦截所有 a 标签、重写 open 方法,在页面跳转的 url 添加参数(_ip_, _port_
);- 页面跳转后,新页面也能转发到正确的目的地
- 解决 HMR 无法热更新
ff-dev-sdk.js
覆写WebSocket
的实现,将链接192.168.0.1:4000/socket.io
替换为ff-dev.bilibili.com/socket.io?_ip_=192.168.0.1&_port_=4000
继续由 Nginx 转发
以上步骤使用的技巧在前文都有介绍
- 注入 js 代码到 html 中,写入参数(
_ip_, _port_
)到 cookie 中 - 拦截 a 标签
- 覆写 API:open,WebSocket
5. 总结
5.1 应用中间层思路的经验
- “添加一个中间层”是一种有效且通用的解决问题的思路
- 根据需要解决的问题,思考中间层的位置、以及一个能注入代码时机,并让代码尽早执行
- 源码注入脚本
- 构建、推送服务注入
- 网关注入
- 浏览器插件、devtools 注入
- Web 技术栈拥有非常大的可操作空间
- 利用 js 的动态特性,覆写系统 API 实现拦截
- 灵活使用具有拦截性质的 API
- ServiceWorker
- 了解 HTTP 请求的构成(Header、Cookie、Body),以及它流转的节点
- 多种拦截技巧灵活搭配,释放更强大的力量
- 能力越强(拦截范围越大)、责任越大,注意安全
5.2 安全边界
从前文看由于 js 灵活特性,甚至看起来有点不安全(覆写系统 API),但恰恰相反,Web 平台常与“安全”一起出现。
js 的动态灵活是运行在 Web 安全边界(同源策略、内容安全策略)构建的“大沙盒”内的。
新手开发者常碰到的跨域问题,就是因为碰到了安全边界,安全边界是 Web 平台能力受限(相对 Native)的重要原因之一。
Web 平台中的所有技巧策略都必须符合安全规则;
所以,建议大家在学习拦截技巧的同时,了解同源策略、内容安全策略(CSP) 等安全知识。