` 接口,这就是为什么同一个构建版本能够同时兼容这两个主要版本,也是为什么该包无法固定你所使用的 Svelte 版本。`@relintio/browser-core` 是其唯一的运行时 dependency。
## 注册
在 `src/routes/+layout.svelte` 中**调用一次** `createRelintio`。该组件包裹了所有的路由,并且在客户端导航期间会保持存活,这正是 agent 所需要的:在其生命周期内对 `fetch` 进行封装,并且确保挑战 iframe 能够渲染并覆盖在当前挂载的任何页面之上。
页面组件并不是合适的位置。`+page.svelte` 在每次导航时都会被销毁并重新创建,因此每次都会在之前的封装器上再次封装 `fetch`,而在导航发生时如果正打开着挑战,该挑战会连同其挂起的请求一起被销毁。
`load` 函数同样不是合适的位置。`+layout.ts` 既会在服务端运行也会在客户端运行,而且它返回的 client 包含了定时器、监听器以及一个类实例——它无法跨越 SSR 的序列化边界。请在组件中创建它。
请与 `onDestroy(() => relintio.destroy())` 配合使用。没有其他地方会调用它:`destroy()` 会恢复原始的 `fetch` 并清理该 agent,这会导致任何未关闭的挑战被拒绝并抛出 `Relintio agent unmounted`。
### 服务端渲染
在 SSR 期间运行 `createRelintio` 是安全的,因为它在那里几乎什么都不做。它会检查 `typeof window`,并且在服务端会完全跳过 `fetch` 拦截——在 SvelteKit 的服务端渲染中封装 `fetch` 会包裹框架自身的请求管道——它会跳过 `verifyOnMount`,并为 store 提供一个 `subscribe`,该方法只会触发一次初始状态并返回一个空操作(no-op)的取消订阅函数。因此,`$challenge.isChallenging` 在服务端渲染的 HTML 中为 `false`,而客户端会在 hydration(水合)时恢复活力。
## 配置
`RelintioOptions` 是 core 包中 `RelintioConfig` 的扩展,并增加了 `interceptFetch`。
| 选项 | 默认值 | 含义 |
| --- | --- | --- |
| `publishableKey` | — | 必填项。必须以 `pk_` 开头。仅在浏览器端进行检查;详见下文。 |
| `apiUrl` | `https://api.relintio.com/v1` | 用于 staging 环境或自托管部署的覆盖配置。末尾的斜杠会被自动去除。 |
| `challengeTimeoutMs` | `120000` | 挑战在失败前保持开启的时长。无论你传入什么值,最低都会被限制在 10000。 |
| `verifyOnMount` | `false` | 在浏览器中创建客户端后,立即请求判定(verdict)。 |
| `fallbackUrl` | — | 在 `RelintioConfig` 中声明,用于无法向访问者展示挑战的情况。当前 core 的代码路径中并未读取该项。 |
| `interceptFetch` | `true` | 封装 `window.fetch`。传入确切的 `false` 可退出该行为。 |
### 密钥说明
这里只能使用 publishable key(`pk_live_…`)。它在设计上就是公开的——它会打包进你的客户端 bundle 中,每个访问者都可以读取它——并且它只能做一件事:就某个请求向 Relintio 请求判定(verdict)。它无法读取你的规则、写入遥测数据,也无法强制通过挑战。
你的 license key(`UP_LIVE_…`)是具有不同用途的不同凭证。它是用于签署挑战护照(challenge passports)和出站 agent 请求的 HMAC 密钥,因此任何持有它的人都可以在不受挑战的情况下直接穿过你的 WAF。它应当属于 `$env/static/private` 或你的进程环境变量,由服务端 agent 使用——绝不能放在 `$env/static/public` 中,绝不能放在 `+layout.svelte` 中,也绝不能放在任何 Vite 可以将其内联到浏览器 bundle 的地方。
**如果提供了 license key,agent 将拒绝启动并始终保持拒绝状态。** 密钥检查要求字符串以 `pk_` 开头;`UP_LIVE_…` 无法通过检查,因此 agent 会记录一条错误日志,说明 license key 绝不能出现在浏览器代码中,并报告自身不可用。在这种绑定情况下,这意味着 `interceptFetch` 永远不会被安装,`verifyOnMount` 永远不会触发,`verify()` 会解析为 `null`,而 `challenge()` 会立即解析完成——并且该密钥永远不会被发送到网络请求中。core 包的测试准确地断言了这一点,并且使用了一个如果被调用就会失败的 `fetch` stub。空白的密钥、`sk_live_…` 或仅仅是一个 `pk` 也都会以同样的方式被拒绝。
## Store 与 action
`state` 在普通 Svelte 意义上是一个 store:通过 `$` 进行订阅,它携带了 `isChallenging`、`challengeUrl`、`resolvedCount` 以及最后一次的 `verdict`。订阅时会立即发出当前值,因此不会出现未定义的第一帧。
`challengeFrame` 是一个 action,而不是一个组件。Relintio 不会在你的布局中注入固定位置的元素——那相当于依赖项将标记强行塞进你的设计系统中。由你来渲染这个 iframe、应用样式并放置它;`use:challengeFrame` 提供了行为逻辑。
`frameAttrs` 是 iframe 必须携带的属性:`sandbox="allow-forms allow-scripts allow-same-origin"`,`referrerpolicy="no-referrer"`,`title="Security check"`。这里没有设置 `allow-top-navigation`,因此挑战页面无法将访问者移出其正在保护的网站。
该 action 添加了一个 `message` 监听器,并在 `destroy` 时将其移除。只有当以下三个条件全部成立时,一条消息才会被算作通过:它的 origin 与当前屏幕上显示的挑战 URL 的 origin 相匹配;它的 `source` 是该 iframe 自身的 `contentWindow`;并且它的 `data` 是确切的字符串 `relintio_challenge_success`。仅凭 origin 匹配会让任何从该挑战 origin 提供服务的 frame 都能代表访问者完成挑战,而使用 `startsWith` 或进行 JSON 解析则会将决定权交给一个访问者可以影响的字符串。
## 请求时发生的事情
对于除了携带 `X-Relintio-Challenge` 的 `403` 响应之外的所有内容,封装后的 `fetch` 只是一个直接透传。当收到该响应时,来自该 header 的 URL 会进入 store 中,你的布局会渲染出 iframe,而正在等待的请求会被挂起。挑战通过后会精确地重放一次原始请求——即一次重试,绝不形成循环,因此如果再次遇到挑战,响应会被直接返回给你的代码,而不是陷入死循环。发生超时或组件被销毁时,则会原封不动地交回原始的 `403` 响应对象,因此 `response.status` 和响应体都是你服务端实际发送的内容。
`verify()` 是另一个调用,它是建议性的。它会向 `${apiUrl}/agent/decision` 发送 POST 请求,携带 `X-Agent-Version` header,请求体中包含了 domain、path、referrer、return URL、查询字符串中携带的任何 `up_token`、`agent_kind: "svelte"` 以及遥测数据。为了保持网络协议兼容性,请求体中的字段被命名为 `license_key`;其值实际上是你的 publishable key。该请求会在 5 秒后被放弃。如果判定(verdict)结果为 `challenge` 且带有 `challenge_url`,则会呈现出一个挑战;其他的操作信息则会作为普通信息进入 store 中。这里没有任何机制去强制执行判定,因为如果一个浏览器能够强制执行某一种判定,它就可以被指示去强制执行另一种不同的判定。强制执行应当由位于你源站(origin)的 agent 负责。
遥测数据来源于与挑战页面共享的采集器——包括屏幕和硬件特征、时区、语言、解析出的字体、canvas、WebGL 和 audio 摘要、网络提示、`navigator.webdriver` 标志以及行为计数器。这些计数器仅仅是发生次数和停留时间,绝对不含具体内容:即发生了多少次指针移动、按键、滚动和触摸,而不是输入了什么内容或停留在什么位置。每一个探测器都有独立的保护机制,并且 audio 探测会受到 120 毫秒预算的限制,因此即使 audio 堆栈挂起,也只会损失一个信号,而不会导致整个页面渲染失败。
## 一个引擎,五大绑定
这个包中不包含任何安全决策逻辑。密钥拒绝机制、对挑战 URL 的 `http`/`https` 限制、由三部分组成的 `postMessage` 检查、十秒钟的超时下限,以及所有的 fail-open(失败即放行)路径,全部位于 `@relintio/browser-core` 中。你在这里安装的仅仅是位于该协议之上的框架形态表面:将 `subscribe()` 呈现为 store,将监听器呈现为 action,将销毁过程呈现为可供 `onDestroy` 调用的函数。
另一种选择是为 React、Vue、Svelte、Angular 和 Expo 分别手写五套协议实现,而它们必定会在那些最不应该出现差异的地方产生分歧。将引擎保留在一个包中,意味着对于挑战处理的任何修复——无论是更严格的 origin 检查,还是修正超时时间——都只需发布一次,就能同时抵达所有的框架,而不是仅仅在各自 SDK 编写的那一天才正确。
## 当 Relintio 不可达时
一切照常运行,不会停止。在连接被拒绝、发生超时、返回非 2xx 状态码或遇到无法读取的响应体时,`verify()` 会返回 `null`,而不会抛出任何需要你的代码去捕获的异常。拦截器会返回你服务端自己的原始响应。无法被呈现或解决的挑战会直接释放其挂起的内容。这个 agent 中的每一条失败路径都被设计为 fail-open(失败放行):如果一个安全 agent 因为无法连接到其控制面而导致页面变成白屏,那就是把我们服务故障变成了你的故障,而这在两种故障中是更糟糕的一个。
## 边缘情况
**在服务端,密钥永远不会被检查。** 可用性的评估标准是“处于浏览器中 **且** 密钥有效”,这种短路机制意味着在 SSR 期间 license key 不会产生任何错误信息。该错误只会在 hydration 时出现在访问者的浏览器控制台中——而不在你的构建输出中,不在于你的服务端日志中,也不在于任何 CI 检查中。如果你想更早地捕获这个错误,请在你自己的配置加载逻辑中断言 `pk_` 前缀。
**被拒绝的密钥会为你提供一个安静地什么也不做的 client。** 不会抛出任何异常,并且每个方法依然存在:`$state` 只会发出一次初始值然后不再改变,`verify()` 解析为 `null`,`challenge()` 会立即解析完成,而 `fetch` 则保持原样不受影响。一个永远不发生变化的 store 加上一条控制台错误就是仅有的症状,所以在你去检查 store 之前,请先检查控制台。
**在 Svelte 5 组件中,请不要将该 store 命名为 `state`。** 在 Svelte 5 中,`$state` 是一个 rune,并且在开启了 runes 模式的组件中,编译器会将 `$state` 读取为 rune,而不是将其视为 store 的自动订阅。请像上面的示例那样在解构时重命名它——只要不是 `state`,叫什么名字都可以,而且无论叫什么,store 始终是同一个对象。
**该 action 必须用在显示 `challengeUrl` 的那个 iframe 上。** 通过校验的逻辑会将消息的 `source` 与绑定了该 action 的节点的 `node.contentWindow` 进行比对。如果你把 `use:challengeFrame` 放在了一个外层包裹的 `` 上,或者放在了另一个 iframe,那么所有的成功消息都会被拒绝:挑战界面将一直停留在屏幕上,直到 `challengeTimeoutMs` 超时——默认是两分钟——然后被挂起的请求会默默地以最初始的那个 `403` 状态解析。
**`destroy()` 需要由你来调用,没有任何其他地方会替你调用它。** 如果不写 `onDestroy` 那行代码,当一个布局被卸载后,`fetch` 仍会被一个没有任何 store 监听的 agent 所包裹。在只有一个布局的 SvelteKit 应用中,那个布局的生命周期和浏览器标签页一样长,所以实际成本为零;但在测试中、在嵌入式 widget 中,或者在任何根节点被反复挂载的地方,封装器就会不断堆积。
**同时发生的失败共享同一个挑战。** 几个同时被拒绝的请求会加入到一个正在等待的单一挑战中,而不是堆叠起多个 iframe。第一个 URL 就是呈现给用户的那个;后来的 URL 会被丢弃,并且所有被挂起的请求都会在通过后一起释放。
**重复调用 `verify()` 会发送过期的行为计数器。** 行为监控器在客户端创建时启动,并在第一次被读取时分离其监听器。第二次判定请求会携带在第一次读取时冻结的计数,而同时停留时间却还在不断增加。它对于挂载时的调用是准确的,但对之后的任何调用都会越来越显得陈旧。
**这里的 `Readable` 并不是 `svelte/store` 中的那个类型。** 它只声明了 `subscribe`,而这正是自动订阅契约所需要的全部内容。它满足了组件中以 `$` 为前缀的用法,但它并不是一个完整的 Svelte store:它没有 `set`,也没有 `update`,如果将它传递给一个签名中要求必须是 `svelte/store` 的 `Readable` 的辅助函数,将无法通过类型检查。
## 相关链接
- [文档](https://relintio.com/docs)
- [快速入门](https://relintio.com/docs/quickstart/svelte)
- [API 参考](https://relintio.com/docs/api-reference)
- [许可证](https://relintio.com/licenses)
安全报告请发送至 **support@relintio.com**,请勿提交至公开的 issue 中。
## 开源协议
MIT。详见 [`LICENSE`](./LICENSE)。
标签:Bot防护, SBOM分析, Svelte, SvelteKit, Syscall, Web开发, 人机验证, 暗色界面, 自动化攻击