Relintio/relintio-edge-agent

GitHub: Relintio/relintio-edge-agent

Relintio 面向 Web 标准边缘运行时的共享 agent 协议引擎,为边缘平台提供基于 ruleset 的请求评估与拦截能力。

Stars: 0 | Forks: 0

Relintio

@relintio/edge-core

npm docs license

用于 Web 标准边缘运行时的 Relintio agent 协议。

这不是一个安装到应用程序中的包。它是 [`@relintio/vercel`](https://www.npmjs.com/package/@relintio/vercel) 和 [`@relintio/supabase`](https://www.npmjs.com/package/@relintio/supabase) 构建时所基于的共享 agent 协议 —— 尽管在目录中与它们并列,但 `@relintio/firebase` 并不属于其中,因为 Cloud Functions 运行的是 Node 而非 Web 标准的边缘运行时,且它是基于 `@relintio/agent` 构建的:除非您正在编写自己的集成,否则请安装上述包之一;如果您确实在编写自定义集成,那么这就是您编写集成所依赖的基础。其入口点是 `EdgeGuard` 类及其唯一的方法 `protect(request, context)`,该方法接收一个 `Request` 并返回一个 `Response`(用于替代运行您的应用程序)或返回 `null`(用于放行请求)。它位于 handler 之前,根据从 control plane 获取并缓存在 isolate 中的 ruleset 做出决策,并且绝不会抛出异常。 ``` import { EdgeGuard } from '@relintio/edge-core'; // Module scope, not per request. The ruleset cache, the fetch timestamp and // the single-flight refresh are all instance state. const guard = new EdgeGuard({ licenseKey: process.env.RELINTIO_LICENSE_KEY, exceptPaths: ['/health'], }); export default async function handler(request, context) { const refused = await guard.protect(request, context); return refused ?? new Response('hello'); } ``` ## 安装 ``` npm install @relintio/edge-core ``` 仅支持 ESM —— 需配置 `"type": "module"` 以及不含 `require` 条件的 `exports` 映射。`engines.node` 为 `>=18`,这是首个将 `fetch`、`Request` 和 `Response` 作为全局变量的 Node 版本。没有任何生产或开发依赖;测试套件仅使用 `node --test`。 | 入口 | 导出 | | --- | --- | | `@relintio/edge-core` | `EdgeGuard`, `evaluate`, `matchRule`, passport 和签名函数,以及 `AGENT_VERSION`, `ALLOW_SAMPLE_RATE`, `RULES_TTL_SECONDS`, `CONTROL_PLANE_TIMEOUT_MS` 和 `ACTION_SCORES` 常量 | | `@relintio/edge-core/crypto` | 相同的 crypto 接口,外加 `PASSPORT_SKEW_SECONDS` 以及原始的 `sha256Hex`, `hmacSha256`, `hmacVerify` | | `@relintio/edge-core/rules` | 规则匹配功能,外加 `BLOCK_SCORE` 和 `CHALLENGE_SCORE` 阈值,根入口不会重新导出这些阈值 | ## 注册 在模块作用域内构造一次 guard,并在您平台的 middleware 入口(即在 handler 之前运行并可以代替其响应的地方)调用 `protect`。如果放在其他地方,会导致两个问题。 如果每个请求都构造一次,您将失去缓存。ruleset、fetch 时间戳和 in-flight promise 都是私有实例字段,因此一个新的 guard 拥有一个空的 ruleset,并且在做出任何决定之前必须调用 `/agent/verify` —— 这会发生在每个请求上,完全没有使冷启动 isolate 变得低廉的单次飞行(single-flight)折叠机制。 如果忽略返回值,您将失去拦截能力。一个非 `null` 的 `Response` 就是拦截页面或 challenge 重定向;如果丢弃它并照常运行 handler,将会悄无声息地为 guard 拒绝的每个请求提供服务,而该决定此时已被上报为 block。 将平台的 context 对象传递过去。`waitUntil` 使 isolate 存活足够长的时间,以便决策报告能够成功发送;而 `ip`(当平台解析出时)会优先于任何 header 被信任。`onlyPaths` 和 `exceptPaths` 会最先被评估,在使用凭证或获取策略之前,因此跳过的路径仅需一次 `startsWith` 判断,并且不会发起任何网络调用。 ## 配置 每个字段都在构造函数中读取,且仅在那里读取。 | 选项 | 默认值 | 含义 | | --- | --- | --- | | `licenseKey` | — | 必填。`UP_LIVE_…` 许可证密钥。为每个 passport 和每个出站签名提供密钥。**机密** —— 见下文。 | | `apiUrl` | `https://api.relintio.com/v1` | control-plane 基础地址。构造函数会剥离一次尾部的斜杠。 | | `agentKind` | `edge` | 在 `/agent/verify` 和每次决策时发送,以便仪表板可以区分这三种集成。截断为 32 个字符。 | | `onlyPaths` | `[]`,即所有路径 | 要保护的路径前缀。为空表示全部。非数组值将变为空。 | | `exceptPaths` | `[]` | 要跳过的路径前缀。在 `onlyPaths` 之前检查,且优先级高于它。 | | `rulesTtlSeconds` | `60`,即导出的 `RULES_TTL_SECONDS` | 获取的 ruleset 受信任的时间。`0` 尝试在每个请求时刷新,但仍是单次飞行(single-flight)。负数或非数值将回退到默认值,而不是零。 | 许可证密钥是一个**机密**。它是用于生成 challenge passport 并为出站调用签名的 HMAC 密钥,因此任何拥有它的人都可以伪造这两者并穿越 WAF。它应该保存在运行时的环境中,绝不能放在代码仓库中,也绝不能放在任何会到达浏览器的地方 —— 浏览器获取的是可发布密钥(`pk_live_…`),它只能做一件事,即请求判定(verdict),这正是 React 和 Shopify SDK 所使用的密钥。 guard 会检查它接收到的是哪种密钥。如果密钥为空或以 `pk_` 开头,会将 `isUsable()` 设为 `false`,在 `console.error` 中记录一次错误,指出两种密钥类型以及在何处找到正确的密钥,并将 `protect` 变为一个直接返回 `null` 而不进行任何外部调用的函数 —— 测试断言永远不会到达 `fetch`。这与浏览器 SDK 的检查恰好相反,原因相同:可发布密钥无法对 passport 签名,因此持有该密钥的边缘部署将生成的每个 passport 都会被源站 agent 拒绝,其症状是每个访问者在每个请求上都会永远被 challenge,而任何日志中都没有任何解释。 ## 它对运行时的要求 不需要 Web 平台之外的任何东西。`src/` 仅使用 `Request`, `Response`, `Headers`, `URL`, `fetch`, `AbortController`, `setTimeout`, `TextEncoder`/`TextDecoder`, `btoa`/`atob`, `crypto.getRandomValues` 以及四个 `crypto.subtle` 操作 —— `digest`, `importKey`, `sign` 和 `verify`。整个包中没有任何地方使用了 `node:` 导入。 这就是为什么它与 Node agent 并列存在,而不是包含在其中的原因。`@relintio/agent` 将 ruleset 缓存在磁盘上并使用 `node:crypto`;而 edge isolate 两者都没有,因此该协议基于 `crypto.subtle` 进行了二次实现。只要存在该 API 接口的地方都可以运行它 —— Vercel Edge Middleware、Supabase Edge Functions、Firebase、Deno、Bun、Cloudflare Workers 以及 Node 18 或更高版本。 ## 请求时的执行流程 `protect` 按以下顺序执行,并在第一个产生响应的步骤停止。 1. **路径过滤。** 先匹配 `exceptPaths` 前缀,然后当 `onlyPaths` 非空时匹配它。被跳过的路径会立即返回 `null`。 2. **Token 交换。** 验证查询字符串中的 `?up_token` 并将其替换为 passport cookie。这发生在获取策略之前,因此即使 control plane 无法访问,刚刚通过 challenge 的访问者也能被放行。 3. **Passport cookie。** 有效的 `relintio_passport` 会返回 `null` —— 已经证明了身份的人不会被重新评分,并且不会代表他们发起 `/agent/verify` 调用。 4. **策略。** 缓存的 ruleset,当其陈旧时间超过 TTL 时进行刷新。完全没有策略意味着返回 `null`。 5. **`bypass_paths`**(来自获取的设置),然后是 **`whitelist_ips`**,与解析出的地址进行精确的字符串匹配。 6. **`evaluate`**(在已同步的规则上执行),以及下述的判定。 当平台提供时,地址为 `context.ip`;否则依次取 `cf-connecting-ip`、`x-real-ip` 的第一个值,或 `x-forwarded-for` 的首项;如果都没有,则为空字符串。上报的国家/地区代码依次为 `context.country`、`cf-ipcountry`、`x-vercel-ip-country`,如果都没有则为 `XX`。 ## 规则匹配 `matchRule` 实现了 `contracts/rule-conditions-v1.json`,并且 `test/rules.test.mjs` 会加载该文件并运行其测试向量,而不是重述它们 —— 基于自身对语义的理解而产生的第十三个实现只会增加第十四种观点。测试还会断言至少加载了 25 个向量,因此移动或清空的契约会明确报错,而不是空洞地通过。 类型包括 `ip`、`path`、`user_agent` 和 `header`。没有冒号的 `header` 模式会测试该 header 是否存在且具有非空值;`Name: value` 会测试指定的 header,同时修剪冒号前后的空白,并且名称匹配是大小写不敏感的。当给定一个 `Headers` 对象时,通过 `.get()` 读取 Header;当给定一个普通对象时,通过大小写不敏感的键扫描来读取,因此匹配器可以在为您提供这两种类型的主机上工作。条件包括 `equals`(精确,大小写不敏感)、`contains`(子字符串,大小写不敏感)和 `regex`(使用 `RegExp` 编译);无法编译的模式永远不会匹配,也永远不会抛出异常。 无法识别的条件在检查类型之前就会被拒绝,而无法识别的类型永远不会匹配。这两者都是契约本身的规定,而非出于谨慎:曾有四个 SDK 完全没有 `header` 分支,因此在这些运行时中,在仪表板中创建的规则评分为零且不报告任何内容;另外三个 SDK 接受了 `regex` 却将其作为子字符串进行匹配,导致写成 `^/admin$` 的规则匹配到了 `/administrator`。 ## 判定 将每个匹配规则的分数相加。操作会在处理过程中升级 —— 匹配到的 `block` 规则永远不会被后续规则降级 —— 然后将总分与阈值进行比较。 | 条件 | 判定 | | --- | --- | | 匹配到 `action: "block"` 的规则,或总分达到或超过 100 | block | | 匹配到 `action: "challenge"` 的规则,或总分达到或超过 50 | challenge | | 其他情况 | allow | 契约规定的分数为:`block` 100,`challenge` 60,`log` 0。`test/rules.test.mjs` 将 `ACTION_SCORES` 与这些数字进行断言比对,而不是与副本比对。累积是关键:匹配同一请求的两个 `challenge` 规则总和为 120,并会变成一个 block。 | 判定 | 响应 | | --- | --- | | allow | `null`,请求继续运行。按采样率进行上报。 | | challenge | `302` 重定向至 `/challenge?return_url=`,`Cache-Control: no-store` | | block | `403`,并附带一个自包含的 `text/html` 页面,`Cache-Control: no-store` | 这里没有 SLOW 层级,也没有 DECOY 层级,也没有内置的评分启发式算法 —— 没有 user-agent 列表,也没有速率限制器。每个分数的每一分都来自于 control plane 下发的规则,因此尚未获取策略的部署,或许可证中没有规则的部署,不会执行任何拦截。`reportsAction` 仍然会识别 `DECOY` 和 `SLOW`,并且测试会针对这些名称进行固定,因为它是与确实会产生这些动作的 agent 共享的上报断言。 无法展示的 challenge 将被允许(放行),而不是被拦截。当获取的设置包含 `challenge_enabled: false` 时,请求将被放行,并以上报原因 `Challenge unavailable` 报告为 `ALLOW` —— 客户关闭的 challenge 并不是拒绝其流量的理由,并且分数仍然会被记录,这无论如何都是证据。该检查是严格的 `=== false`,因此如果响应中省略了该字段,则会触发 challenge。 ## Passport 通过了 challenge 的访问者会带着 `?up_token=` 返回。guard 会验证它,生成一个具有该 token 所要求 TTL 的新 passport,将其设置为 `relintio_passport`,并执行 `302` 重定向到仅移除了 `up_token` 的相同 URL —— 所有其他查询参数都会保留,并且 token 不会作为某人可能分享的链接留在地址栏中。 cookie 属性为 `Path=/; Max-Age=; HttpOnly; SameSite=Lax`,当请求 URL 是 `https:` 时添加 `Secure`。ttl 被限制在 300–604800 秒之间,无效值默认为86400:token 到达时是带有签名的,但上游的 bug 不应该能够生成一个长达十年的 cookie。 验证失败的 `up_token` 会返回 `403 Invalid Token`,而不是直接放行。这是 guard 选择进行响应而不是放行的唯一地方,这是故意设计的 —— 持有有效 token 的唯一途径就是刚刚通过了 challenge。 passport 的格式为 `v2..`:使用去除了填充的 base64url 编码,严格的 JSON 键顺序为 `{v, exp, b}`,在许可证密钥下使用 HMAC-SHA256 进行签名。`b` 是 `sha256(licenceKey|userAgent|acceptLanguage)` 的前 16 个十六进制字符,绑定到客户端正是为了防止被窃取的 cookie 在其他地方重放。验证是完全离线的 —— 不发起网络调用 —— 使用 `crypto.subtle.verify` 进行签名验证(规范中规定为常数时间),对于绑定检查,则使用一个无论首次出现不匹配都会运行到结束的循环。`exp` 允许与 challenge 服务器之间有 60 秒的时钟偏差。任何不以 `v2.` 开头的内容,以及任何不包含恰好三个以点分隔的部分的内容,都会被直接拒绝。 `test/parity.test.mjs` 是使该格式的第二次实现变得安全的关键。它使用本包生成 passport 并使用 Node agent 进行验证,同时使用 Node agent 生成并在这里进行验证,并断言两者对于相同的输入会生成**字节级一致**的 passport —— 而不仅仅是互相可以接受,因为那只会让它们由于各自碰巧容忍的格式而意外兼容。它对 `signRequest` 和 ttl 钳制(clamp)也做了同样的测试。它所防止的情况是:访问者在 Vercel 边缘通过了 challenge,然后到达了运行 Node 或 PHP agent 的源站:如果任何地方的键顺序、base64 填充或 HMAC 覆盖的字节存在差异,该访问者将在之后的每个请求上永远被 challenge,并且这会表现为 challenge 功能损坏,而不是序列化 bug。 ## 请求签名 两个出站调用 —— `/agent/verify` 和 `/agent/log` —— 都带有: ``` X-Relintio-Timestamp: 1785120000 X-Relintio-Nonce: <24 base64url chars, from 18 random bytes> X-Relintio-Signature: v1=<64 hex> ``` 签名为 `HMAC-SHA256("v1:" + timestamp + ":" + nonce + ":" + sha256(body), licenceKey)`。`body` 是传递给 `fetch` 的确切字符串:私有的 `#call` 方法会执行一次 `JSON.stringify`,并将该字符串同时提供给签名者和请求。对对象进行哈希并让客户端重新序列化,相当于为一个从未到达网络的数据包字节序列进行了签名;服务器对实际收到的内容进行哈希,因此它会拒绝一切,并且任何日志中都不会有解释。每次调用都会从 `crypto.getRandomValues` 获取一个新的 nonce,并由一个 `AbortController` 在 `CONTROL_PLANE_TIMEOUT_MS`(3000 毫秒)时中止,该控制器的定时器会在 `finally` 块中被清除。 ## 失败放行(Failing open) 每个失败路径都会放行请求,因为如果一个安全 agent 由于无法对页面进行评分而阻止了它,那就等于把我们的故障变成了客户的故障。 | 路径 | 行为 | | --- | --- | | 凭证错误或缺失 | `protect` 返回 `null`,不传输任何内容 | | `protect` 内部发生任何异常抛出 | 在最外层边界被捕获;返回 `null` | | `/agent/verify` 无法访问、超时、返回非 2xx 或无法解析 | 缓存的策略保留;不进行任何替换 | | 响应不包含 `rules` 数组 | 作为非策略被拒绝;缓存保留 | | 暂无策略 —— 冷启动 isolate,或 control plane 从未响应过 | 返回 `null` | | 格式错误的 ruleset,或非对象类型的规则 | 视为无规则;判定为 `allow` | | 无法编译的 `regex` 模式 | 该规则永远不会匹配且永远不会抛出异常 | | `/agent/log` 失败,或宿主从 `waitUntil` 抛出异常 | 被吞没(忽略);已经做出的决策仍然有效 | `rules` 检查值得深入探讨。一个携带了 `{"status": "error"}`、`{}`、`{"rules": null}` 或是代理认为的成功页面的 `200` 响应并不是一个策略,将其作为策略应用会在某些东西已经出现问题的确切时刻,用空数据替换掉客户的保护。明确为空的数组**是**一个策略,并且会被应用。`test/guard.test.mjs` 会运行上述每一种数据形态,并断言之前的 ruleset 仍在执行拦截。 ## 边缘情况 **解析出的地址来自于客户端可以设置的 headers。** `cf-connecting-ip`、`x-real-ip` 和 `x-forwarded-for` 在读取时没有考虑受信任跳数(trusted hop)的概念。在覆盖这些 header 的平台上,这是正确的;而在追加这些 header 的平台上,访问者可以选择自己的地址并规避 `ip` 规则 —— 或者在 `whitelist_ips` 中声明一个地址,这完全基于对该值的精确字符串匹配。只要平台能够解析,就请传递 `context.ip`,因为它比任何 header 都更受信任。 **一切都是针对 isolate 的。** ruleset 缓存和单次飞行(single-flight)刷新存在于实例上,因此按区域或按突发启动 isolate 的平台,会为每个 isolate 获取一次 ruleset,而不是每个部署获取一次。在仪表板中更改的规则会在每个 isolate 的 `rulesTtlSeconds` 内生效,而不是立刻全局生效。 **允许的请求按 1% 进行上报。** `ALLOW_SAMPLE_RATE` 是一个常量,并且故意不作为选项提供:平台会根据该比率将上报的 allow 数量进行乘法还原,如果安装时采用了不同的采样率,将会报告一个随后被平台使用错误数字进行纠正的值。Block 和 challenge 从不被采样 —— 它们是安全记录。因此,控制台显示的大约是一百次 allow 中的一次,这并不是上报故障。 **没有 `waitUntil` 的遥测是尽力而为的。** 在请求路径中永远不会 await 上报 —— 一个测试保持 `/agent/log` 永久开启,并断言响应仍然能够返回 —— 因此在不提供 `waitUntil` 的宿主上,或者如果您没有传递 context,isolate 可能会在报告到达之前被销毁。 **拦截页面是固定的。** 一个内联的英文 HTML 文档,没有品牌挂钩,也没有模板选项。如果您需要自定义页面,请在上游匹配 `403`。 **Challenge URL 是通过剥离尾部的 `/v1` 派生出来的。** 使用默认的 `apiUrl` 会得到 `https://api.relintio.com/challenge`。如果 `apiUrl` 不以 `/v1` 结尾,则会保留其完整路径,并在其后附加 `/challenge`。 **一致性测试需要 Node agent 在其旁边。** 它通过相对路径导入 `../../node/src/node-utils.js`,因此它在 monorepo 中运行,而不是从发布的 tarball 中运行,后者仅包含 `src/`、`README.md` 和 `LICENSE`。 ## 链接 - [文档](https://relintio.com/docs) —— 从这里开始;本包没有针对特定运行时的快速入门指南 - [API 参考](https://relintio.com/docs/api-reference) - [许可证](https://relintio.com/licenses) - 基于它构建的两个集成:[`@relintio/vercel`](https://www.npmjs.com/package/@relintio/vercel), [`@relintio/supabase`](https://www.npmjs.com/package/@relintio/supabase) 安全报告请发送至 **support@relintio.com**,而不是提交到公开的 issue 中。 ## 许可证 请参阅 [`LICENSE`](./LICENSE),`package.json` 中声明为 `SEE LICENSE IN LICENSE`。
标签:API防护, OSV, Syscall, Vercel, Web开发, 中间件, 云计算, 数据可视化, 自定义脚本, 规则引擎, 边缘计算