Relintio/relintio-express-agent

GitHub: Relintio/relintio-express-agent

Relintio Express Agent 是一个 Express 安全中间件,通过一行 app.use() 实现基于评分的请求拦截、质询和反爬虫防护。

Stars: 0 | Forks: 0

Relintio

@relintio/express

npm quickstart license

专为 Express 设计的 Relintio 代理。

只需在你的调用栈前加上一个 `app.use()`。`relintio(options)` 会基于 `@relintio/agent` 构建一个 `UltimateProtectorNodeAgent`,并返回一个 Express 请求处理程序。该处理程序会在你的路由运行之前对每个请求进行评分——决定是允许 (allow)、减速 (slow)、质询 (challenge)、诱饵 (decoy) 还是拦截 (block) ——这些判定基于从控制平面同步并镜像到操作系统临时目录的规则集。相比于引擎自带的中间件,此包的核心增量在于它的边界处理:当发生意外故障时,它会直接放行请求并上报给 `onError`,而绝不会传递给 `next(err)`。该包的入口是命名导出 `relintio`,它同时也是默认导出。 ``` import express from 'express'; import { relintio } from '@relintio/express'; const app = express(); app.use(relintio({ licenseKey: process.env.RELINTIO_LICENSE_KEY, apiUrl: 'https://api.relintio.com/v1', exceptPaths: ['/healthz'], onError: (error, req) => reportToSentry(error, { url: req.originalUrl }), })); app.use(express.json()); app.get('/', (req, res) => res.send('protected')); app.listen(3000); ``` ## 安装 ``` npm install @relintio/express ``` 需要 Node 18 或更高版本。`express` 是一个对版本有严格限制(`>=4.21.2`)的 peer dependency;引擎则是直接依赖于 `@relintio/agent@^0.11.4`,因此无论你是否自行安装该引擎,环境中都只会保留它的一份副本。该包仅支持 ESM —— 配置了 `"type": "module"`,仅提供单个 `.` 导出且不包含 CommonJS 构建版本 —— 因此调用 `require('@relintio/express')` 将无法解析。 许可证密钥 (licence key) 属于**机密信息**。它是用于签署质询通行证 (passport) 以及所有发往控制平面的出站请求的 HMAC 密钥,因此任何掌握它的人都能伪造通行证并直接穿透 WAF。请将其保存在环境变量中,切勿放入会被下发到浏览器的代码中;可公开的密钥 (publishable keys) 才是专为浏览器端设计的,它们归属于浏览器 SDK,而非本包。 ## 注册 只需挂载一次,且必须在根路由下,**在你的 body parser 以及任何路由之前执行**: ``` app.use(relintio({ licenseKey: process.env.RELINTIO_LICENSE_KEY, apiUrl: '...' })); app.use(express.json()); ``` 如果 body parser 先执行,它就已经把客户端上传的 40 MB 数据读取完毕了,哪怕代理原本打算拦截该请求,这也就意味着服务器已经付出了相应的处理成本。如果路由先执行,那些注册在中间件之前的路由将直接被响应,永远不会接受安全评估。 如果只需保护某个子树路由,请将其挂载到对应的路径下。代理会读取 `req.originalUrl`,因此 `onlyPaths` 和 `exceptPaths` 匹配的是访客实际请求的原始路径,而不是 Express 在剥离挂载点后所保留的路径 —— 例如应写为 `app.use('/api', relintio({ exceptPaths: ['/api/health'] }))`,而不是 `['/health']`。 多次挂载是安全的。处理程序会使用 `Symbol.for('relintio.express.handled')` 标记请求,第二次挂载时会立即调用 `next()`,因此一次页面访问只会被评估、记录和统计一次,而不会产生两次计费。 `relintio(...)` 返回的处理程序会将代理实例附加在 `.agent` 属性上,以备你在极少数情况下需要直接向其发起查询: ``` const guard = relintio({ /* ... */ }); app.use(guard); const rules = await guard.agent.getRules('shop.example.com'); ``` ## 配置 如果 `options` 不是对象,`relintio(options)` 将会抛出异常;随后引擎构造器会分别抛出 `licenseKey required` 和 `apiUrl required`。除了会被本包读取并设置的 `onError` 和 `agentKind` 之外,所有参数都会直接透传给底层引擎。 | 选项 | 默认值 | 含义 | | --- | --- | --- | | `licenseKey` | — | 必填。形如 `UP_LIVE_…`。用于签署通行证和出站请求。属于机密。 | | `apiUrl` | — | 必填。例如 `https://api.relintio.com/v1`。末尾的斜杠会被自动去除。 | | `syncIntervalSeconds` | `10` | 规则集同步的目标频率。底数为 10;在此之上还会应用退避和抖动策略。 | | `rateLimitPerMinute` | `120` | 会被接受并存储,但没有任何逻辑会读取它——参见边界情况。 | | `onlyPaths` | 所有路径 | 仅保护列出的路径。以 `*` 结尾的条目按前缀匹配;其他条目则必须与路径完全匹配。 | | `exceptPaths` | 无 | 跳过列出的路径,判定优先级先于 `onlyPaths`。匹配规则同上。 | | `onlyRegex` | 无 | 类似 PHP 风格的 `/pattern/flags` 字符串,旨在与其他代理保持一致。如果正则表达式无法编译,则会保护所有路径而不是全不拦截。 | | `enforceTlsMinVersion` | `true` | 当 socket 暴露相关信息时,拦截低于 1.2 版本的 TLS 握手。只有显式设为字面量 `false` 才会关闭此项。 | | `onError` | 无 | 发生意外故障时以 `(error, req, res)` 形式被调用。无法改变请求的最终结果。 | | `agentKind` | `express` | 供控制台面板过滤使用的标签。会被截断为 32 个字符。 | `allowSampleRate` 特意没有被设计为配置项。被允许的请求会以固定的 1% 概率上报,平台会在后端将其数值放大还原;如果某个实例以不同的抽样率进行上报,就会导致平台使用错误的常数去校正数据。而针对拦截、质询、诱饵和减速的请求则绝对不会进行抽样。 ## 请求处理流程 首先会在不阻塞当前执行的情况下触发一个节流心跳,随后开始进行路径匹配。发往 `/.well-known/relintio-trap` 和 `/.well-known/aura-trap` 的请求会在一切其他逻辑之前被直接拦截——这些路径只能通过点击代理注入的隐形链接来访问。系统会将 `?up_token` 交换为 `relintio_passport` cookie(包含 `Path=/`、`Max-Age`、`HttpOnly`、`SameSite=Lax`,当 `req.secure` 为真时还会附加 `Secure`),并重定向至纯净路径;如果 token 无效则会返回 `403 Invalid Token`,因为获取 token 的唯一途径就是刚刚成功通过了质询。如果持有合法的 passport cookie,则会直接放行给你的业务处理器,而无需重新评分。 接下来由规则集按以下顺序做出判定:IP 白名单、SEO 安全性、全局黑名单、TLS 版本与指纹、地理位置、被封禁的 CIDR、蜜罐请求头、扫描器签名与爬虫正则、VPN 防护、Referer 校验、自定义 WAF 规则,最后才会计算累加评分。各项信号会被累加并截断至满分 100。 | 信号 | 权重 | 触发条件 | | --- | --- | --- | | 空的 User Agent | +50 | 完全不存在 `User-Agent` 请求头 | | 频率突增 | +35 | 基于 IP 的令牌桶已耗尽 | | 过短的 User Agent | +25 | 存在该请求头但长度不足 10 个字符 | | 缺少 `Accept-Language` | +20 | 缺失该请求头 | | 泛用的 `Accept` | +15 | 该请求头缺失或 exactly 为 `*/*` | | 无 Referer 的 POST | +15 | 属于 `POST` 请求且缺失 `Referer` | | 扫描器关键字 | +15 | UA 命中了同步规则库中的关键字 | | `Connection: close` | +10 | 该请求头的值为 `close` | | 等级 | 分数 | 响应行为 | | --- | --- | --- | | ALLOW | 0–39 | `next()` | | SLOW | 40–59 | 延迟两秒,随后执行 `next()` | | CHALLENGE | 60–74 | `302` 重定向至托管的质询页面 | | DECOY | 75–84 | 返回 `200` 状态码及维护页面,或在配置了 `cloak_html` 时返回该内容 | | BLOCK | 85–100 | 返回 `403` 拦截页面,或在配置了 `cloak_html` 时返回 `200` 及该内容 | 速率限制器采用的是基于 IP 的令牌桶算法,每秒生成 8 个令牌,最大突发上限为 24,持续匀速恢复,并根据路由进行缩放:在 `/login`、`/auth` 和 `/wp-login` 上系数为 0.4,在 `/wp-admin` 上为 0.5,在 `/api/` 上为 0.7,在 `/assets/` 上为 2.0。一旦令牌耗尽会贡献 +35 的评分,但其本身绝对不会直接导致拦截请求。 ## 故障边界 这正是开发本包的核心意义所在,其包含的两方面场景均在 `test/express.test.mjs` 中得到了测试覆盖。 **异常永远不会传递给 `next(err)`。** 引擎自带的 Express 中间件曾经采用 `.catch(next)` 作为兜底,而 Express 的默认错误处理器会直接返回 500 状态码——这就导致安全代理自身的一个 bug 直接演变成了客户端接口的宕机,而这也正是系统整体设计中坚决不可接受的故障场景。在本包中,无论是异步拒绝还是同步抛出的异常,最终都会调用同一个 `release()`,该函数会以无参数的形式调用 `next()`。引擎中所有符合预期的失败路径也都秉持着同样的逻辑:无论是无法连通控制平面、`getRules` 被拒绝、地理位置查询无法解析,还是收到了不包含可读规则集的 `200` 响应,这些情况全都会选择直接放行请求,而不是将其无限期挂起。 **被拦截的请求会直接返回响应,而不会继续流转。** `#respondBlock`、`#respondSoftBlock`、`#respondDecoy` 以及地理拦截逻辑都会写入状态码并结束响应,它们都不会调用 `next`。`release()` 在继续放行前会检查 `res.headersSent` 和 `res.writableEnded`,因此,如果异常是在代理已经写完拦截页面*之后*抛出的,系统将保持该响应原封不动,而不会让你的路由覆盖掉它——否则将会触发 Express 中的 `ERR_HTTP_HEADERS_SENT` 报错,导致我们的缺陷表现为你方响应损坏。 唯一在终止分支内部继续调用放行逻辑的情况是配置为 `challenge_disabled` 且 `fallback: "allow"`:即控制平面响应称该许可证的质询功能已被关闭,并指示了替代处理方式。任何其他情况——无论是回退策略为 `block`、缺失回退配置、发生超时,还是遇到不可用的请求体——都会直接写入 `403`。 ## 边界情况 **除非你主动监听,否则此处的异常是静默的。** 如果没有配置 `onError`,将不会输出任何日志;请求会被直接放行,且不留下任何故障痕迹。对于一个绝不应当成为页面加载失败元凶的安全代理而言,这是正确的权衡,但这也就意味着上报器 (reporter) 成了暴露问题的唯一出口。如果上报器自身抛出了异常,也会被刻意忽略——因为它是阻挡自身故障演变为你方 500 错误的最后一道防线。 **许可证一旦过期即会启动“失败关闭”模式,且在重启之前会一直保持该状态。** 当 `/agent/verify` 返回 `expired` 或 `outdated` 时,代理会返回 `503` 状态码以及一个“Subscription Expired”(订阅过期)页面,该页面每六秒会自动刷新一次。该状态会被持久化到磁盘上,因此即使重启也会维持该状态,直到一次成功的同步将其解除。当状态为 `expired` 时,`getRules` 会跳过刷新,因此对于一个长时间运行的进程来说,仅仅续费许可证是无法解除 `503` 状态的——必须让进程重新启动。这是整个代理逻辑中唯一不会“失败放行”的路径。 **`rateLimitPerMinute` 实际上不起任何作用。** 构造器会对其进行校验并存储,但引擎中没有任何一行代码会读取它。真正的限流桶就是前文描述的那个固定为每秒 8 个、突发上限 24 且基于路由缩放的机制。设置此参数既不会报错,也无法作为控制开关。 **启动后的第一个请求需承担同步操作的时间成本。** 系统不存在后台任务。如果内存或磁盘中没有规则集,`getRules` 就会在处理该请求的过程中等待 `/agent/verify` 的响应;此后,当后续请求继续依据内存中已有的规则副本进行评分时,陈旧的规则集会在后台进行异步刷新。同步失败时会采用二进制指数退避策略,上限为 5 分钟,并伴随 80% 到 120% 的抖动。 **磁盘缓存会进行完整性校验,但加密。** 规则会被镜像存储为 `/up_rules_.json` 文件,并附带一个由许可证密钥签署的 HMAC 校验文件;如果 MAC 不匹配或缺失,就会删除该文件并强制重新拉取。不过,只要任何程序有权限读取你的临时目录,就依然能直接查看其中的内容。 **`domain` 字段取自 `Host` 请求头,而该请求头可由客户端控制。** 该名称会被用于每次同步和日志上报,同时也是构建质询验证 `return_url` 所使用的主机名。如果你无法信任该字段,请在流量入口层对其进行规范化或直接拒收。 **内容注入功能会对响应进行修补。** 当规则集启用了 Obsidian 时,代理会包装 `res.send` 和 `res.end` 方法,以便在 `text/html` 响应的 `` 标签前拼接入一段蜜罐链接和客户端脚本。如果响应的 `Content-Encoding` 不是 `identity`,则不会进行任何修改。 ## 相关链接 - [官方文档](https://relintio.com/docs) - [快速入门](https://relintio.com/docs/quickstart/express) - [API 参考](https://relintio.com/docs/api-reference) - [许可证](https://relintio.com/licenses) 安全漏洞报告请发送至 **support@relintio.com**,请勿提交至公开的 issue 中。 ## 授权协议 专有软件。详见 [`LICENSE`](./LICENSE) —— 即 Relintio 专有许可协议。该协议授权你仅在持有有效、活跃许可证的前提下,通过集成与运行 Relintio 服务来使用本包,并保留所有其他权利:不得再分发、不得修改、不得逆向工程,且不得移除专有权利声明。本软件按“原样”提供,不附带任何保证。
标签:AMSI绕过, Express, GNU通用公共许可证, MITM代理, Node.js, Syscall, WAF, Web开发, 威胁检测, 安全防护, 机器人缓解, 自定义脚本