# 阻止用户脚本和扩展程序修补你的 Web 应用

用户脚本、Tampermonkey 补丁或扩展程序，都必须锚定在你 JavaScript 中的某个名称、某个字符串或某种可识别的形态上。结构性混淆和每次构建的新形态会对这些锚点产生什么影响。

Source: https://www.afterpack.dev/zh/docs/use-cases/patching-tools

用户脚本、社区补丁或扩展程序修改你的应用的方式，是匹配你发布的 JavaScript 中的文本并把它改写掉。只要在每次发布时改变可供匹配的内容，补丁就不再生效。

## 补丁锚定在什么上

打补丁的人需要一个稳定的锚点：一个函数名、一个字面量字符串、一种能在下一次下载的文件中再次找到的可识别形态。压缩会把这三者都原样保留。压缩后的 bundle 仍然有函数名（虽然缩短了，但在不同构建之间保持一致）、同样的字符串字面量，以及同样的整体结构，所以针对某一次发布编写的补丁会继续匹配下一次。

这就是为什么一个好的补丁能存活好几个月。作者针对你当前的构建测试它，把它分发给自己的用户，而只要某次发布没有动它的锚点，它就能一直有效。

## 结构性变化会消除补丁所匹配的形态

仅靠改名只会让锚点换个位置。从 `medium` 起，AfterPack 重写的是程序本身：控制流被扁平化并重新排序，值在每次使用时重新计算而不是被存储，原本相互独立的代码在输出中彼此纠缠。交付出去的是你所写代码的一个功能等价物，一个[行为相同](https://www.afterpack.dev/zh/docs/semantic-contract)的不同程序，由这次构建的 [`seed`](https://www.afterpack.dev/zh/docs/config#seed) 选定。

所以一个过去会往 `checkAccess` 中插入一行的补丁，必须先找到执行 `checkAccess` 原来那些工作的代码。它不再是一个叫这个名字的函数，也不再是一个具有那种形态的函数。[AfterPack 的工作原理](https://www.afterpack.dev/zh/docs/concepts#a-new-program-every-build)介绍了其中的机制。

## 打补丁的人 grep 的字符串不复存在

在任何正数的 [`complexity`](https://www.afterpack.dev/zh/docs/config#complexity) 目标下，[`strings.encode`](https://www.afterpack.dev/zh/docs/config#strings-encode) 都是开启的，因此你 JavaScript 中的字符串字面量在产物中不是文本。功能名称、消息类型、API 路径、权益字符串、门控关闭时显示的文案：对已发布的文件执行 `grep`，一个都找不到，针对其中任何一个编写的查找替换也同样找不到。

这些字面量是补丁能利用的最廉价的锚点，因为它们经过压缩后原封不动，而且读起来就像是在说明周围代码在做什么。编码正是构建中消除它们的那一部分。

## 每次发布都会再次移动锚点

除非你固定 [`seed`](https://www.afterpack.dev/zh/docs/config#seed)，否则它在每次构建时都是新的，因此名称、编码和形态都与你的上一次发布不同。于是对维护补丁的人来说，补丁变成了一笔反复发生的代价：针对你发布的每一个构建都要重做一遍，而已经分发给用户的脚本在你部署的那一刻就会失效。

## 在打补丁的人下手的地方提高保护

`medium` 会提高整个构建的保护。它也会使页面启动时间成倍增加（[加载代价](https://www.afterpack.dev/zh/docs/presets#load-cost)），因此在发布之前请先实测你的页面：

```json
{
  "preset": "medium"
}
```

在 [Pro](https://www.afterpack.dev/zh/docs/tiers) 上，用[指令](https://www.afterpack.dev/zh/docs/directives)只把 `hard` 用在补丁瞄准的代码上，比如权益校验：

```ts
/* @afterpack preset=hard */
function checkEntitlement(token: string): boolean {
  return validateSubscription(token);
}
/* @afterpack end */
```

让生产环境保持随机种子。固定不变的种子，正是让同一个补丁能在一次又一次发布中持续匹配的原因；[种子策略](https://www.afterpack.dev/zh/docs/best-practices#seed-strategy)介绍了哪些情况下固定种子是安全的。如果你很少发布，就按计划重新构建，让形态照样轮换；参见[按计划轮换](https://www.afterpack.dev/zh/docs/builds#rotate-on-a-schedule)。

## 确认锚点已经消失

在你即将部署的产物中搜索补丁会瞄准的名称和字符串。应该什么都搜不到。

打开 [Protection Map](https://www.afterpack.dev/zh/docs/protection-map#how-to-read-it)（在 [`protectionMap.enabled`](https://www.afterpack.dev/zh/docs/config#protectionMap-enabled) 开启时生成），确认补丁会下手的代码在你预期的复杂度上被覆盖到了。然后把 [`verify`](https://www.afterpack.dev/zh/docs/cli#afterpack-verify-dir) 作为部署前的最后一步运行：构建会在其输出中写入一份保护凭据，`verify` 会重新计算凭据中列出的每个文件的哈希，只要有一个不再匹配就会失败。

## 这不能阻止什么

打补丁不会停止。阅读新构建的人可以针对它编写新的补丁，而构建中的任何东西都无法阻止扩展程序在其所有者控制的浏览器中修改你的页面。

锚定在 DOM 而不是你的 JavaScript 上的用户脚本（匹配 CSS 类、元素结构或可见文本），不受 AfterPack 对 bundle 所做处理的影响。改变的是每一个需要查找代码的补丁：它的作者每次发布都要重新付出这份工作，而他的用户在拿到更新之前运行的都是一个失效的脚本。

## 下一步

- [构建与 CI](https://www.afterpack.dev/zh/docs/builds)：按计划重新构建，并在生产环境中保持随机种子。
- [最佳实践](https://www.afterpack.dev/zh/docs/best-practices#seed-strategy)：完整的种子策略，包括何时固定种子是安全的。
- [保护付费墙校验](https://www.afterpack.dev/zh/docs/use-cases/paywall-checks)：同样的机制应用于权益门控。
