这是 Web 全量代理系列里最硬核的一篇,讲一个让所有"作用域劫持"方案翻车的语言特性:动态 this。
如果你在做 Web 代理 / 浏览器沙箱 / JS 沙盒 / 注入式前端 SDK,几乎一定会撞上这个问题。本文完整讲清楚:为什么 this 是死穴,为什么字符串替换救不了,AST 怎么救。
一、背景:作用域劫持与"内网穿透"
前置知识。Web 全量代理的目标:把任意网站在浏览器里完整代理,让 JS "以为"自己还在原站运行。
为什么需要"作用域劫持"?因为现代网站 JS 会动态读 location:
js
const apiBase = window.location.origin
const url = apiBase + '/api/v2/user'
fetch(url)
光服务端改写 URL 不够------JS 运行时读 location.origin 拿到的是代理域名,拼出来的 URL 还是代理域名,请求能通但接口里校验 origin 会挂。
解法:劫持作用域 ,把代码包进 with:
js
(function () {
with (self.__context_proxy__) {
// 原代码
const apiBase = window.location.origin // 实际读 __location__.origin,返回原站
}
}).call(self.__context__.self)
__context_proxy__ 是 Proxy,has 永远返回 true,get 优先返回伪造对象。代码里所有裸标识符 location、window、self 都被劫持到代理对象,返回原站的值。
这样被代理网站的 JS 拿到的 location.origin 是原站 URL,但实际请求经 fetch 包装后打到代理域名------JS 视角透明,请求实际走代理。这是"在浏览器里实现内网穿透"的核心机制:用户在外网,但页面 JS 看到的是内网视角。
二、死穴:动态 this
上面方案看起来天衣无缝,直到遇到这段代码:
js
function getApiBase() {
return this.location.origin
}
element.addEventListener('click', getApiBase)
浏览器调用事件回调时,this 指向触发事件的元素。但如果是这样:
js
function getApiBase() {
return this.location.origin
}
// 非严格模式下直接调用
getApiBase()
非严格模式下,裸调用函数 this === window 。这时 this.location 实际访问的是真实的 window.location------绕过了 with 作用域劫持。
为什么 with 拦不住 this.location
with(obj) { ... } 的语义是:在 with 块内,裸标识符 优先从 obj 解析。但 this.location 不是裸标识符------this 是关键字,求值后得到一个对象(这里是 window),然后 .location 是属性访问,走原型链。
with 只劫持"作用域查找",不拦截"对象属性访问"。所以:
js
with (__context_proxy__) {
location // ✓ 被劫持,读 __location__
window.location // ✓ 被劫持,window 是 __self__
this.location // ✗ this 是真实 window,location 是真实 location
}
这种代码真的存在吗
非常常见。随便举几种:
js
// React 类组件
class App extends Component {
componentDidMount() {
fetch(this.location.origin + '/api/init') // 翻车
}
}
// jQuery 风格
$('#btn').on('click', function() {
window.open(this.location.href) // 翻车
})
// 事件回调
function handler() {
const base = this.location.origin // 翻车
}
document.addEventListener('click', handler)
更坑的是这种:
js
const obj = { location: window.location }
function getHref() { return this.location.href }
obj.getHref = getHref
obj.getHref() // this 是 obj,obj.location 是真实 location(构造时就缓存了)
obj.location 在构造时被赋值,直接缓存了真实 window.location 。即便后续劫持了 window.location,obj.location 已经是旧引用。这种"快照"问题更隐蔽。
三、为什么字符串替换救不了
第一反应:把所有 this.location 替换成 __location__ 不就行了?
js
function getApiBase() {
return __location__.origin // 替换后
}
问题在于 this 的指向是运行时动态的:
js
function getHref() { return this.location.href }
getHref() // this 是 window
getHref.call(obj1) // this 是 obj1
getHref.call(obj2) // this 是 obj2
element.onclick = getHref // this 是 element
new getHref() // this 是新对象
同一个函数,四种调用方式,this 四种值。静态分析无法确定 this 是什么。
如果无脑替换成 __location__:
js
function getHref() { return __location__.href }
const element = { location: 'some element location' }
getHref.call(element) // 原本应该返回 element.location,现在返回 __location__.href ------ 业务逻辑错了
业务逻辑里 this.location 可能根本不是 window.location------是某个业务对象的 location 字段。无脑替换会破坏业务。
更糟:注释和字符串
js
const code = 'this.location.href' // 字符串,不是代码
const comment = '/* this.location */' // 注释
const regex = /this\.location/ // 正则
字符串替换会把字符串内容和注释也替换掉,破坏业务逻辑。
四、商业方案的妥协:每站规则
商业 Web 代理怎么处理?答案是给特定网站写规则:
kotlin
检测到 example.com 的代码里有 this.location
↓
为 example.com 单独写规则
↓
在通用拦截之外,针对 example.com 做替换
↓
上线,下个网站又坏了
↓
再写一条规则
↓
... 规则库积累到几千条
这是"打地鼠"模式。每个网站的 this 用法不一样,规则不能复用。规则库越来越臃肿,新网站适配越来越慢。这是商业 Web 代理(包括校园网 Web 代理)的核心痛点。
五、AST 解法:精确识别 this
要让"通用全量代理"成立,必须从根上解决 this 穿透,不能依赖每站规则。
关键洞察
this 在 JS 语法树里是 ThisExpression 节点------关键字,不是标识符。AST 解析器能精确区分:
js
this.location // ThisExpression + MemberExpression
const x = 'this.location' // 字符串字面量,不解析为 ThisExpression
/* this.location */ // 注释,不进入 AST
const this_location = 1 // 标识符,不是 ThisExpression
AST 相比字符串替换的核心优势:知道每个 this 是关键字还是文本。
改写策略
遍历 AST,把所有 ThisExpression 替换成:
js
(this === self ? __self__ : this)
this === window时(裸调用、事件回调),返回__self__,劫持生效this是其他对象时(call/apply/new),返回原this,业务逻辑不变
改写后:
js
function getApiBase() {
return (this === self ? __self__ : this).location.origin
}
__self__ 是伪造的 window Proxy,访问 __self__.location 走 __location__,劫持生效。
完整流程
kotlin
JS 源码
↓ oxc / acorn / babel 解析
AST
↓ 遍历,ThisExpression → (this === self ? __self__ : this)
AST(改写后)
↓ codegen
JS 源码(改写后)
↓ 注入 with(__context_proxy__) 包裹
最终代码
关于 this === self 比较的细节
细心的读者会发现:在 with 块内,self 也会被 __context_proxy__ 劫持为 __self__。所以 this === self 实际比较的是"真实 window(this)是否等于 self (劫持后的 self)"------不相等。
这里还有个隐蔽的坑:业务代码里常见的 var self = this(jQuery 风格)会污染 self,让 this === self 在不该成立时恒真。所以更稳的基准是 globalThis------Rust 版用的替换式是 (this === globalThis ? __self__ : this)。globalThis 作为内置全局引用不被业务代码覆盖,提供一个"未被劫持的真实基准"。具体实现细节见代码。
六、AST 改写的工程细节
选哪个 parser
| Parser | 语言 | 速度 | 适用 |
|---|---|---|---|
| oxc | Rust | 极快 | 服务端(Rust 版) |
| acorn | JS | 快 | 浏览器端 / Node |
| babel | JS | 慢 | 浏览器端,生态丰富 |
| swc | Rust | 极快 | 服务端 |
本项目两个版本都在服务端 做 AST 改写:Rust 版用 oxc,Node 版用 acorn。另外还有一个更细的问题:eval 和 Function 构造器在浏览器端 动态生成代码,服务端改写不到------本项目把 oxc 编译成 WASM(rewrite.wasm)随拦截脚本下发,在浏览器里对动态代码做同样的 this 重写。
遍历范围
一个自然的想法是"有的 this 不用改":
js
// 箭头函数里的 this 继承外层,不独立求值
const fn = () => this.location // 外层的 this 改了,这里继承的就是改过的
// class 里的 this 通常是实例
class App {
constructor() { this.x = 1 } // this 是实例,不是 window
}
但实际实现里所有 ThisExpression 都统一改写,不做跳过。原因:
- 安全侧倾向 :改写是无损的------
this不是 window 时表达式原样返回this,业务逻辑不变;漏改一个才是真 bug - 箭头函数的坑 :箭头函数的
this继承外层,但外层改写后的值在运行时才确定,静态分析保证不了"外层一定已经改过"(比如外层是 eval 的代码);统一改写是唯一可靠策略 - 实现简单 :一遍遍历替换所有
ThisExpression,不需要作用域分析
代价是 this.x = 1 这种实例属性赋值多了一次 this === window 的判断,运行时开销可忽略。
性能
- AST 解析 + 改写:单文件 1-50ms(取决于大小)
- 大文件(webpack bundle 1MB+):约 5-10ms(oxc),acorn 稍慢
- 快速跳过:代码不含
this关键字时直接返回,不启动解析器;配合本地缓存开关,改写后的 JS 可落盘复用,二次访问零开销
服务端做 AST 改写,对响应延迟的影响可接受。
七、对比总结
| 方案 | this 穿透 | 字符串/注释误伤 | 每站规则 | 通用性 |
|---|---|---|---|---|
字符串替换 this.location → __location__ |
✓(部分) | ✗ 会误伤 | 需要 | 低 |
| 每站规则(商业方案) | ✓ | ✓ | 需要几千条 | 低 |
| AST ThisExpression 重写 | ✓ | ✓ 不误伤 | 0 条 | 高 |
核心结论:要实现真正的"通用全量代理",AST 改写是必经之路。字符串替换和每站规则都是治标。
八、延伸:不只是 Web 代理
this 穿透问题不只出现在 Web 代理。任何"在宿主环境里注入并劫持 JS 作用域"的场景都会遇到:
- 浏览器沙箱(micro-frontend、iframe 替代方案)
- JS 沙盒(code execution platform、低代码运行时)
- 注入式 SDK(埋点、监控、A/B 测试)
- 测试 mock(jsdom 之外的浏览器环境模拟)
- 小程序运行时(在自定义环境里跑业务代码)
只要你的方案是"劫持全局对象 + 包 with",就一定撞上 this 穿透。AST 重写是通用解。
九、代码开源
完整实现(Rust + oxc 与 Node + acorn 双服务端 AST 改写,浏览器端 WASM 处理 eval/Function,JS 客户端作用域劫持)已开源:
GitHub:github.com/Stardust100...
欢迎 Star、Issue。如果你在做 JS 沙箱 / 浏览器代理 / 注入式 SDK,强烈推荐试试 AST 这条路,比字符串替换和每站规则都靠谱。
项目仅用于学习研究,请确保在授权范围内使用,勿用于违反法律法规的用途。