返回知识中心
指纹

代理陷阱与运行时强制的不变量

一次固定陷阱探针:哪些陷阱会触发,哪些看似合理的应答被运行时以 TypeError 拒绝,以及已撤销代理和不可调用目标会怎样。

BotBrowser Team

文档中心

想直接进入 指纹 文档吗?

这篇文章属于知识中心的指南库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

BotBrowser 支持在声明的 Profile 下重放经授权的同源 fixture,并保留范围受限的记录:页面安装了哪些陷阱、引擎拒绝了哪些应答;但它不能使代理破坏引擎强制的不变量,不能让已撤销的代理继续可用,也不能把对不可调用目标的陷阱变成一次调用。本文用一个探针覆盖九个固定陷阱,记录普通读取触发哪个陷阱、六个会以 TypeError 拒绝看似合理陷阱应答的不变量、两个在目标允许时真正改写值的陷阱、已撤销的代理对每次访问做了什么,以及为什么对象目标无法应答一次调用。

摘要

  • 普通属性读取只触发 get 陷阱,所以探针打印 order=get:a,没有描述符查找。
  • 对一个不可扩展目标报告比它更少的键会抛 TypeError,所以陷阱无法隐藏目标保留的属性。
  • 报告一个目标并不拥有的不可配置属性会抛 TypeError,所以陷阱无法凭空造出锁定的属性。
  • 对冻结属性从 set 返回 true 会抛 TypeError,并让目标在 1 处保持不变。
  • 一个把写入转发到目标的 set 陷阱确实会改变它,所以探针在把 21 翻倍后打印 target=42。
  • 改变可扩展目标的原型是允许的,而对不可扩展目标执行同样的陷阱会抛 TypeError。
  • 已撤销的代理在读取、in 检查和 Object.keys 上都会抛 TypeError。
  • apply 陷阱把结果翻倍成 50,而同样的陷阱用在对象目标上会抛 TypeError,因为目标不可调用。
  • deleteProperty 陷阱无法把一个不可配置属性报告为已删除,属性仍然存在。

目录

第一个面板显示一次普通读取只触发 get 陷阱。第二个列出当陷阱应答与非扩展或冻结目标相矛盾时会抛 TypeError 的那些不变量。第三个显示 set 陷阱把写入值翻倍成 42,而对冻结属性的受陷写入会抛错。第四个显示已撤销的代理在每次访问时抛错,以及不可调用目标拒绝 apply 陷阱。

普通读取触发哪个陷阱

代理把操作转发到陷阱,每个操作只会到达一组特定的陷阱。探针读取一个属性并记录每个运行过的陷阱:

node=v26.9.0
read-order -> value=1 order=get:a
ownKeys-invariant -> TypeError
gopd-invariant -> TypeError
set-invariant -> TypeError targetValue=1
set-doubled -> target=42
prototype-invariant -> extensible=allowed non-extensible=TypeError
revoked -> TypeError,TypeError,TypeError
apply-trap -> result=50 object-target=TypeError
delete-invariant -> TypeError stillThere=true

只有 get 触发了。这一点很重要,因为一个通过统计 getOwnPropertyDescriptor 调用来探测读取的页面什么也数不到,而一个期望每次读取都运行 has 的页面会得到一个意外。每个操作都一对一地映射到它的陷阱,而读取是最窄的那个。

同样的映射决定了检测检查观察到什么。一个把普通对象与代理比较的指纹探针,往往通过触发强制某陷阱的操作来做到这一点,而单次读取只会强制出一个;描述符查找强制出另一个,探针的那一行显示两者并非由同一条语句同时触发。这就是为什么一个能应答读取的代理,对一个只做读取的检查来说仍可能看起来像普通对象。

代理所过滤的属性顺序,就是 对象键顺序 为普通对象描述的那个顺序。完整映射是有限的,值得记住,因为每一条都决定了某条给定语句会到达哪个陷阱。get 服务于属性读取,set 服务于赋值,has 服务于 in 运算符,deleteProperty 服务于 delete,ownKeys 服务于 Object.keys 和 Reflect.ownKeys,getOwnPropertyDescriptor 服务于 Object.getOwnPropertyDescriptor 以及 Object.keys 在 ownKeys 之前执行的内部查找,defineProperty 服务于 Object.defineProperty,getPrototypeOf 和 setPrototypeOf 服务于两个原型操作,isExtensible 和 preventExtensions 服务于两个可扩展性操作,apply 和 construct 服务于对可调用目标的一次调用和一个 new。一个期望一条语句触发一个陷阱的页面,只有当那条语句恰好在列表上时才是对的。

其中两条成对出现,于是产生一个顺序而不是单次调用。Object.keys 先调用 ownKeys 取得候选键列表,然后对它打算保留的每个键调用 getOwnPropertyDescriptor,以检查该属性是否可枚举。因此一个应答 ownKeys 但不应答 getOwnPropertyDescriptor 的代理仍然工作,因为默认的描述符陷阱会转发到目标。一个对它在 ownKeys 中列出的键、用不可枚举描述符应答 getOwnPropertyDescriptor 的代理,会静默地把该键从结果中丢掉,而一个通过统计 ownKeys 调用次数来推断代理暴露多少属性的页面,会正好多算它过滤掉的那些键。

同样的成对现象出现在比较代理开销与普通对象开销的基准测试中。每个被插桩的操作至少花费一次陷阱调用加上陷阱本身所做的事,而成对的操作在一条语句里花费两次陷阱调用,这就是为什么在一个 Object.keys 热循环上包一层代理,明显比在读取热循环上包一层慢。探针无法在九个操作上计时这一点,而两种情况下顺序完全相同,所以开销差异是映射本身的属性,而不是任何特定陷阱体的属性。

为什么引擎拒绝看似合理的陷阱应答

陷阱可能返回一个与目标相矛盾的值,而引擎强制一组固定的不变量来拒绝这些应答。探针驱动其中六个,每一个都抛 TypeError:

ownKeys-invariant -> TypeError
gopd-invariant -> TypeError

第一种情况是一个有两个键的不可扩展目标,而 ownKeys 陷阱只报告一个,引擎拒绝让陷阱隐藏目标仍然拥有的键。第二种情况里,一个陷阱报告目标并不拥有的不可配置属性,引擎拒绝让陷阱凭空造出锁定的属性。两个应答单独看都合理,而两者恰恰都是不变量要捕捉的那种错配。

第三种情况会让一个缓存代理翻车。一个冻结目标加上返回 true 的 set 陷阱会抛错,而目标保持不变:

set-invariant -> TypeError targetValue=1

陷阱声称成功,而不变量检查的断言是目标自身的状态是否一致。一个把冻结的配置对象包进日志代理、并始终从 set 返回 true 的页面,会在第一次尝试写入时得到抛错,而不是被静默忽略,这才是正确结果,也往往是页面作者没有预料到的结果。

第四对关乎身份与删除:

prototype-invariant -> extensible=allowed non-extensible=TypeError
delete-invariant -> TypeError stillThere=true

改变一个可扩展目标所报告的原型是允许的,因为目标仍能随之改变;对不可扩展目标执行同样的陷阱会抛错,因为目标再也无法移动到所声称的原型。通过一个返回 true 的陷阱删除不可配置属性会抛错,因为目标仍然拥有该属性。两种情况里,不变量是同一个想法换了不同的键:陷阱不得报告目标自身特性所禁止的对象状态。

六个不变量背后的一般规则值得说一次:代理是过滤器,不是替代品。引擎把每个陷阱应答与目标自身的属性特性进行比对,任何会要求目标违反这些特性的应答都被拒绝。这就是为什么不能用代理假装一个冻结对象可变,或假装一个密封对象有更少的键,或假装某个属性既存在又不可配置而目标根本没有这样的属性。

还有第五个不变量,探针通过 set 演练,另有一个最容易与它成对来看。set 不变量有两半:陷阱不得对一个目标标记为不可写的属性报告成功,也不得对一个目标并不拥有、同时目标不可扩展的属性报告成功。探针的冻结情形是前半。后半出现在一个包在密封对象上的代理的 set 陷阱对一个全新键返回 true 时:目标无法增长,所以引擎即使陷阱从未碰过它也会抛错。一个用 Reflect.set 转发每次写入的日志代理则两者都遇不到,因为 Reflect.set 对两者都返回 false,陷阱的 true 就会与它矛盾。

让所有这些不变量都可靠的一条规律是,Reflect 返回的正是不变量所期望的应答。Reflect.ownKeys(target) 返回目标自己的键,Reflect.getOwnPropertyDescriptor(target, key) 返回目标自己的描述符或 undefined,Reflect.set(target, key, value) 返回目标是否接受了写入,Reflect.deleteProperty(target, key) 返回删除是否成功。因此一个返回 Reflect 结果的陷阱永远不会与目标冲突,这就是为什么惯用的转发代理是每个陷阱一个瘦 Reflect 调用,也是为什么不变量往往在一个返回手写字面量的陷阱上咬人。

第六个不变量关乎原型,探针的两个情形精确地展示了形状。只要目标可扩展,陷阱可以报告任意原型,因为目标仍能移动以匹配,而引擎甚至在目标移动之前就允许这一报告。一旦目标变得不可扩展,所报告的原型就必须等于目标的实际原型,因为目标再也无法移动。因此一个从陷阱返回固定原型的页面会一直工作,直到某个环节冻结目标,这就是为什么失败出现得很晚,且只出现在密封对象的那条代码路径上。

陷阱在哪里真正改写值

探针的两个陷阱不只是观察,两者都被允许,因为目标支持这一改变:

set-doubled -> target=42
apply-trap -> result=50 object-target=TypeError

set 陷阱把写入的 21 翻倍成目标上的 42,写入成功,因为目标是普通且可扩展的。像这样的改写陷阱是代理计算派生值的常规方式,不变量并不反对,因为陷阱让目标与其自身特性保持一致。apply 陷阱把一次调用的结果乘以十,所以用 2 和 3 调用代理返回 50 而不是 5,同样是因为可调用目标让这次改写合法。

那一行的后半是限制。同样的 apply 陷阱用在普通对象目标上会抛 TypeError,因为只有当目标可调用时代理才可调用,而对象没有 [[Call]] 可以转发。一个想要可调用代理的页面必须从函数出发,这是一个小小的结构性约束,而一个从对象构建的代理会静默地违反它。

construct 陷阱有同样的要求,外加一条额外规则。包在函数上的代理可以通过 construct 陷阱服务于 new,而陷阱必须返回一个对象,因为构造调用必须产出一个对象。返回原始值的陷阱会抛 TypeError,这是构造一侧、对应应用到一侧“目标必须可调用”要求的孪生规则。一个把类包进代理以拦截构造的页面必须让陷阱返回实例,而忘记这么做的页面会返回 undefined 并得到抛错,而不是一个静默的实例。

一个返回完全不同对象的改写是合法的,代理正是在这里不再透明。一个为数组返回新包装器的 get 陷阱仍然满足不变量,因为 get 没有必须匹配的特性,而调用方收到的是包装器而不是存储的数组。一个比较 proxy.items === target.items 的页面随后得到 false,而一个期望两次读取之间身份一致的页面,除非陷阱缓存它们,否则会得到两个不同的包装器。不变量允许这一切,这就是为什么对代理行为的记录必须写明陷阱返回什么,而不是假定它会转发。

已撤销的代理与不可调用目标会怎样

一个被使用时才失效的代理,与一个自己丢弃条目的集合有同样的弱点:弱集合与内存保留 说明页面如何失去对它以为仍持有的值的访问。Proxy.revocable 返回一个代理和一个撤销函数,撤销代理会让每个操作都失败:

revoked -> TypeError,TypeError,TypeError

属性读取、in 检查和 Object.keys 调用在撤销之后各自抛出 TypeError。这是比删除引用更强的失败,因为一个保留着代理活动引用的页面仍然无法使用它。这也是为什么已撤销的代理是关闭一项能力的有用方式:消费方持有句柄,而生产方撤销时句柄就停止工作,无需消费方配合。

撤销也是不可逆的,撤销函数是幂等的。第二次调用 revoke() 什么也不做,也不抛错,所以一个用标志来守护理清工作的页面,除了更清晰之外并无收益。一个在撤销后试图用同一个目标重新创建代理的页面会得到一个全新的、独立的代理,因为撤销附着于代理对象而非目标,这就是为什么已撤销的代理无法通过再次包装同一目标来复活。

有两个细节让已撤销的代理难以被检查出来。第一,对已撤销的可调用代理执行 typeof 仍报告 function,所以一个只测试 typeof 的检查会通过一个已撤销的函数代理。第二,抛错发生在使用时而不是构造时,所以一个在初始化期间构建代理、稍后才使用的页面,会在离引发它的撤销很远的地方看到失败。两者是同一个设计:撤销是一种运行时状态,而不是结构状态。

一个想检测代理而不是使用代理的页面任务更难,而不变量正是原因。代理没有自己的自有属性,所以一个读取 Object.getOwnPropertyNames(proxy) 的结构检查看到的是目标的名称,而一个读取 Object.getPrototypeOf(proxy) 的检查看到的是目标的原型,除非陷阱报告另一个。唯一能留下来的信号是陷阱自身的行为:一个返回目标无法产生的值的陷阱是唯一可观察的痕迹,而这恰恰是不变量所约束的,也是为什么一个写得好的转发代理几乎不留痕迹。

记录一次代理读数

一份代理读数支持一个很窄的断言:这个运行时、这些陷阱、这个目标,产生了这些观察。四个字段让它可核查:

  1. 访问类型,因为每种操作都对应自己的一条陷阱。
  2. 目标自身的特性,因为冻结或不可扩展的目标会改变哪些陷阱应答合法。
  3. 陷阱应答,因为不变量是把该应答与目标比对,而不是直接接受。
  4. 代理是否被撤销、其目标是否可调用,因为两者都会把失败变成使用时的一次 TypeError。

公开来源

实践结论

把代理当作对真实目标的过滤器:每个陷阱应答都会与目标自身的特性比对,不一致的应答会在使用时抛 TypeError。当一份记录描述代理时,写明操作,因为读取和描述符查找到达不同的陷阱,只有其中一个可能触发过。可调用代理使用函数目标,属性代理只用普通对象,并记住已撤销的代理会保留它的 typeof,只在被使用时失败。关于代理过滤其属性的那个对象,阅读 对象键顺序;关于代理透传的复制语义,阅读 structuredClone 与 JSON。

#代理#Reflect#不变量#ownKeys#可撤销代理

本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界

让 BotBrowser 从研究走向生产

先用这些指南理解模型,再进入跨平台验证、隔离上下文和面向规模化的浏览器部署。