Relintio/relintio-firebase-agent
GitHub: Relintio/relintio-firebase-agent
面向 Firebase Cloud Functions 的请求防护 agent,在 Node.js 运行时中对入站 HTTP 请求进行实时评分与拦截。
Stars: 0 | Forks: 0
`withRelintio(handler)` 会包装你传递给 `onRequest` 的函数,并且 agent 会在你的 handler 处理之前先看到该请求。它会在实例中使用已加载至内存的规则集对该请求进行评分,然后直接予以响应——拦截页面、诱饵、挑战重定向——或者将其放行,并使用原本的 `req` 和 `res` 调用你的 handler。这就是整个入口点;当需要构建一次 agent 并在多个导出中共享时,可以使用它旁边的 `createAgent`。无需代理、无需更改 DNS、无需 sidecar,在请求路径上只增加了一个额外的 `await`。
```
import { onRequest } from 'firebase-functions/v2/https';
import { withRelintio } from '@relintio/firebase';
export const api = onRequest(
{ secrets: ['RELINTIO_LICENSE_KEY'] },
withRelintio(async (req, res) => {
res.send('protected');
}),
);
```
## 安装说明
```
npm install @relintio/firebase
```
将其安装在 `functions/` 目录中——即实际部署的 package 目录,而不是仓库根目录。根据 `engines` 的要求,需要 Node 18 或更高版本。
`firebase-functions` 是一个版本为 `>=4.0.0` 的**可选 peer 依赖**:这个 package 从不导入它,而是由你的函数来导入,因此这里没有任何东西会锁定你的 Firebase 版本。
## 使用的引擎类型
这不是 edge 引擎,尽管在文档中此集成位于 Vercel 和 Supabase 旁边。`package.json` 中的依赖是 `@relintio/agent` 版本 `^0.11.5`——即 Node 和 Express SDK 运行的相同服务端引擎——而**不是** `@relintio/vercel` 和 `@relintio/supabase` 所基于的 `@relintio/edge-core`。
这是由 `onRequest` 提供给你的内容所决定的。Cloud Functions 运行完整的 Node.js 并传递 Express 的 request 和 response,而不是 edge isolate 所使用的 Web 标准 `Request`/`Response`。该服务端引擎也是能够保留无状态 isolate 所无法保留的状态的引擎:磁盘上的规则集镜像、反向 DNS 缓存、基于 IP 的 token bucket。一个预热后的 Cloud Function 实例会在调用之间保留所有这些内容,这也是选择此引擎的大部分价值所在。
`UltimateProtectorNodeAgent` 在这里被重新导出,因此不需要为了对类型进行定义或 stub 而直接依赖该引擎。
## 注册
在 Cloud Functions 中没有应用级别的注册。每个导出都是其独立的入口点,因此你想要保护的每个导出都必须被包装:
```
export const api = onRequest(withRelintio(apiHandler, { agent }));
export const webhooks = onRequest(withRelintio(webhookHandler, { agent }));
```
包装 **handler**,然后将结果传递给 `onRequest`。未包装的导出是不受保护的,并且无论部署的其余部分是否已包装,它都可以在其自己的 URL 上被访问。
如上所述,传递 `agent` 以共享一个实例。如果没有传递,每个包装器会在首次使用时构建自己的 agent,随后每个包装器都会获取自己的规则集,并为每个访问者提供一个全新的、满额的 rate-limit bucket——也就是说,在整个部署中没有实现任何 rate limit。
如果你在 Cloud Function 中挂载了 Express 应用,请在 `onRequest` 边界处进行包装,**或者**在应用内部使用 `@relintio/express`。不要两者都用:这里的重入防护(re-entry guard)只能识别本 package 自身的包装器(参见边缘情况)。
## 许可证密钥
位于服务端,且必须保密。`UP_LIVE_…` 是用于签署挑战护照(challenge passport)和出站请求签名的 HMAC 密钥,因此任何持有它的人都可以为自己生成通行证,从而穿过其所属的 WAF。它应该被存放在 Secret Manager 中:
```
firebase functions:secrets:set RELINTIO_LICENSE_KEY
```
然后将其放入函数的 `secrets` 数组中(如上面的示例所示),这样才能使其在运行时出现在 `process.env` 中。请从 **Dashboard → Deployment → Firebase** 获取该密钥。
它绝对不能到达浏览器。公共凭证是一个 **publishable key**(`pk_live_…`),它只能做一件事——请求判定(verdict)——并且属于 React、Vue 和 Shopify SDK,而不是此 SDK。`createAgent` 会直接抛出异常,而不会在没有密钥的情况下启动;它无法区分浏览器和服务器,因此这个边界需要由你来维护。
## 配置
`withRelintio(handler, options)` 会读取四个选项,并将整个对象传递给引擎构造函数。
| 选项 | 默认值 | 含义 |
| --- | --- | --- |
| `licenseKey` | `process.env.RELINTIO_LICENSE_KEY` | `UP_LIVE_…`。**保密。**为空或缺失都会抛出异常,并提示 `firebase functions:secrets:set` 命令。 |
| `apiUrl` | `process.env.RELINTIO_API_URL`,否则为 `https://api.relintio.com/v1` | 结尾的斜杠会被引擎修剪掉。 |
| `agent` | 在首次调用时构建 | 用于替代构建新 agent 的已有 agent。 |
| `onError` | 无 | 当发生意外错误时,以 `onError(error, req, res)` 形式调用。仅用于观察。 |
其他所有选项都会传递给 `UltimateProtectorNodeAgent`:
| 选项 | 默认值 | 含义 |
| --- | --- | --- |
| `syncIntervalSeconds` | `10` | 目标同步频率,下限为 10。在此基础上会应用退避策略和 80–120% 的抖动。 |
| `onlyPaths` | 无 | 如果设置,则只评估这些路径前缀。 |
| `exceptPaths` | 无 | 无需评估直接放行的路径前缀。 |
| `onlyRegex` | 无 | 路径必须匹配才能被评估的正则表达式。 |
| `enforceTlsMinVersion` | `true` | 阻断低于 1.2 的 TLS——仅当请求到达 TLS socket 时有效。参见边缘情况。 |
| `rateLimitPerMinute` | `120` | 虽被接受、存储和读取,但无实际作用。limiter 是一个固定的 token bucket。 |
| `agentKind` | `firebase` | 不可覆盖:`createAgent` 在展开你的选项后会对其进行设置,以便控制台能够区分 Firebase 安装和普通的 Node 安装。 |
## 被拦截的请求必须予以响应
这是该包装器旨在防止的失败情况,也是在编辑它时最不应该重新引入的问题。
`handleExpress(req, res, next)` 有两种退出方式。在放行请求的路径上,它会调用 `next()`。在需要响应的路径上——拦截页面、诱饵、指向挑战的 302 重定向、`403 Invalid Token`、在生成护照后剥离 `?up_token` 的 302 重定向、许可证过期时的 503——它会写入响应并返回,**且从不调用 `next`**。这是正确的:因为请求已经结束了。
因此,如果包装器等待 `next`,它等待的东西将永远不会到来。调用会一直挂起,直到平台的函数超时将其强制终止,客户需要为这段挂钟时间买单,而他们看到的是 Relintio 挂起了他们的 API 而不是在保护它——而且恰好发生在 agent 决定拦截的请求上。
因此,该包装器等待的是 **promise**,而不是后续的 `next`:
```
const released = await new Promise((resolve) => {
let proceed = false;
...
agent.handleExpress(req, res, () => { proceed = true; }).then(done, ...);
});
```
`next` 只是设置了一个标志。promise 的状态落定才是释放 `await` 的条件,并且它在每条路径上都会落定,无论是进行响应还是放行。然后是同一规则的另一半:
```
if (!released || res.headersSent || res.writableEnded) {
return undefined;
}
```
如果 agent 已经做出了响应,再运行 handler 就会覆盖已完成的响应,Node 会将其报告为 `ERR_HTTP_HEADERS_SENT`——这会将我们的名字留在客户日志中的崩溃记录上,而且是留在一个我们已经决定拦截的请求上。只要 `next` 没有被调用,`released` 就为 false;那两个响应检查是为了捕获 `next` 被调用了但仍然有其他代码写入响应的情况。
引擎从另一端维持着相同的规则。`#respondChallenge` 是作为 `return this.#respondChallenge(req, res, next)` 调用的,因此它控制着所有的退出路径:当控制平面返回带有 `fallback: "allow"` 的 `challenge_disabled` 时,它会自己调用 `next()`,而在其他所有路径上它都会发送响应。在那里添加的任何没有执行这两者操作的代码都会导致请求挂起。
`test/function.test.mjs` 锁定了这两部分。`does not hang when the agent answers without calling next` 会将包装后的调用与一个 300 毫秒的计时器进行竞速,并断言它已经返回;`does not run the handler once the agent has answered` 会编写脚本模拟一个设置 `headersSent` 的 agent,并断言 handler 从未运行。
## 请求处理流程
agent 是延迟构建的,是在首次调用时而不是在模块加载时构建的,并作为 `wrapped.agent` 保存在包装器上。在冷启动实例上,第一个请求需要等待规则集的获取。之后,一旦间隔时间过去,`getRules` 会在后台进行刷新,并返回内存中已有的规则,因此后续的任何请求都不必为同步付出代价——如果你在将此 agent 与其他在请求路径上获取规则集的 agent 进行比较时,这个差异值得注意。
评估大致按以下顺序进行:路径过滤器、honeypot 陷阱、`?up_token` 交换和护照 cookie,然后是同步的策略——SEO 安全性、全局黑名单、TLS 指纹、地理位置、CIDR、honeypot 标头、扫描器签名、VPN shield、referrer 规则、自定义 WAF 规则——最后是附加评分,该评分被限制在 100 以内,并根据固定阈值进行读取:40 SLOW(延迟两秒,然后放行)、60 CHALLENGE、75 DECOY、85 BLOCK。
规则集会被镜像到 `os.tmpdir()` 中,命名为 `up_rules_<16 hex>.json`,以许可证密钥的 SHA-256 作为索引,并带有一个 `.mac` 伴随文件,其中保存了在同一密钥下该文件的 HMAC。如果不匹配,则会删除缓存并重新获取,因此与普通的镜像不同,这个镜像不会成为任何能够写入临时目录的程序的策略绕过途径。在 Cloud Functions 上,该目录是基于内存的,并计入实例的内存分配。
## 边缘情况
**此 package 是 ESM。**它设置了 `"type": "module"`,并且只有一个指向 `src/index.js` 的 `exports` 条目。因此,`require('@relintio/firebase')` 仅在允许 require ES module 的 Node 版本(20.19+、22.12+)上有效,而在 Node 18(这仍然是一个可选的 Cloud Functions 运行时)上会抛出 `ERR_REQUIRE_ESM` 异常。要么给你的 `functions/package.json` 设置 `"type": "module"` 并使用 `import`(如上面的示例所示),要么运行一个允许 `require` 的 Node 版本。
**过期或被撤销的许可证会返回 503,而不是交由 handler 处理。**如果同步返回 `status: "expired"` 或 `"outdated"`,则会清除规则、持久化该状态,并且随后的每个请求都会收到 503 许可证错误页面,直到状态发生变化。这是 agent 唯一不会放行请求的地方,这是故意的——但这意味着过期的许可证会使 API 宕机,而不是让其在失去保护的情况下运行。这也是从该分支中移除 `quota_exceeded` 的原因:超额使用是一个计费事件,绝不能因此中断防御。
**无法访问的挑战服务会执行失败关闭。**`#respondChallenge` 会向 `/agent/challenge/init` 发起 POST 请求,并带有两秒钟的超时限制,超时、拒绝连接或格式错误的响应最终都会导致 `403`。由于没有发出任何挑战,因此什么也不会被放行,而将访问者置于该境地的评分仍然有效。不要将此与 `challenge_disabled` 混淆,后者是一个策略响应,带有其自身的 `allow` 或 `block` fallback,并且会被执行。
**其他所有情况都是失败放行。**同步失败会保留其上次成功解码的策略,并以 2 的幂次进行退避,上限为五分钟;未知的状态、无法解密的 payload 或空主体都会被视为同步失败,而不是新策略。`getRules` 抛出异常会放行请求。完全没有任何规则也会放行请求。agent 内部的异常(无论是同步的还是异步的)都会报告给 `onError` 并放行请求。我们的 bug 不应该成为你的服务中断,而一个仅仅因为无法连接到其控制平面就拦截页面的安全 agent,已经把我们的故障变成了客户的故障。
**`onError` 仅用于观察,无法进行控制。**它无法改变结果,并且被包裹在它自己的 `try`/`catch` 中——一个抛出异常的 reporter 会被静默处理,而不会让它自己变成它所报告的故障。对此我们有专门的测试。
**重入防护仅涵盖此 package。**它是 request 上的一个 `Symbol.for('relintio.firebase.handled')`:全局 symbol 注册表意味着在一个部署中存在两份 `@relintio/firebase`副本仍然会达成一致,并且穿过其中两个包装器的请求只会被评估、记录和计量一次。挂载在同一个函数内的 `@relintio/express` 既不会设置也不会读取它,因此这种组合会进行两次评估,并且客户会看到同一个请求被计费两次。
**TLS 指纹识别需要 TLS socket。**除非 `req.socket` 暴露了 `getProtocol`,否则 `extractTlsFingerprint` 会返回 `null`,而在实例之前就终止了 TLS 的情况(Cloud Functions 上通常是这种配置)下,这是不可能发生的。在这种情况下,指纹检查和 `enforceTlsMinVersion` 都会被静默跳过。评估的其余部分不受影响。
**`req.secure` 和 `req.ip` 的影响比表面看起来更大。**`req.secure` 会为挑战的 `return_url` 选择 `https`,并向护照 cookie 添加 `Secure` 属性;`req.ip` 是被评分、进行 rate limit 以及报告的地址,只有当对端地址位于同步的 Cloudflare 范围内时,才会应用 `X-Forwarded-For`。在 Google 的前端之后,这两者都取决于你的框架的代理信任设置。在信任仪表板上的数字之前,请先在真实的部署环境中检查一次。
## 链接
- [文档](https://relintio.com/docs)
- [快速入门](https://relintio.com/docs/quickstart/firebase)
- [API 参考](https://relintio.com/docs/api-reference)
- [许可证](https://relintio.com/licenses)
安全报告请发送至 **support@relintio.com**,请勿提交至公开的 issue 中。
## 许可证
专有软件。请参阅 [`LICENSE`](./LICENSE),`package.json` 中将其声明为 `SEE LICENSE IN LICENSE`。
Relintio 专有许可证仅授予一项权限:在从 Relintio 获得的有效、活跃的许可证下,使用此软件来集成和运行 Relintio 服务。它保留了所有其他权利——不得复制、再分发、修改、翻译、逆向工程或制作衍生作品,也不得移除专有权利声明——并且免除了所有保证。提供未压缩的源代码的目的是为了让你阅读源代码,以了解在你的流量之前运行的是什么;但将其中的任何代码再次转发发布都是不被允许的。
标签:AppImage, CISA项目, Firebase, GNU通用公共许可证, MITM代理, Node.js, WAF, Web应用防火墙, 中间件, 云函数, 密码管理, 自定义脚本