用户脚本、社区补丁或扩展程序修改你的应用的方式,是匹配你发布的 JavaScript 中的文本并把它改写掉。只要在每次发布时改变可供匹配的内容,补丁就不再生效。
补丁锚定在什么上
打补丁的人需要一个稳定的锚点:一个函数名、一个字面量字符串、一种能在下一次下载的文件中再次找到的可识别形态。压缩会把这三者都原样保留。压缩后的 bundle 仍然有函数名(虽然缩短了,但在不同构建之间保持一致)、同样的字符串字面量,以及同样的整体结构,所以针对某一次发布编写的补丁会继续匹配下一次。
这就是为什么一个好的补丁能存活好几个月。作者针对你当前的构建测试它,把它分发给自己的用户,而只要某次发布没有动它的锚点,它就能一直有效。
结构性变化会消除补丁所匹配的形态
仅靠改名只会让锚点换个位置。从 medium 起,AfterPack 重写的是程序本身:控制流被扁平化并重新排序,值在每次使用时重新计算而不是被存储,原本相互独立的代码在输出中彼此纠缠。交付出去的是你所写代码的一个功能等价物,一个行为相同的不同程序,由这次构建的 seed 选定。
所以一个过去会往 checkAccess 中插入一行的补丁,必须先找到执行 checkAccess 原来那些工作的代码。它不再是一个叫这个名字的函数,也不再是一个具有那种形态的函数。AfterPack 的工作原理介绍了其中的机制。
打补丁的人 grep 的字符串不复存在
在任何正数的 complexity 目标下,strings.encode 都是开启的,因此你 JavaScript 中的字符串字面量在产物中不是文本。功能名称、消息类型、API 路径、权益字符串、门控关闭时显示的文案:对已发布的文件执行 grep,一个都找不到,针对其中任何一个编写的查找替换也同样找不到。
这些字面量是补丁能利用的最廉价的锚点,因为它们经过压缩后原封不动,而且读起来就像是在说明周围代码在做什么。编码正是构建中消除它们的那一部分。
每次发布都会再次移动锚点
除非你固定 seed,否则它在每次构建时都是新的,因此名称、编码和形态都与你的上一次发布不同。于是对维护补丁的人来说,补丁变成了一笔反复发生的代价:针对你发布的每一个构建都要重做一遍,而已经分发给用户的脚本在你部署的那一刻就会失效。
在打补丁的人下手的地方提高保护
medium 会提高整个构建的保护:
{
"preset": "medium"
}对于补丁真正瞄准的代码,比如权益校验,提高到 hard:
$ npx afterpack@latest dist/ --preset=hard让生产环境保持随机种子。固定不变的种子,正是让同一个补丁能在一次又一次发布中持续匹配的原因;种子策略介绍了哪些情况下固定种子是安全的。如果你很少发布,就按计划重新构建,让形态照样轮换;参见按计划轮换。
确认锚点已经消失
在你即将部署的产物中搜索补丁会瞄准的名称和字符串。应该什么都搜不到。
打开 Protection Map(在 protectionMap.enabled 开启时生成),确认补丁会下手的代码在你预期的复杂度上被覆盖到了。然后把 verify 作为部署前的最后一步运行:构建会在其输出中写入一份保护凭据,verify 会重新计算凭据中列出的每个文件的哈希,只要有一个不再匹配就会失败。
这不能阻止什么
打补丁不会停止。阅读新构建的人可以针对它编写新的补丁,而构建中的任何东西都无法阻止扩展程序在其所有者控制的浏览器中修改你的页面。
锚定在 DOM 而不是你的 JavaScript 上的用户脚本(匹配 CSS 类、元素结构或可见文本),不受 AfterPack 对 bundle 所做处理的影响。改变的是每一个需要查找代码的补丁:它的作者每次发布都要重新付出这份工作,而他的用户在拿到更新之前运行的都是一个失效的脚本。