Relintio/relintio-angular-agent
GitHub: Relintio/relintio-angular-agent
Relintio Angular Agent 是一个 HttpClient 拦截器与服务模块,在 Angular 应用中自动响应服务端下发的验证挑战并重放受保护的请求。
Stars: 0 | Forks: 0
`provideRelintio()` 将一个 publishable key 放入应用 injector 中,而 `relintioInterceptor` 则位于 `HttpClient` 链中,监视着一个响应:来自你自身 API 且携带 `X-Relintio-Challenge` 的 `403`。遇到该响应时,请求会被挂起,随后展示托管的 challenge,并在访客通过后重放该请求。`RelintioService` 以 Angular signal 的形式暴露状态,因此模板无需订阅即可渲染 challenge。协议本身并不包含在此包中 —— publishable-key 拒绝机制、针对 challenge URL 的 `http`/`https` 检查、三段式的 `postMessage` 验证、十秒的超时下限以及 fail-open 行为,全都位于 `@relintio/browser-core` 中,并与 React、Vue、Svelte 和 Expo SDK 共享。此包是其上的 Angular 形态表层,它本身不决定任何关于安全性的内容。
```
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRelintio, relintioInterceptor } from '@relintio/angular-agent';
export const appConfig: ApplicationConfig = {
providers: [
provideRelintio({ publishableKey: 'pk_live_...' }),
provideHttpClient(withInterceptors([relintioInterceptor])),
],
};
```
```
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig);
```
## 安装说明
```
npm install @relintio/angular-agent
```
| Peer | Range |
| --- | --- |
| `@angular/core` | `>=16.0.0` |
| `@angular/common` | `>=16.0.0` |
| `rxjs` | `^7.0.0` |
Angular 16 是一个实打实的最低门槛,而非出于客套。`RelintioService` 通过 `signal()` 发布其状态并通过 `DestroyRef` 注销,而这两者在 16 之前都不存在。`@relintio/browser-core` `^1.0.0` 是唯一的运行时依赖,并会随安装一并获取。
[展示 challenge](#presenting-the-challenge) 中的模板使用了 `@if` 控制流块,这是 Angular 17 的特性。在 16 版本中,将 `*ngIf` 与导入到独立组件中的 `NgIf` 搭配使用也能实现相同的效果。
## 注册
这两个 provider 都应放在同一个 `ApplicationConfig` 中,并且无论如何都必须包含 `provideHttpClient` —— `relintioInterceptor` 仅在 `HttpClient` 链内运行。
`provideRelintio()` 返回一个 `Provider[]`,仅携带一样东西:`RELINTIO_CONFIG` token。它不会启动任何东西。`RelintioService` 被设定为 `providedIn: 'root'`,因此它会在首次注入时被构造;如果应用内唯一的消费者是 interceptor,那么首次注入将发生在第一个 `HttpClient` 请求时,而不是在引导期间。如果你希望 agent 在应用加载的那一刻起就保持监视行为,请在你的根组件中注入 `RelintioService`。
省略 `provideRelintio()` 并不是一个静默的空操作。`RELINTIO_CONFIG` 在注入时未添加 `{ optional: true }`,因此缺失的 provider 会在服务首次构造时暴露为一个 `NullInjectorError` —— 发生在请求期间,这虽然比期望的要晚,但总比毫无动静要好。
### Interceptor 顺序
`withInterceptors([a, b])` 构建链时的顺序是最外层优先:`a` 在 `b` 之前看到请求,而响应 —— 或是 `HttpErrorResponse` —— 则以相反方向回传,在到达 `a` 之前先经过 `b`。`relintioInterceptor` 完全在返回阶段(在 `catchError` 中)工作,因此这个顺序构成了其正确性的全部。请将其注册在**最后**,即最靠近后端的位置。
在它之后注册的 interceptor 会最先接收到错误。如果该 interceptor 吞掉了错误 —— 例如 `catchError(() => of(fallback))`、一个将失败映射为空结果的重试助手,或者一个只报告并返回的全局处理器 —— 那么 `403` 就永远不会到达 `relintioInterceptor`,`X-Relintio-Challenge` 永远不会被读取,也就永远不会展示任何 challenge。没有任何异常抛出,没有日志记录,而网络标签页显示一个 challenge header 到达了页面,但页面却毫无反应。
重试机制是 `next(request)`,且请求与该 interceptor 接收到它时的状态完全一致。在它之前注册的 interceptor 所应用的转换 —— 授权 headers、base URL、correlation ids —— 已经存在于请求中,并在重放中得以保留。在它之后注册的 interceptor 会再次运行,因此在该阶段具有副作用的 interceptor 在一次调用中会被执行两次。
## 配置
传递给 `provideRelintio()` 的所有内容,类型为 `RelintioConfig`,并从该包中重新导出。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `publishableKey` | `string` | — | 必填。必须以 `pk_` 开头。其他任何内容都会被拒绝;详见下文。 |
| `apiUrl` | `string` | `https://api.relintio.com/v1` | Control-plane 基地址。末尾的斜杠会在构造时被截断一次。 |
| `challengeTimeoutMs` | `number` | `120000` | 一个 challenge 在其 promise reject 之前可以保持打开状态的时间。下限为 `10000`;低于此值的设置会被上调,而不是按原样执行。 |
| `verifyOnMount` | `boolean` | `false` | 在构造服务时请求一个 verdict。详见下文。 |
| `fallbackUrl` | `string` | — | 声明在 `RelintioConfig` 上,但不被核心代码读取。设置它没有任何效果。 |
`verifyOnMount` 默认关闭是有原因的。interceptor 会对你的源服务器所做出的决定做出反应,当你拥有服务器时,这是正确的形态:防护在服务器端执行,而此 SDK 使得这种防护对于真实用户而言是可存活的。当 Angular 应用是整个产品时(即位于 CDN 上的静态包,且没有你自己的源服务器在运行 agent),才应开启它。
## 哪种 key 应该放在这里
这是一个**浏览器**包。无论它持有什么 key,都会存在于每个访客下载的 JavaScript 包中,因此它只接受 **publishable key**(`pk_live_…`)而不接受其他任何内容。publishable key 在设计上就是公开的,并且只携带一种能力:它可以向 Relintio 请求 verdict 并读取答案。它无法读取你的规则、写入遥测数据,也无法促成颁发 challenge 通过的通行证。
你的**licence key 绝对不能出现在这里**。它是用于 challenge passport 和请求签名的 HMAC 密钥,任何持有它的人都可以为自己铸造一个通过你 WAF 的通行证。如果提供了一个 licence key,`RelintioAgent.isUsable()` 会匹配 `pk_` 前缀,匹配失败,写出一个指明问题的 `console.error`,并且服务会将 `usable` 设为 `false`。从那一刻起,该 agent 不会传输任何内容:`verify()` 将在不发出请求的情况下返回 `null`,因此该 key 永远不会到达网络。它唯一不会做的事情就是抛出异常 —— 关于不可用的 agent 会对被拦截的请求产生什么影响,请参阅 [边缘情况](#edge-cases)。
## 展示 challenge
遮罩层由你决定。服务为你提供状态、frame 必须携带的属性,以及用于决定 `message` 事件是否真实有效的检查。
```
import { Component, ElementRef, HostListener, ViewChild, inject } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { RelintioService } from '@relintio/angular-agent';
@Component({
selector: 'app-relintio-challenge',
standalone: true,
template: `
@if (relintio.state().isChallenging) {
}
`,
})
export class RelintioChallengeComponent {
readonly relintio = inject(RelintioService);
private readonly sanitizer = inject(DomSanitizer);
@ViewChild('frame') frame?: ElementRef;
frameSrc() {
const url = this.relintio.state().challengeUrl;
return url ? this.sanitizer.bypassSecurityTrustResourceUrl(url) : null;
}
@HostListener('window:message', ['$event'])
onMessage(event: MessageEvent) {
if (this.relintio.isChallengeSuccess(event, this.frame?.nativeElement.contentWindow)) {
this.relintio.resolveChallenge();
}
}
}
```
`isChallengeSuccess` 包含三项检查,且这三项全都是必须满足的:事件 origin 与 challenge URL 的 origin 相匹配,`event.source` 是该 iframe 自身的 `contentWindow`,并且 `event.data` 是确切的字符串 `relintio_challenge_success`,而不是某个前缀或需要解析的内容。仅凭 origin 匹配将允许位于 challenge origin 上的任何 frame 代表访客通过 challenge。组件本身不做任何决定;它只负责转发事件,并在被指示时调用 `resolveChallenge()`。
`frameAttrs` 为 `{ sandbox: 'allow-forms allow-scripts allow-same-origin', referrerPolicy: 'no-referrer', title: 'Security check' }`。它是数据,而非强制执行 —— 没有绑定它的组件将获得一个未设 sandbox 的 frame,并且不会收到任何警告。故意省略了 `allow-top-navigation`,使得 challenge 页面无法将访客移出其正在受保护的站点。`allow-scripts` 与 `allow-same-origin` 的组合之所以安全,仅仅是因为 challenge 是从不同于你应用的 origin 提供的;如果你将 `apiUrl` 指向你自身 origin 上的自托管 control plane,这种组合就会允许 frame 绕过其自身的 sandbox。
## 通过网络传输的内容
仅当设置了 `verifyOnMount` 时,或者当你亲自调用 `RelintioService.verify()` 时。会向 `/agent/decision` 发起一次 `POST` 请求,携带 `X-Agent-Version` header,设有五秒的中止时间,并且 body 中携带 publishable key、当前 hostname、路径和 referrer、一个 `return_url`、`agent_kind: 'angular'`、查询字符串中的任何 `up_token`,以及共享收集器所收集的遥测数据。用于该 key 的网络传输字段依然命名为 `license_key`;它携带的是 publishable key,服务器会从该 key 本身而非 `agent_kind` 推导出所有重要信息(`agent_kind` 仅作报告之用,不被信任)。
答案是一个 verdict —— `action`、`reason`、`reason_code`、`risk_score`、`ip`,以及需要 challenge 时的 `challenge_url` —— 并且它被存储在 `state().verdict` 中。它不是策略。`challenge` 的 verdict 会打开遮罩层;`block` 则仅供参考,因为浏览器施加的 block 也就是浏览器可以拒绝施加的 block。
收集器在每次 verdict 请求时运行一次,并收集 user agent、屏幕和显示信息、时区和语言、探测到的字体、插件、canvas hash、WebGL renderer、在 120 毫秒预算内竞速测得的音频 hash、网络状况以及行为计数器。行为类仅包含计数 —— 停留时间、指针移动次数、按键次数、页面滚动次数、屏幕触摸次数。它从不记录输入了什么内容或指针移动到了何处。监视器在构造服务时启动,而不是在发出请求时启动,因为在请求时创建的监视器总是报告一个什么都没做的访客,而这正是该信号旨在捕获的自动化行为的特征。
## 边缘情况
**仅覆盖 `HttpClient`。** 此绑定绝不会 patch 任何全局对象。`fetch`、`XMLHttpRequest` 和 Axios 调用会在不经过 `relintioInterceptor` 的情况下离开应用,因此它们上的任何 challenge 都会作为普通的 `403` 暴露给调用发起者。核心代码中有一个用于包装全局对象的 `interceptFetch`;Angular 包并未调用它,也没有将其暴露出来。
**被拒绝的 key 会使 interceptor 进行重试而不是发起 challenge。** 当 `usable` 为 false 时,`RelintioService.challenge()` 返回 `Promise.resolve()`。interceptor 会将该解决视为已通过的 challenge 并将请求重放一次,因此使用 licence key 或缺失 key 发布的构建版本会用一次静默重试且没有遮罩层的方式响应 `403`。`console.error` 是唯一的信号。状态 signal 也会被冻结:当 agent 不可用时,服务永远不会订阅,因此在应用的整个生命周期内 `state()` 都会返回其初始值。
**interceptor 不会查看请求的 URL。** 任何携带 `X-Relintio-Challenge` header 的 `403` 都会触发一次 challenge,无论它来自哪个 origin。如果第三方 API 碰巧发送了该 header,就会将你的访客置于一个 challenge 页面之前。
**未解决的 challenge 会暴露原始的 `403`。** 超时、关闭或销毁都会 reject 挂起的 promise,并且内部的 `catchError` 会重新抛出服务器实际发送的错误,而不是一个合成的错误。你现有的错误处理机制所看到的响应,与没有此包时本会看到的完全一样。再次受到 challenge 的重试会按原样返回 —— 仅有一次重试,永远不会形成循环。
**并发失败会产生一个 challenge。** 同时失败的三个请求会加入同一个挂起的 promise,因此只有一个遮罩层和一个 iframe,而不是三个相互竞争去解决同一个访客的实例。第二个 challenge URL 会被丢弃;屏幕上显示的第一个才是唯一有效的。
**所有的失败路径都会 fail open。** 无法到达的 control plane、非 2xx 的响应、在五秒时中止、无法解析的 body:所有这些情况都不会返回 verdict,并且页面会照常渲染。如果一个安全 agent 因为自己无法访问其 control plane 而让页面变成空白,那就是把我们自身的故障变成了客户的故障,这比它所要防范的情况还要糟糕。
**服务端渲染既不会崩溃,也不会做任何有用的事情。** 行为监视器受到 `typeof document` 的保护,因此在服务器上构造该服务是安全的。`verifyOnMount` 没有受保护:在服务器上没有 `location`,因此 调用会以空的 `domain`、`/` 的路径以及 `https://localhost/` 的 `return_url` 发出,而它所收到的 verdict 毫无意义。请在 SSR 下关闭 `verifyOnMount`,或者根据平台对其设置门控。
**销毁过程与 injector 绑定。** `DestroyRef.onDestroy` 会取消订阅监听器并销毁 agent,这将 reject 任何处于打开状态的 challenge 并清除监听器集合。由于该服务是 root-provided 的,这种情况会在 application injector 被销毁时发生,而不是按路由发生。已被销毁的 agent 拒绝启用新的 challenge,因此不会有任何计时器的存活时间超过应用本身。
## 链接
- [文档](https://relintio.com/docs)
- [快速入门](https://relintio.com/docs/quickstart/angular)
- [API 参考](https://relintio.com/docs/api-reference)
- [许可证](https://relintio.com/licenses)
安全报告请发送至 **support@relintio.com**,请勿提交至公开的 issue。
## 许可证
MIT。详见 [`LICENSE`](./LICENSE)。
标签:Angular, AppImage, Bot防护, Grype, HTTP拦截器, Web应用防火墙, 人机验证, 暗色界面, 自动化攻击, 配置错误