Skip to content

fix: 修复审计发现的崩溃、权限、CSRF/SSRF 与配额问题 - #4

Merged
SMNETSTUDIO merged 1 commit into
mainfrom
fix/audit-batch
Aug 28, 2026
Merged

fix: 修复审计发现的崩溃、权限、CSRF/SSRF 与配额问题#4
SMNETSTUDIO merged 1 commit into
mainfrom
fix/audit-batch

Conversation

@SMNETSTUDIO

Copy link
Copy Markdown
Owner

对上一轮审计逐条自行验证后修复。有一条审计结论被我推翻、一条被改小,都在下面标出。

🔴 面板会被打进重启循环(已实测复现)

src/instance.js_tickTps 用了 props['rcon.port'],而该方法里根本没有 props 变量(同名局部变量在另一个方法里):

✘ 确认抛出: ReferenceError: props is not defined

三个条件叠起来才致命:

  1. 触发条件是面板自己推荐的配置 —— 实例在跑 + enable-rcon=true + 有密码。面板有「一键开启 RCON 并生成随机密码」,README 力推的 TPS 监控也必须开 RCON。
  2. 指标循环回调没有 try/catch,全局也没有 uncaughtException 兜底 → 抛出即进程退出。
  3. PM2 autorestart 拉起后 resumeInstances() 又把服务端拉起来 → 10 秒后再崩,max_restarts: 10 之后彻底停摆。

修了根因,另加两道纵深防御:指标循环 per-instance try/catch、server.js 全局兜底。

我第一次测试时没复现,差点当误报放掉 —— 函数首行 if (!this.proc …) return,我没设 proc 就提前返回了。

🔴 协作者收不到任何实时推送

resolveAllowed 原样展开 collaborators,而 bus.jsincludes(username)字符串比对。但 PUT /collaborators 一律写成 {name, role} 对象:

allowed.includes('alice') = false   ← 当前 UI 添加的协作者
allowed.includes('bob')   = true    ← 老式纯字符串协作者

控制台不滚动、状态不刷新,而 REST 正常 —— 表现为「页面像卡住了」,极易误判成前端问题。

🟠 权限分档

问题 实质
OPERATOR_WRITES 启停正则不匹配 正则要 /start,真实路由是 /:iid/server/:action。方向是失效关闭(不越权),但 operator 档形同虚设 —— 想给启停权只能给 manager,而 manager 能改文件改配置,净效果是权限被迫放大
viewer 能读凭据 /rcon(明文密码)、/properties/files/content/files/download/backups/:id/download 全在 viewer。「只读」档能把实例全部秘密拿走
cloneisOwnerOrAdmin 协作者能把别人的实例连同世界和密钥复制成自己完全掌控的实例

🟠 CSRF / SSRF

CSRF:全站无防护,只有 SameSite=Lax。新增 Origin/Sec-Fetch-Site 校验。放行 Bearer Token(浏览器不会自动附带,跨源加头会触发预检)与无头的非浏览器客户端 —— smoke 全程用裸 fetch,73 条老用例一条没红,证明脚本没被误伤。

SSRF:新增 utils.checkOutboundUrl,拒绝环回/私有/链路本地(含 169.254.169.254 云元数据)/CGNAT/组播,IPv4-mapped IPv6 按内嵌 v4 判定,并对 webhook 关掉重定向跟随。ARCHITECTURE 里那句「别把面板变成内网探测器」之前只做到了挡协议。

🟠 配额

  • 恢复备份完全不校验磁盘配额 —— 「五处校验」之外的第六条写入路径。inspectBackup 改用 tar -tzvf 拿真实未压缩体积,按净增长校验(恢复是覆盖式的,同等大小的包净增长≈0,不该被拦)。
  • Modrinth 预检写死 64MB 而下载无界。改用 Modrinth 自己给的 file.size,并在下载中按已写字节持续复查(声明体积可能是假的)。

其余

notify 凭据明文回显 · 时钟回拨让 interval 任务永久静默停摆 · 玩家名正则 \w 不含 .(Bedrock 玩家能被封却不算在线)· 放弃的 import 空壳删不掉 · 删实例缺 archiveBusy 守卫 · motd 死参数

⚠️ 两处我对审计结论的修正

  1. 「导入空壳永远删不掉」不成立(我此前也这么说过,是错的)。state 不落盘 —— meta() 不含它,构造函数硬编码 stopped重启面板就能删。 准确说法是"不重启就删不掉",是 UX 毛病不是死锁。仍然修了,没理由让人为删一个空壳去重启面板。
  2. 「用户填的 MOTD 被静默丢弃」被改小。查下来前端根本没有 MOTD 输入框,路由也没解构 motd,只是个永远为 undefined 的死参数,无用户可见影响。

验证

  • npm test:85 passed, 0 failed(原 73)。新增 securitySuite:CSRF 四条 / SSRF 三条 / 凭据掩码三条 / 放弃的空壳可删两条
  • collabRoleSuite 在空面板上会跳过(它需要已存在的实例),所以那批权限用例这次没真正执行。已对运行中的面板手工验证:viewer /logs 200、/rcon /properties /files/content 403;operator /server/stop 200(修复前是 403)、PATCH 403、clone 403、建备份 200
  • tar -tzvf 解析含空格文件名实测正确,体积精确
  • SSRF 判定 16 个 IP 全对,CSRF 九种场景真值表全对

🤖 Generated with Claude Code

## 崩溃类

**_tickTps 引用未定义的 props**(instance.js)—— 该方法里根本没有 `props` 变量
(同名局部变量在另一个方法里),开了 RCON 的实例每 10 秒抛一次 ReferenceError。
而指标循环的回调没有 try/catch、进程也没有 uncaughtException 兜底,于是
**面板进程直接退出**;PM2 拉起后 resumeInstances 又把服务端重启一遍,10 秒后再崩。
触发条件恰好是面板自己推荐的配置(一键开 RCON + TPS 监控要求开 RCON)。
改用 this.getProp(),并补上两道纵深防御:指标循环 per-instance try/catch、
server.js 加全局 uncaughtException/unhandledRejection 兜底。

**tar spawn 缺 error 监听**(backups.js 的 createBackup/restoreBackup)——
同文件的 inspectBackup 是有的。spawn 失败只触发 error 不触发 exit,不挂就是
uncaughtException,而且 Promise 永不 settle → archiveBusy 锁永久泄漏,
该实例之后所有压缩操作恒 409。

**restart 定时器不可取消**(instance.js)—— 手动重启那个 1 秒 setTimeout 没存
handle,cancelAutoRestart 只清崩溃定时器。重启后 1 秒内删实例:目录被 rm,
定时器照常触发 start(),_appendLogFile 的 mkdirSync 又把目录建回来。

## 协作者实时流失效

resolveAllowed 原样展开 collaborators,而 bus 用 includes(username) 做字符串比对 ——
PUT /collaborators 一律写成 {name,role} 对象,所以**通过当前 UI 添加的协作者收不到
任何 log/state/metrics/players 事件**。REST 正常、SSE 静默失效,极易误判成前端问题。
改走已有的 collaboratorList() 归一化。

## 权限分档

- OPERATOR_WRITES 的启停正则是 /^\/(start|…)$/,而真实路由是 /:iid/server/:action,
  req.path 为 /server/start —— 永不匹配,启停被判成 manager。方向上是失效关闭
  (不越权),但 operator 档因此形同虚设:想给人启停权只能给 manager,而 manager
  能改文件改配置,净效果是权限被迫放大。
- viewer 能读 /rcon(明文密码)、/properties(整个 server.properties)、
  /files/content、/files/download、/backups/:id/download —— "只读"档能把实例的
  全部秘密拿走。新增 MANAGER_READS 把这几条 GET 提到 manager。
- clone 缺 isOwnerOrAdmin:协作者能把别人的实例连同世界和密钥复制成自己完全
  掌控的实例,绕过"协作者不能把实例拿走"的边界。

## CSRF / SSRF

- 全站无 CSRF 防护,只有 SameSite=Lax。新增 Origin/Sec-Fetch-Site 校验中间件,
  放行 Bearer Token(浏览器不会自动附带,跨源加头会触发预检)与无头的非浏览器
  客户端,不误伤脚本。
- webhook / Discord / S3 / WebDAV / OAuth 端点只校验协议不校验地址,而"测试推送"
  会回显每个通道的错误 —— 这是个带回显的内网探测原语。新增 utils.checkOutboundUrl,
  拒绝环回/私有/链路本地(含 169.254.169.254 云元数据)/CGNAT/组播,IPv4-mapped
  IPv6 按内嵌 v4 判定,并对 webhook 关掉重定向跟随。

## 配额

- **恢复备份完全不校验磁盘配额** —— 五处校验之外的第六条写入路径。
  inspectBackup 改用 tar -tzvf 拿到未压缩体积,按净增长(归档体积 - 当前占用)校验,
  增量链按各归档之和取上界。
- Modrinth 安装预检是写死的 64MB,而下载是无界流式写入。改用 Modrinth 自己给的
  file.size 预检,并在下载过程中按已写字节持续复查(声明体积可能是假的)。

## 其余

- notify 的 webhook/Discord URL 与 telegramToken 明文回显(backupRemote 早已掩码,
  同一响应里一半掩一半不掩)。补 maskNotify 与掩码回传保护。
- 时钟回拨会让 lastRun 落在未来,interval 任务在时钟追上前完全不执行而界面无异常。
- 玩家名正则 \w{1,16} 不含 . 和 -,Bedrock/Floodgate 玩家不入在线列表和 playtime,
  但封禁接口用的是 [\w.-] —— 同一个名字能被封却不算在线。
- 中途放弃的 import 空壳删不掉(state=importing,唯一出路 finalize 要有效压缩包),
  只能靠重启面板让 state 变回 stopped 这种非显然的办法。DELETE 放行 importing,
  并补上 archiveBusy 守卫(压缩进行中删实例会让 tar 写出残缺产物)。
- installInstance 的 motd 是死参数,调用方从来不传。

## 测试

smoke 73 → 85 项。新增 securitySuite(CSRF 四条 / SSRF 三条 / 凭据掩码三条 /
放弃的空壳可删两条),并在 collabRoleSuite 里补 operator 启停与 viewer 读凭据。

注:collabRoleSuite 需要一个已存在的实例,空面板上会跳过 —— 这批权限用例
已另行对运行中的面板手工验证(viewer /logs 200、/rcon /properties /files/content
403;operator /server/stop 200、PATCH 403、clone 403、建备份 200)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@SMNETSTUDIO
SMNETSTUDIO merged commit ae5f716 into main Aug 28, 2026
2 checks passed
@SMNETSTUDIO
SMNETSTUDIO deleted the fix/audit-batch branch August 28, 2026 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant