# 安全说明:刷新滥用防护、SSH 探测与信息泄露控制 面向「公网可访问的状态页」这个前提编写。核心结论先给: | 场景 | 建议 | | --- | --- | | 本地 / 内网自用 | 默认配置即可,无需额外改动 | | 公网发布 | 静态快照(GitHub Pages / EdgeOne)或 Cloudflare Worker + `REFRESH_TOKEN`;**不要把 Node 探针直接暴露到公网** | | 目标含内网资产 | 用 `local.monitors.json` 分层,公开配置只留 `id/name/group`,并开 `hideTargets` + `redactDetails` | --- ## 一、`/api/refresh` 的滥用风险与分层防护 「刷新即重新检测」意味着**一个未鉴权的 GET 请求会触发一次对所有目标的出站连接**。滥用的三种形态: 1. **探测放大器**:任何人反复调用,把本服务当作对目标站点施压的跳板(借刀杀人) 2. **资源耗尽**:每次强制刷新都会对所有目标发起连接,慢目标(如超时 10s)会长时间占用并发 3. **目标配额消耗**:被诱导高频探测的第三方目标可能限流甚至封禁我们的出口 IP(真实案例:GitHub 未认证 API 上限 60 次/小时,被高频探测打满后返回 403,面板如实记为离线) ### 防护措施(已实现) | 位置 | 措施 | 默认 | | --- | --- | --- | | Node 探针 | 仅允许内网/回环来源触发;**只信任 socket 对端地址,不解析 `X-Forwarded-For`**(该头部可被伪造) | 开启 | | Node 探针 | 每来源冷却 `settings.refreshCooldownSec`,超限返回 `429` + `Retry-After` | 5s | | Node 探针 | 轮次去重:多标签页/多客户端共享同一轮探测;2s 内的重复请求直接复用快照 | 开启 | | Node 探针 | 单轮上限 22s 兜底(`FORCED_MAX_WAIT_MS`),防止卡死连接长期占用请求 | 开启 | | Node 探针 | 总开关 `settings.allowRefresh: false`,完全关闭强制探测(只读模式) | 可配 | | Node 探针 | 拒绝响应只回错误码(`{"error":"refresh-throttled"}`),不回显目标与内部细节 | 固定 | | Worker | **必须配置 `REFRESH_TOKEN` 才开放** `/api/refresh`,否则一律 403;token 比较用等长逐字节异或,避免按字符提前返回的时序差异 | 默认关闭 | | 面板 | 收到 401/403 自动退回只读轮询(页脚标注「只读」);429 仅本轮降级并在按钮提示 | 自动 | ### 配置参考 ```jsonc { "settings": { "refreshInterval": 30, // 面板刷新节奏 "probeOnRefresh": true, // 刷新是否重新探测 "allowRefresh": true, // false = 完全关闭强制探测(公网部署建议) "refreshLocalOnly": true, // 仅内网/回环来源可触发 "refreshCooldownSec": 5 // 每来源冷却 } } ``` 单个目标也可退出「刷新即检测」,避免被高频探测压垮配额: ```jsonc { "id": "github-api", "interval": 300, "probeOnRefresh": false } ``` ### 已知限制(请按需叠加外部措施) - **限流在进程内**:多个探针实例不共享计数;需要强约束时请在网关(Nginx / Cloudflare Rate Limiting)再加一层 - **反向代理场景**:因为不信任 `X-Forwarded-For`,前置代理时所有请求都来自代理的内网地址,`refreshLocalOnly` 会失效——此时应设 `allowRefresh: false`,或自行在代理层做认证与限流 - Worker 的同隔离体去重是 best-effort(不同边缘节点各自计数) --- ## 二、SSH 这类服务怎么探测 支持三种探测类型,按「侵入性从低到高」: | 类型 | 做什么 | 适合 | | --- | --- | --- | | `tcp` | 仅建立 TCP 连接,测连通性与连接耗时 | 任意端口存活监控 | | `banner` | 连接后**被动读取服务端主动发送的标识串**,可与规则比对 | SSH / SMTP / FTP / POP3 / IMAP / HTTP 等主动问候的协议 | | `http` | 完整 HTTP 请求,读状态码 | Web 服务 | ### SSH 配置示例 ```jsonc { "id": "ssh-gateway", "name": "跳板机 SSH", "group": "基础设施", "type": "banner", "host": "10.0.0.5", // 真实地址建议放 local.monitors.json "port": 22, "timeout": 5, "interval": 300, "expectBanner": "^SSH-2\\.0-", // 策略:必须是 SSH 2.0 "revealBanner": false // 默认不回显标识串(含软件版本) } ``` `expectBanner` 让探测同时具备**合规检查**能力:例如要求 `^SSH-2\.0-OpenSSH_[89]`,若目标降级到旧版本,探测即判为离线并触发面板告警。 ### 明确不做的三件事 1. **不做 SSH 登录/认证尝试**:会触发目标侧 fail2ban/sshguard 封禁,需要凭据,还会在目标留下审计噪音 2. **不发送任何应用层数据**:因此需要客户端先行发包的协议(Redis 的 `PING`、MySQL 握手)暂不适用——这是刻意保留的安全边界,不是能力缺失 3. **不在标识串不匹配时回显实际收到的内容**:避免把「目标是什么版本」写进公开的 `status.json` banner 探测的副作用仅有一次 TCP 连接与读取,与端口扫描器不同:不产生登录失败记录,也不改动目标任何状态。 --- ## 三、信息泄露控制 状态页天生是公开的,泄露面有三处,逐个封堵: | 泄露面 | 风险 | 措施 | | --- | --- | --- | | `public/monitors.json` | 面板必需文件,静态部署必然公开,含真实 URL / 内网主机端口 | **私有配置分层**:公开文件只写 `id/name/group`,真实地址放仓库根的 `local.monitors.json`(已 gitignore),探针按 `id` 合并覆盖 | | 通知凭据 | SMTP 密码、MeoW 昵称(等同密钥)泄露 → 被冒名发信/推送 | 与地址一样只放 `local.monitors.json` 或环境变量;`public/monitors.json` 中 `notify.channels.*` 保持 `enabled: false` | | `status.json` / `/api/status` | 探针输出,Pages 部署会公开,`detail` 可能含地址、版本、错误码 | `settings.redactDetails: true` → detail 只保留「正常/异常」;TCP 详情已默认不含地址;UptimeRobot 镜像目标的地址在 `hideTargets` 开启时不下发;事件时间线在 `redactDetails` 下只保留类型与时间 | | 面板渲染 | 直接把地址、错误详情展示给访客 | `settings.hideTargets: true` → 不渲染任何地址(含 tooltip),显示「已隐藏」 | | UptimeRobot API Key | 泄露后可读取(只读 Key 不能改配置,但会暴露监控清单) | 只放 `local.monitors.json`(gitignore)或环境变量 `UPTIMEROBOT_API_KEY`;`public/monitors.json` 里 `apiKey` 必须留空 | | `/api/v2/getMonitors`(兼容接口) | 无鉴权,暴露全部目标与可用率 | 内容与公开面板一致,故默认开放;如需收敛请在其前方加带认证的代理,或设 `allowRefresh: false` 并只暴露静态快照 | ### 对外发布状态页的推荐组合 ```jsonc // local.monitors.json(不入库,仅本机/CI 环境存在) { "monitors": [ { "id": "blog", "url": "https://blog.example.com" }, { "id": "nas", "host": "192.168.1.10", "port": 5000 } ] } ``` ```jsonc // public/monitors.json(公开) { "settings": { "hideTargets": true, "redactDetails": true }, "monitors": [ { "id": "blog", "name": "博客站点", "group": "网站", "type": "http", "interval": 60, "timeout": 10 }, { "id": "nas", "name": "家庭存储", "group": "基础设施", "type": "tcp", "interval": 300, "timeout": 5 } ] } ``` 这样即使 `status.json` 与面板完全公开,访客也只能看到「有哪些服务、是否在线、延迟、可用率」。 补充:`dataFilter`(`both` / `config` / `uptimerobot`)只决定**展示与快照输出**哪些目标,不影响探测行为;如果希望某个来源的数据完全不进入公开快照,应使用 `config` 过滤或把对应目标从配置中移除。 ### 仍然会被公开的信息(请自行评估) - **服务数量与名称**:命名不要包含敏感语义(不要叫「内网财务系统」) - **延迟特征**:多次采样可反推大致网络位置 - **可用率与故障时间**:这本身就是状态页要公开的信息 - **GitHub Actions 场景**:CI 读不到未提交的 `local.monitors.json`,需用 repository secrets 在 workflow 中生成,或只监控公开目标 --- ## 四、管理后台(`/admin`)的鉴权边界 v0.12.0 起,所有会改配置的操作都放在 `/admin`,**必须绑定并通过两步验证才能进入**。 ### 部署形态与可写能力 管理端**只存在于「本地 Node 探针」这一种部署形态**。各形态的可写面: | 部署形态 | 面板可读 | 探测 | 管理端(2FA) | | --- | --- | --- | --- | | 本地 Node | ✅ | ✅ | ✅ | | Cloudflare Workers | ✅ | ✅ | ❌ 403 | | GitHub Pages / EdgeOne Pages(纯静态) | ✅ | ❌(仅展示 CI 产出的快照) | ❌ 不存在 | 边缘形态访问 `/admin` 或 `/api/admin/*` 会得到 **403 + 指向本文的提示**,而不是 404 —— 明确表达「这里不提供写能力」,避免访客把 404 误读成路径写错。 这不是「没做」,而是这些能力依赖本地状态:2FA 档案与会话存在 `data/` 目录与进程内存中,而 Workers 无文件系统、isolate 之间不共享内存(KV 又是最终一致,无法承载「恢复码一次性使用」这类语义)。 ### 远程访问 探针在内网时若需从外网访问 `/admin`,用 `settings.admin.allowRemote` 打开远程访问(**默认关闭**,此时只监听 `127.0.0.1`): ```jsonc // local.monitors.json { "settings": { "admin": { "allowRemote": true } } } ``` **怎么把流量送到这个端口由你决定**(反向代理 / 隧道 / VPN / 直连公网 IP),本项目不做任何假设。唯一要提醒的是:打开后该端口同时伺服公开面板,也就是说 `/` 与 `/api/status` 也会跟着对外可达 —— 这两项本身只读,但目标名称、地址与可用率会一并暴露。若不希望如此,请在你自己的那层按路径收窄。 ### 配置接口不得泄露密钥 `/api/monitors` 与 Worker 上的同名接口都**无鉴权**,因此对外响应一律剥离 `uptimeRobot.apiKey`(保留 `enabled` 等非敏感字段供面板读取)。探针内部同步 UptimeRobot 仍使用完整配置,剥离只作用于 HTTP 出口。`/api/status` 与静态 `monitors.json` 同样不含私有层内容。 ### 跨域只读白名单(CORS) 面板部署在别处(GitHub Pages / Cloudflare)、探针在另一台机器时,需要让指定页面跨域读取状态与配置。配置项:`settings.dataUrl`(面板去哪里取数据)与 `settings.corsOrigins`(允许哪些页面读取)。 核心风险是「任意网站都能读走监控数据」,措施: | 风险 | 措施 | | --- | --- | | 开放成 `*` 导致任意网站可读 | **代码里不存在 `*` 分支**:白名单里写 `*` 不会放行任何来源(已加断言) | | 本机回环被任意端口冒用 | 默认只放行 `localhost` / `127.0.0.1` 且**忽略端口**(本地前端常跑 5173);其余必须精确匹配完整 origin | | 前缀伪造域名(`https://you.github.io.evil.com`) | 精确字符串匹配,不做子串判断(已加断言) | | 协议降级(`https://localhost` 冒用 http 的默认项) | 协议必须一致才走回环豁免(已加断言) | | 中间缓存串味(把 A 的响应给 B) | 回显具体 origin 并带 `Vary: Origin` | | **外部页面读取或改写管理端** | `/admin` 与 `/api/admin/*` **一律不带 `Access-Control-*` 头**,预检直接 403(已加断言) | | 外部页面借 CORS 触发有副作用的调用 | `/api/admin/*` 完全不放行;`/api/refresh` 另有 `refreshDecision` 的限流与 `refreshLocalOnly` 兜底 | | 跨域读到密钥 | 跨域响应与同源响应走同一套剥离逻辑,`uptimeRobot.apiKey` 不会回吐(已加断言) | ### 威胁模型与对应措施 | 威胁 | 措施 | | --- | --- | | 未绑定就被抢先接管管理端 | 绑定需 `SHIROKO_ADMIN_SETUP_KEY`;未绑定时 `/api/admin/*` 除绑定流程外**一律 403**;未设置该环境变量时绑定入口直接关闭(503) | | 未授权者经外部网络访问管理端 | 打开 `allowRemote` 后,防护完全依赖管理端的 TOTP 校验 + 会话哈希 + 失败限流三重;默认不监听外部地址 | | 配置接口泄露 UptimeRobot Key | 对外响应剥离 `uptimeRobot.apiKey`;内部同步仍用完整配置(有断言覆盖两侧) | | 面板成为探测他人管理面的跳板 | 管理入口链接只接受 `http`/`https`,`javascript:` 等协议被服务端拒绝;不配置则不渲染入口 | | 恶意枚举 6 位码(100 万种) | 按 IP 计数,连续 5 次失败后指数退避锁定(1m → 2m → 4m …上限 30m);锁定期间即使验证码正确也拒绝 | | 会话 id 被窃取 | id 为 32 字节随机数,**服务端只存 SHA-256 哈希**;cookie 为 `HttpOnly`(JS 读不到)+ `SameSite=Strict`;30 分钟空闲失效、最长 2 小时;进程重启清空全部会话 | | 截图复用验证码 | 记录最近使用的 TOTP counter,**同一时间步的码用过即作废** | | 恢复码泄露 | 只存 SHA-256 哈希(带随机盐),一次性使用,用后立刻从档案中移除 | | 伪造来源 IP 绕过限流 | **只信任 socket 对端地址,不解析 `X-Forwarded-For`**(可被任意伪造)。若前面挂了反向代理/隧道,对端地址是代理节点,因此限流是按代理 IP 而非真实客户端统计的 —— 这是有代理层时的已知粒度损失 | | 篡改请求 | `Path=/` + `HttpOnly` + `SameSite=Strict`;请求体上限 64KB,超出返回 413;参数逐项白名单校验(如 `probeScope` 只接受三个枚举值、`adminUrl` 只接受 http/https) | | 通过管理端把密钥写进仓库 | **所有写操作只落 `local.monitors.json`**(私有、已 gitignore),绝不改 `public/monitors.json` | ### 已知限制(请按需叠加外部措施) - **会话 cookie 未设 `Secure`**:纯 HTTP 下设置 `Secure` 会导致 cookie 完全不发送,管理端直接不可用。部署到公网时请放在 HTTPS 反向代理之后,由代理层负责加密传输 - **绑定口令 `SHIROKO_ADMIN_SETUP_KEY` 只能从环境变量读取**,不写入任何文件;重启后需要重新设置才能再次绑定或重置 - **2FA 档案(`data/admin-2fa.json`)是明文 secret**:该文件权限应仅限运行用户,且 `data/` 已在 `.gitignore` 中 - **会话存在内存里**:多实例部署时各实例的会话不共享,需要粘性会话或改为单实例 - **忘记 2FA 且忘记绑定口令**时只能停服务、删除 `data/admin-2fa.json` 后重新绑定 ### 验证方式 ```bash node scripts/verify-admin.cjs # 102 项:RFC 向量 / 绑定 / 会话 / 防重放 / 限流 / HTTP 边界 / 页面流程 ``` 该脚本会先用 **RFC 4226 与 RFC 6238 官方测试向量**校验自实现的 TOTP(这是整个 2FA 的地基),再覆盖绑定流程、会话生命周期、恢复码一次性、防重放、失败限流,以及「未绑定时所有写接口必须 403」这类鉴权边界。 ## 五、其他基线 - `.gitignore` 已包含 `local.monitors.json` 与 `.dev.vars`(Cloudflare 本地密钥) - Worker 的 `REFRESH_TOKEN` 用 `wrangler secret put REFRESH_TOKEN` 管理,不要写进 `wrangler.toml` - **UptimeRobot API Key 在 Worker 上必须用 `wrangler secret put UPTIMEROBOT_API_KEY`**,写进 `public/monitors.json` 会随静态资源公开。只用 `ur*` 开头的只读 Key - **公开面板**(`/`)仍是只读前端:无 cookie、无写入接口、无服务端会话;渲染配置字段时统一做 HTML 转义(`escapeHtml`),XSS 面仅剩配置自身。管理能力全部隔离在 `/admin` 与 `/api/admin/*`(需会话) - Node 探针同时充当静态服务器,**默认只监听 `127.0.0.1`**;需要远程访问时用 `settings.admin.allowRemote=true` 显式打开,并确保前置代理已启用 HTTPS