Relintio/relintio-vue-agent
GitHub: Relintio/relintio-vue-agent
Relintio 的官方 Vue 3 插件,通过拦截 fetch 和组合式 API 为前端应用提供透明的人机验证挑战处理能力。
Stars: 0 | Forks: 0
这是一个位于你的组件和 `window.fetch` 之间的 Vue 插件。通过 `app.use(relintio, …)` 安装它,它会包装 `fetch`,因此当你的自有 API 以 `403` 和 `X-Relintio-Challenge` header 拒绝请求时,访问者会看到托管的 challenge,并且在通过后,被拒绝的请求会重试一次。`useRelintio()` 向组件提供响应式状态、`verify()` 和 `challenge()`;`useRelintioChallenge()` 向其提供 iframe 的连接逻辑。入口点是从 `@relintio/vue-agent` 导出的 `relintio` 插件对象,它应被放置在 `main.ts` 中。
```
// main.ts
import { createApp } from 'vue';
import { relintio } from '@relintio/vue-agent';
import App from './App.vue';
createApp(App)
.use(relintio, { publishableKey: 'pk_live_...' })
.mount('#app');
```
## 安装说明
```
npm install @relintio/vue-agent
```
Vue 3.3 或更高版本,作为 peer dependency 声明 — 该插件使用了 `onScopeDispose` 和 `shallowRef`,并且没有 Vue 2 构建版本。唯一的 runtime dependency 是 `@relintio/browser-core`,npm 会为你安装它。
## 注册
`app.use(relintio, { … })` 应放置在你准备挂载的 app 实例的 `main.ts` 中,且必须在 `.mount()` 之前执行。插件的 `install` 方法负责调用 `app.provide`,而 `inject` 只能访问在组件的 `setup` 运行之前就已提供的依赖。因此,如果在挂载之后安装插件,会导致所有已创建的组件都缺少 agent。如果你开启了 `verifyOnMount`,它同样会在 `install` 中触发 — 在第一个组件渲染之前执行,而非之后。
注册是针对每个 app 实例的。在一个页面上调用两次 `createApp` 会生成两个独立的 agent,除非你在两者上都安装了该插件;第二个 app 下的组件无法通过第一个 app 进行 inject。
该插件永远不会抛出异常。如果 key 被拒绝,`install` 会在提供任何内容之前返回,应用会在不受保护的情况下挂载,并在 console 中输出错误 — 因为我们的配置问题而导致客户的应用崩溃是更糟糕的结果。这种选择的代价可见于 [边界情况](#edge-cases)。
## 配置
`app.use` 的第二个参数是 `RelintioPluginOptions`:即 core 的 `RelintioConfig` 加上 `interceptFetch`。
| 选项 | 默认值 | 含义 |
| --- | --- | --- |
| `publishableKey` | — | 必填。必须以 `pk_` 开头。任何其他格式都会在数据离开浏览器之前被拒绝。 |
| `apiUrl` | `https://api.relintio.com/v1` | 用于 staging 或自托管的覆盖地址。构造时会自动去除一次末尾的斜杠。 |
| `challengeTimeoutMs` | `120000` | challenge 可以保持开启的时长。无论你设置得多低,最低都会被限制为 10000。 |
| `verifyOnMount` | `false` | 是否在安装时请求判定。除非前端是你唯一可控的层面,否则请保持关闭。 |
| `fallbackUrl` | — | 在 `RelintioConfig` 上声明,用于无法向访问者展示 challenge 的情况。当前的 core 中没有任何逻辑会读取它;目前设置它不会改变任何行为。 |
| `interceptFetch` | `true` | 包装 `globalThis.fetch`。只有字面量值 `false` 才会关闭此功能。 |
### 应该使用哪个 key
这段代码运行在访问者的浏览器中,因此无论它持有什么 key,加载该页面的所有人都能读取到。该 key 必须是 **publishable key**(`pk_live_…`)。publishable key 在设计上就是公开的,并且只具备一种能力:它可以向 Relintio 请求判定结果。它无法读取你的规则、写入 telemetry,或导致 challenge 被通过。
**licence key**(`UP_LIVE_…`)是用于 challenge passport 和请求签名的 HMAC key。任何持有它的人都可以自行伪造通过你 WAF 的通行凭证。它属于你的服务器、edge 或适配器包,绝对不能被粘贴到 `main.ts`、被 Vite 内联到 bundle 中的 `.env` 文件,或任何其他会被发送到浏览器的内容中。
**如果传入了 licence key,agent 将拒绝启动。** `isUsable()` 会检查该 key 是否是以 `pk_` 开头的字符串;而 licence key 并不符合,因此它会输出一个指明该错误的错误日志并返回 `false`,随后 `install` 会就此停止。此时不会发起任何判定请求,key 也永远不会被传输 — `verify()` 会直接返回 `null` 而完全不调用 `fetch`,core 的测试套件正是通过一个记录自身是否被调用过的 stubbed `fetch` 来断言这一点的。对于空字符串、`undefined`、单纯的 `pk`,或是以 `sk_` 开头的 secret key,处理逻辑也是一样的。
## 组件中的 challenge
`useRelintioChallenge()` 返回状态、一个 template ref 以及 iframe 必须携带的 attributes。它特意返回数据而不是组件:由依赖注入的固定定位 iframe 在别人的设计系统中就是一个布局 bug。markup 和样式应由你自己决定。
```
```
`attrs` 包含了 `sandbox="allow-forms allow-scripts allow-same-origin"`、`referrerpolicy="no-referrer"` 和 `title="Security check"`。其中没有 `allow-top-navigation`:challenge 页面绝对不能有能力将访问者移出它正在保护的站点。
该 composable 会向 `window` 附加一个 `message` 监听器,并通过 `onScopeDispose` 将其移除。每个事件都会经过 `agent.isChallengeSuccess(event, frame.value?.contentWindow)` 处理,该函数要求同时满足三个条件 — 事件的 origin 必须等于当前屏幕上显示的 challenge URL 的 origin,其 `source` 必须是那个特定 iframe 的 `contentWindow`,并且其 `data` 必须是确切的字符串 `relintio_challenge_success`。如果只验证 origin,challenge origin 上的任何 frame 都可以为访问者通过 challenge;如果允许前缀匹配或进行 JSON 解析,访问者自己的页面就能自行决定是否通过了 challenge。
## 请求时会发生什么
当开启 `interceptFetch` 时,应用中的每个 `fetch` 都会经过这个包装器。非 `403` 的响应,或者没有 `X-Relintio-Challenge` header 的 `403` 响应,将被原封不动地返回。一个带有该 header 的 `403` 会将 challenge URL 显示在屏幕上并等待其完成:如果访问者通过了 challenge,原始请求将被精确地重放一次 — 永远不会形成死循环,因此如果重试的请求再次被 challenge,它将被交由你的代码来决定如何处理。如果 challenge 超时或 agent 被销毁,调用者将收到原始的 `403` 对象,而不是一个合成的对象,因此你的错误处理逻辑能看到服务器实际返回的内容。
`verify()` 是另一条路径,它是建议性的而非强制性的。它会向 `${apiUrl}/agent/decision` 发起 POST 请求并附带一个 `X-Agent-Version` header,携带 domain、path、referrer、return URL、访问者携带的 `up_token` query parameter(如果有的话)、`agent_kind: "vue"` 以及收集到的 telemetry。请求体中的字段名为 `license_key` 以保证 wire compatibility;但其中的值是你的 publishable key。该调用会在 5 秒后被中止。如果判定结果为 `challenge` 并附带 `challenge_url`,则会展示 challenge;其他任何动作都只是你可以读取的状态,而不是这个 package 会强制执行的。强制执行位于你的 origin 处,在那里,client 是无法被告知做出不同决定的。
Telemetry 由共享的收集器采集:屏幕和硬件特征、时区、语言、解析到的字体、canvas、WebGL 和 audio 摘要、网络提示、`navigator.webdriver` 标志以及行为计数器。这些计数器仅记录次数 — 鼠标指针移动、按键、滚动和触摸的次数,以及访问者在页面上停留的时长。绝对不会记录输入的内容或鼠标指针移动的轨迹;任何更多的信息都会使它变成针对他人结账流程的键盘记录器。每个探测点都有独立的保护措施,audio 探测被设置了一个 120 ms 的执行预算进行竞速而不是单纯等待,因此,即使浏览器的 audio stack 挂起,其代价也只是丢失一个信号,而不会导致页面加载延迟。
## 协议的所在之处
这个 package 中的任何内容都不会做出安全决策。publishable key 的拒绝、对 challenge URL 的 `http`/`https` 检查、由三部分组成的 `postMessage` 验证、十秒的超时下限以及所有的 fail-open 路径都位于 `@relintio/browser-core` 中,而这个文件只是用了几十行代码将 `subscribe()` 转换成了 `shallowRef`,将 `provide`/`inject` 转换成了 composable,并将 template ref 转换成了 `postMessage` 检查时要对比的 frame 身份。
这就是进行这种拆分的全部理由。React、Vue、Svelte、Angular 和 Expo 本来会成为同一个协议的五个纯手写副本,而这些规则恰恰是它们之间绝对不能存在差异的地方。因为引擎掌握了这些规则,所以针对 challenge 处理的修复会在每一个框架绑定中同时生效,只需发布一个 package 的一个版本,而不是按照五个时间表发布五个 package。
## 当 Relintio 不可达时
你的应用将继续工作。`verify()` 会吞掉拒绝连接、超时、非 2xx 响应和无法解析的响应体,并统一返回 `null`;interceptor 会原样返回你的服务器发送的响应;无法解决的 challenge 会释放原始响应而不是进行阻塞。这个 agent 中故意没有任何 fail closed 的路径:一个 security agent 如果因为无法连接到自己的 control plane 就导致页面白屏,那就是把我们的故障变成了你的故障,这比它所要防范的故障还要糟糕。
## 边界情况
**被拒绝的 key 会表现为在别处抛出的错误。** `install` 会在 `app.provide` 之前返回,因此 injection key 永远不会被注册,第一个调用 `useRelintio()` 的组件就会抛出 `useRelintio() was called without the plugin installed`。该错误信息指向了你的连接逻辑;而实际原因是 key 检查在片刻之前输出的 `console.error`。如果你在一个 `main.ts` 中明明调用了 `app.use(relintio, …)` 的应用中看到了这个异常,在修改连接逻辑之前,请先阅读它上面的那行 console 输出。
**`useRelintioChallenge()` 必须在 `setup` 期间调用。** 监听器的清理是通过 `onScopeDispose` 注册的,它只会在活跃的 effect scope 中被附加。如果你在模块顶层、事件处理程序中,或者在 `await` 之后的 `async` 延续操作中调用该 composable,`message` 监听器虽然被添加到了 `window` 上,但没有任何机制来移除它 — 每次调用都会导致一个泄漏的监听器,伴随页面的整个生命周期。
**忘记写 `ref="frame"` 会让访问者白等两分钟。** `isChallengeSuccess` 会将 `event.source` 与 `frame.value?.contentWindow` 进行比对。未绑定的 ref 会导致比对值为 `undefined`,使得每一个成功消息都被拒绝,并且 challenge 会一直停留在屏幕上,直到 `challengeTimeoutMs` 耗尽 — 默认时间为两分钟 — 随后挂起的请求会以原始的 `403` 状态 resolve。期间不会记录任何日志。其状态永远无法离开 `isChallenging`。
**`fetch` 是全局包装的,而不是针对每个 app。** interceptor 会替换 `globalThis.fetch`,而恢复函数会重新赋值为该 app 安装时所捕获到的引用。如果在同一个页面上有两个 Vue app,卸载第一个 app 会恢复为 pre-Relintio 的 `fetch`,并无声地丢弃第二个 app 的包装器。如果你挂载了多个 app,请只在其中一个上安装该插件。
**销毁逻辑依赖于 `app.unmount`。** `install` 包装了 `app.unmount` 以恢复 `fetch`、丢弃状态订阅并销毁 agent。一个永远不会被卸载的 app — 即大多数生产环境下的 app — 永远不会运行这些逻辑,这没问题。但在 HMR 环境下,或者在循环挂载并丢弃 app 的测试中,`unmount` 是防止包装器堆积的关键,所以请务必调用它。
**第二次调用 `verify()` 报告的是冻结的行为计数器。** 行为监视器是在 agent 构造时启动一次的,它的 `snapshot()` 在第一次被读取时就会分离所有的监听器。因此,第二次判定请求所携带的计数与第一次请求时的状态保持一致,只有 dwell time 会继续增长。这仅在你重复调用 `verify()` 时才会有影响;在 mount 时调用的那次读取的是活跃的监视器。
**`relintio` 已被赋值但未定义类型。** `install` 会将 API 赋值给 `app.config.globalProperties.$relintio`,但该 package 并没有为它提供 `ComponentCustomProperties` 的类型增强。在 Options API 和模板中,它在 runtime 下可以正常工作;但在 `vue-tsc` 下,它会是一个未知属性。建议优先使用已定义类型的 `useRelintio()`。
**并发失败会产生一个 challenge。** 同时被拒绝的三个请求会合并为一个挂起的 challenge 和一个 iframe;第一个请求的 URL 会胜出,后续的 URL 会被丢弃。当访问者通过 challenge 后,这三个请求会一起被释放。
## 链接
- [文档](https://relintio.com/docs)
- [快速入门](https://relintio.com/docs/quickstart/vue)
- [API 参考](https://relintio.com/docs/api-reference)
- [许可证](https://relintio.com/licenses)
安全报告请发送至 **support@relintio.com**,请勿提交至公开的 issue 中。
## License
MIT。详见 [`LICENSE`](./LICENSE)。
标签:Fetch拦截, npm包, Vue 3, 人机验证, 插件, 暗色界面, 自动化攻击, 请求重试