Files
tamakyi 6b0a23c38e feat: v0.12.2 —— 跨域只读支持(面板可部署在别处)
用户诉求:管理端与前端完全分离,本地探针只跑 /admin,前端部署到 GitHub Pages
并硬编码管理地址,探测仍在本地常驻。用户自己搭隧道给本地套 HTTPS。

硬限制(务必先看):GitHub Pages 强制 HTTPS,而浏览器「混合内容」策略禁止
HTTPS 页面 fetch HTTP 资源 —— 这与 CORS 无关,改设置也没用。
因此 settings.dataUrl 必须与面板页面同为 HTTPS。

服务端
- 只读接口(/api/status、/api/monitors、/api/events、/api/v2/getMonitors、
  /api/refresh)按 settings.corsOrigins 白名单回显 Access-Control-Allow-Origin
- /api/admin/* 一律不放行跨域,预检直接 403 —— 外部页面既读不到也改不了
- 绝不使用 '*':白名单里写 '*' 不放行任何来源

前端
- settings.dataUrl 存在时,所有 api/ 相对路径自动改写到该地址
- 数据源标记为「远程探针(跨域)」,页脚可区分
- 管理界面新增两项配置:跨域数据源地址、允许跨域的页面地址(改完立即生效)

关键设计:origin 匹配必须考虑端口
origin 是「协议 + 主机 + 端口」的完整组合。最初白名单存 http://localhost(无端口),
导致本地前端跑在 localhost:5173 时被拒 —— 浏览器实测 Failed to fetch 而 curl 能过。
修法:回环主机(localhost / 127.0.0.1)忽略端口放行,其余要求完整 origin 精确匹配。

测试:verify-security 25→40 项,新增 [D3] CORS 白名单 14 条 + [D4] '*' 写法 1 条。
全项目 8 套 334 项通过。

附记一个测试上的教训:跨域 fetch 时 JS 读不到 Access-Control-Allow-Origin
(CORS 规范规定只有同源能读),所以断言「是否放行」要看 fetch 有没有拿到数据,
不能看响应头 —— 我一开始用 headers.get() 判断,拿到 null 误以为失败。
2026-10-14 16:19:31 +08:00

16 KiB
Raw Permalink Blame History

安全说明:刷新滥用防护、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 仅本轮降级并在按钮提示 自动

配置参考

{
  "settings": {
    "refreshInterval": 30,        // 面板刷新节奏
    "probeOnRefresh": true,       // 刷新是否重新探测
    "allowRefresh": true,         // false = 完全关闭强制探测(公网部署建议)
    "refreshLocalOnly": true,     // 仅内网/回环来源可触发
    "refreshCooldownSec": 5       // 每来源冷却
  }
}

单个目标也可退出「刷新即检测」,避免被高频探测压垮配额:

{ "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 配置示例

{
  "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 并只暴露静态快照

对外发布状态页的推荐组合

// local.monitors.json(不入库,仅本机/CI 环境存在)
{
  "monitors": [
    { "id": "blog", "url": "https://blog.example.com" },
    { "id": "nas", "host": "192.168.1.10", "port": 5000 }
  ]
}
// 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):

// 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 后重新绑定

验证方式

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