diff --git a/scripts/smoke.js b/scripts/smoke.js index a55c589..3a3387b 100644 --- a/scripts/smoke.js +++ b/scripts/smoke.js @@ -16,10 +16,14 @@ const TOTP_SECRET = process.argv[5] || process.env.MCSP_SMOKE_TOTP || ''; let cookie = ''; let passed = 0, failed = 0; -async function req(method, path, body) { +async function req(method, path, body, extraHeaders) { const res = await fetch(BASE + path, { method, - headers: { 'Content-Type': 'application/json', ...(cookie ? { Cookie: cookie } : {}) }, + headers: { + 'Content-Type': 'application/json', + ...(cookie ? { Cookie: cookie } : {}), + ...(extraHeaders || {}), // 模拟浏览器的 Origin / Sec-Fetch-Site,测 CSRF 用 + }, body: body ? JSON.stringify(body) : undefined, redirect: 'manual', }); @@ -241,6 +245,20 @@ async function collabRoleSuite(iid) { // operator:能做日常运维,但改不了配置 check('collab operator: 可建备份', (await as(users.operator, 'POST', '/backups')) === 200); check('collab operator: 禁止改配置', (await as(users.operator, 'PATCH', '', { name: 'x' })) === 403); + /* 启停的真实路由是 /server/:action。OPERATOR_WRITES 原先写的是 /^\/(start|…)$/, + 永不匹配,启停于是被判成 manager —— operator 档形同虚设,想给人启停权就只能 + 给 manager(而 manager 能改文件改配置)。不是 200 就是没走到业务逻辑: + 这里只要求"不是 403",实例没装完 start 返回失败也算通过。 */ + check('collab operator: 可启停(路由是 /server/:action,别再写成 /start)', + (await as(users.operator, 'POST', '/server/stop')) !== 403); + + // viewer 不该读到凭据:这几条 GET 会吐出 rcon 密码 / 整个 server.properties / 任意文件 + check('collab viewer: 禁止读 rcon 密码', (await as(users.viewer, 'GET', '/rcon')) === 403); + check('collab viewer: 禁止读 server.properties', (await as(users.viewer, 'GET', '/properties')) === 403); + check('collab viewer: 禁止读任意文件内容', + (await as(users.viewer, 'GET', '/files/content?path=%2Fserver.properties')) === 403); + check('collab viewer: 仍读得到日志(没有误伤只读本职)', + (await as(users.viewer, 'GET', '/logs')) === 200); // manager:配置也能改,但仍然碰不到所有权级操作 check('collab manager: 可改配置', (await as(users.manager, 'PATCH', '', {})) === 200); @@ -371,6 +389,88 @@ async function finalizeImportShell(iid) { } } +/** + * 安全边界:CSRF、SSRF、凭据回显。 + * + * 这三样都是"不做也能正常跑"的东西 —— 正因如此才要有用例钉住, + * 否则哪天有人为了省事把校验去掉,功能测试一条都不会红。 + */ +async function securitySuite() { + const MASK = '••••••••'; + let r; + + /* ── CSRF ── + 面板全靠 Cookie 会话,而 Cookie 是浏览器自动附带的。没有这道校验, + 任何网页都能在管理员登录着的时候对面板发 POST。 */ + r = await req('PUT', '/api/settings', { announcement: 'csrf-probe' }, { 'Sec-Fetch-Site': 'cross-site' }); + check('csrf: 跨站请求被拒(Sec-Fetch-Site)', r.status === 403 && r.json && r.json.code === 'csrf', + `${r.status} ${JSON.stringify(r.json)}`); + + r = await req('PUT', '/api/settings', { announcement: 'csrf-probe' }, { Origin: 'https://evil.example.com' }); + check('csrf: 伪造 Origin 被拒', r.status === 403, `${r.status} ${JSON.stringify(r.json)}`); + + r = await req('PUT', '/api/settings', { announcement: '' }, { 'Sec-Fetch-Site': 'same-origin' }); + check('csrf: 同源请求放行(没误伤正常前端)', r.status === 200, `${r.status} ${JSON.stringify(r.json)}`); + + r = await req('GET', '/api/host', undefined, { 'Sec-Fetch-Site': 'cross-site' }); + check('csrf: GET 不受影响(只拦状态变更)', r.status === 200, String(r.status)); + + /* ── SSRF ── + "测试推送"会把每个通道的错误回显,不挡内网的话它就是个带回显的端口探测器。 */ + for (const [label, url] of [ + ['环回', 'http://127.0.0.1:25575/x'], + ['云元数据', 'http://169.254.169.254/latest/meta-data/'], + ['内网段', 'http://10.0.0.5/hook'], + ]) { + r = await req('POST', '/api/settings/notify/test', { + notify: { enabled: true, webhookUrl: url, discordUrl: '', telegramToken: '', telegramChatId: '' }, + }); + const results = (r.json && r.json.results) || []; + const blocked = results.some((x) => !x.ok && /内网|环回|localhost|拒绝/.test(x.error || '')); + check(`ssrf: ${label}地址被拒(${url.slice(0, 32)}…)`, blocked, JSON.stringify(results)); + } + + /* ── 凭据回显 ── + backupRemote 早就掩码了,notify 里的 webhook / bot token 之前是明文返回的。 */ + await req('PUT', '/api/settings', { + notify: { + enabled: false, webhookUrl: 'https://example.com/hook?token=s3cr3t', + discordUrl: '', telegramToken: 'bot-token-should-not-echo', telegramChatId: '123', + }, + }); + r = await req('GET', '/api/settings'); + const n = (r.json && r.json.notify) || {}; + check('mask: telegramToken 不明文回显', n.telegramToken === MASK, JSON.stringify(n.telegramToken)); + check('mask: webhookUrl 不明文回显', n.webhookUrl === MASK, JSON.stringify(n.webhookUrl)); + + // 掩码原样传回来不能把真值抹掉,否则改个 chatId 就会把 token 洗成一串圆点 + await req('PUT', '/api/settings', { + notify: { enabled: false, webhookUrl: MASK, discordUrl: '', telegramToken: MASK, telegramChatId: '456' }, + }); + r = await req('POST', '/api/settings/notify/test', { + notify: { enabled: true, webhookUrl: MASK, discordUrl: '', telegramToken: '', telegramChatId: '' }, + }); + const msg = JSON.stringify((r.json && r.json.results) || []); + check('mask: 掩码回传后真值仍在(报错不是"不是合法 URL")', !/不是合法 URL/.test(msg), msg); + + // 收尾:把 notify 清空,别给下次运行留状态 + await req('PUT', '/api/settings', { + notify: { enabled: false, webhookUrl: '', discordUrl: '', telegramToken: '', telegramChatId: '' }, + }); + + /* 中途放弃的导入空壳要能删掉。它的 state 是 importing,唯一出路 finalize + 需要一个有效压缩包 —— 原先 DELETE 只放行 stopped,于是这个空壳既用不了也删不掉, + 只能靠"重启面板让 state 变回 stopped"这种非显然的办法脱身。 */ + r = await req('POST', '/api/instances/import', { name: 'smoke-abandoned', xmx: 512 }); + const aid = r.json && r.json.instance && r.json.instance.id; + check('import: 建空壳', !!aid && r.json.instance.state === 'importing', JSON.stringify(r.json)); + if (aid) { + r = await req('DELETE', `/api/instances/${aid}`); + check('import: 放弃的空壳可以直接删(不必重启面板)', + r.status === 200 && r.json && r.json.ok, `${r.status} ${JSON.stringify(r.json)}`); + } +} + /** * 多租户 / 权限边界用例(功能 15)。 * @@ -890,6 +990,8 @@ async function uniqueNameRoundtrip() { 全部在 admin 会话下建资源、切到普通用户会话验证隔离,最后清理干净。 */ if (isAdmin) await multiTenantSuite(); + if (isAdmin) await securitySuite(); + // 畸形 JSON 应该是 400(客户端错),不是 500 { const res = await fetch(BASE + '/api/settings', { diff --git a/server.js b/server.js index d54883c..285deaf 100644 --- a/server.js +++ b/server.js @@ -7,4 +7,15 @@ * * 代码结构见 src/(分层说明在 ARCHITECTURE.md)。 */ +/* 最后一道兜底。面板里跑着别人的 Minecraft 服务器 —— 一个后台定时器里的 + 意外异常不该让所有服务端跟着进程一起消失。记下来继续跑,比静默退出、 + 再由 PM2 拉起、再 resumeInstances 把服务端重启一遍要好得多。 + 注意这不是"忽略错误":该修的照修,这里只是不让它变成全员停服。 */ +process.on('uncaughtException', (err) => { + console.error('[MCSP] 未捕获异常(进程继续运行,请上报此堆栈):', err); +}); +process.on('unhandledRejection', (reason) => { + console.error('[MCSP] 未处理的 Promise rejection:', reason); +}); + require('./src/app').start(); diff --git a/src/app.js b/src/app.js index 5c7a03e..4b50bf1 100644 --- a/src/app.js +++ b/src/app.js @@ -14,6 +14,48 @@ const app = express(); app.set('trust proxy', 1); app.use(express.json()); +/** + * CSRF 防护:所有会改状态的 /api 请求必须来自本站。 + * + * 面板全靠 Cookie 会话,而 Cookie 是浏览器**自动**附带的 —— 没有这道校验, + * 任何网页都能在管理员登录着的时候对面板发 POST:删实例、改配额、 + * 甚至 `POST /api/panel/import` 把整份 users.json(含攻击者的管理员口令)覆盖进去。 + * `SameSite=Lax` 只挡住一部分场景,不是完整防护。 + * + * 判定顺序,三种情况: + * 1. 带 `Authorization: Bearer` —— API Token 认证。浏览器不会自动附带这个头, + * 跨源加自定义头又会触发 CORS 预检(本站不发 CORS 头,预检必失败), + * 所以这类请求天然不是 CSRF。放行,否则所有脚本都会断。 + * 2. 有 Sec-Fetch-Site / Origin —— 现代浏览器对非 GET 必发其一,按同源判定。 + * 3. 两个都没有 —— curl / 老客户端 / 服务端到服务端。浏览器不会走到这里, + * 放行以免误伤自动化。 + */ +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); +const TRUSTED_ORIGINS = String(process.env.MCSP_TRUSTED_ORIGINS || '') + .split(',').map((s) => s.trim()).filter(Boolean); + +app.use('/api', (req, res, next) => { + if (SAFE_METHODS.has(req.method)) return next(); + if (/^Bearer\s+\S+/i.test(req.headers.authorization || '')) return next(); // ① API Token + + const site = req.headers['sec-fetch-site']; + if (site) { // ② 浏览器明说了 + if (site === 'same-origin' || site === 'none') return next(); + return res.status(403).json({ ok: false, code: 'csrf', error: '跨站请求被拒绝' }); + } + + const origin = req.headers.origin; + if (origin) { + if (TRUSTED_ORIGINS.includes(origin)) return next(); + let host; + try { host = new URL(origin).host; } catch { host = null; } + if (host && host === req.headers.host) return next(); + return res.status(403).json({ ok: false, code: 'csrf', error: '跨站请求被拒绝' }); + } + + return next(); // ③ 非浏览器客户端 +}); + /* 健康检查(免鉴权,供探针/监控使用) */ app.get('/api/health', (req, res) => { res.json({ diff --git a/src/backups.js b/src/backups.js index 361334d..ef877fd 100644 --- a/src/backups.js +++ b/src/backups.js @@ -231,6 +231,14 @@ function createBackup(inst, name, opts = {}) { inst.log('INFO', `[MCSP] 开始${mode === 'incremental' ? `增量(第 ${chain.nextSeq} 个)` : '全量'}备份到 ${id}`); if (inst.proc) inst.command('save-all'); const tar = spawn('tar', args); + /* spawn 失败(PATH 里没有 tar、fork 时 EAGAIN)只触发 error 不触发 exit。 + 不挂这个监听的话:① 未处理的 error 事件 = uncaughtException; + ② 这个 Promise 永不 settle,调用方的 archiveBusy.delete 在 finally 里 + 也就永不执行 —— 该实例之后所有压缩/备份操作恒 409,只能重启面板。 */ + tar.on('error', (e) => { + inst.log('ERROR', `[MCSP] 无法执行 tar: ${e.message}`); + resolve({ ok: false, error: `无法执行 tar: ${e.message}` }); + }); tar.on('exit', (code) => { if (code === 0) { const meta = readChains(inst); @@ -274,8 +282,9 @@ function inspectBackup(inst, id) { return new Promise((resolve) => { const file = path.join(backupDir(inst), id); if (!fs.existsSync(file) || !id.endsWith('.tar.gz')) return resolve({ ok: false, error: '备份不存在' }); - // -tzf 只读目录表;大包也就几秒,比解压便宜得多 - const tar = spawn('tar', ['tzf', file]); + /* -tzvf 而不是 -tzf:带 v 才有每个条目的**未压缩体积**,恢复前的配额校验要靠它。 + 两者代价一样(都得把压缩流解出来读头部),只是多打印几列。 */ + const tar = spawn('tar', ['tzvf', file]); let out = ''; let err = ''; tar.stdout.on('data', (d) => { out += d; }); @@ -286,7 +295,19 @@ function inspectBackup(inst, id) { // 这里失败基本等于包损坏 —— 正是要在覆盖之前发现的事 return resolve({ ok: false, error: `归档无法读取,可能已损坏 (tar 退出码 ${code})${err ? ': ' + err.trim().slice(0, 200) : ''}` }); } - const entries = out.split('\n').map((s) => s.replace(/^\.\//, '').trim()).filter(Boolean); + /* -tzvf 每行形如: + -rw-r--r-- root/root 12345 2026-08-28 10:00 ./world/level.dat + 取第 3 列求和 = 未压缩总体积(配额校验用),末列之后是路径。 + 路径里可能有空格,所以按前 5 个字段切,剩下的整段当路径。 */ + let totalBytes = 0; + const entries = out.split('\n').map((line) => { + const s = line.trim(); + if (!s) return ''; + const m = /^(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/.exec(s); + if (!m) return s.replace(/^\.\//, ''); + totalBytes += parseInt(m[3], 10) || 0; + return m[6].replace(/^\.\//, '').trim(); + }).filter(Boolean); const files = entries.filter((e) => !e.endsWith('/')); // 顶层条目:用户认得出"这个包里有 world / plugins / server.properties" const top = [...new Set(entries.map((e) => e.split('/')[0]).filter(Boolean))].sort(); @@ -301,6 +322,7 @@ function inspectBackup(inst, id) { resolve({ ok: true, fileCount: files.length, + totalBytes, // 未压缩总体积,恢复前的配额校验用 topLevel: top.slice(0, 200), worlds, hasPlugins: top.includes('plugins') || top.includes('mods'), @@ -346,6 +368,11 @@ function restoreBackup(inst, id) { const tar = spawn('tar', args); let err = ''; tar.stderr.on('data', (d) => { err += d; }); + // 同上:spawn 失败只有 error,不挂就是 uncaughtException + Promise 永挂 + tar.on('error', (e) => { + inst.log('ERROR', `[MCSP] 无法执行 tar: ${e.message}`); + resolve({ ok: false, error: `无法执行 tar: ${e.message}` }); + }); tar.on('exit', (code) => { if (code !== 0) { inst.log('ERROR', `[MCSP] 恢复中断于 ${path.basename(f)} (tar ${code})`); diff --git a/src/instance.js b/src/instance.js index 647c969..d0d172d 100644 --- a/src/instance.js +++ b/src/instance.js @@ -558,7 +558,12 @@ class Instance { bus.broadcast('players', { iid: this.id, players: this.playerList() }); if (this._restartAfterExit) { this._restartAfterExit = false; - setTimeout(() => { + /* 存 handle,让 cancelAutoRestart() 能清掉。原先这个定时器是"裸"的: + 点重启 → 进程退出(此时 state 已是 stopped)→ 1 秒内点删除, + DELETE 的 stopped 守卫放行、目录被 rm,然后定时器照常触发 start(), + _appendLogFile 的 mkdirSync 又把刚删掉的实例目录建回来。 */ + this._restartTimer = setTimeout(() => { + this._restartTimer = null; const r = this.start({ auto: true }); if (!r.ok) this.log('ERROR', `[MCSP] 重启失败: ${r.error}`); }, 1000); @@ -726,6 +731,11 @@ class Instance { cancelAutoRestart() { clearTimeout(this._crashTimer); this._crashTimer = null; + // 手动重启那条 1 秒的定时器也要一起清 —— 删实例时只清崩溃定时器的话, + // 实例都删了它还会把目录建回来 + clearTimeout(this._restartTimer); + this._restartTimer = null; + this._restartAfterExit = false; this._crashTimes = []; this.autoRestartBlocked = false; } @@ -747,13 +757,16 @@ class Instance { this.startedAt = Date.now(); this.emitState(); } + /* 玩家名放宽到 [\w.-]:`\w` 不含 `.` 和 `-`,而 Bedrock/Floodgate 的玩家名常带 `.`。 + 原先这两条正则匹配不上他们,于是在线列表和 playtime 统计里凭空少人 —— + 但封禁接口用的是 [\w.-],同一个名字**能被封却不算在线**,口径自相矛盾。 */ let pm; - if ((pm = message.match(/^(\w{1,16}) joined the game$/))) { + if ((pm = message.match(/^([\w.-]{1,16}) joined the game$/))) { this.players.add(pm[1]); playtime.onJoin(this.id, pm[1]); bus.broadcast('players', { iid: this.id, players: this.playerList() }); this.emitState(); - } else if ((pm = message.match(/^(\w{1,16}) left the game$/))) { + } else if ((pm = message.match(/^([\w.-]{1,16}) left the game$/))) { this.players.delete(pm[1]); playtime.onLeave(this.id, pm[1]); bus.broadcast('players', { iid: this.id, players: this.playerList() }); @@ -1338,9 +1351,13 @@ class Instance { return; } this._tpsBusy = true; + /* 同样走 getProp 的缓存。原先这里写的是 `props['rcon.port']` —— 而 `props` + 在本方法里根本不存在(只在 writeProps 那边有个同名局部变量),于是每次采样 + 都抛 ReferenceError。上面的 enable-rcon 判断刚好把它挡住了,所以只有 + **真正开了 RCON 的用户**会踩到,而那正是这个功能的目标用户。 */ require('./rcon').exec({ - port: parseInt(props['rcon.port'], 10) || 25575, - password: props['rcon.password'], + port: parseInt(this.getProp('rcon.port'), 10) || 25575, + password: this.getProp('rcon.password'), command: 'tps', }).then((out) => { const p = parseTps(out); diff --git a/src/modrinth.js b/src/modrinth.js index f2c88d4..181158a 100644 --- a/src/modrinth.js +++ b/src/modrinth.js @@ -96,15 +96,24 @@ async function versions({ projectId, type, version }) { * 下载指定版本到 destDir。返回 { filename, size }。 * 先写临时文件、校验 sha1 通过后再 rename —— 校验失败绝不能留下一个 * 半截或被篡改的 jar 在 plugins/ 里等着被加载。 + * + * `checkQuota(bytes)` 由调用方传入,返回错误文案表示放不下。Modrinth 的版本信息里 + * 本来就带文件体积,拿真实数字去过配额比在路由里拍一个固定值靠谱得多 —— + * 整合包和大型模组远不止几 MB,而下面的下载是**无界**流式写入, + * 预检一旦放行就写多少算多少。同时也用它做下载的硬上限。 */ -async function install({ projectId, versionId, type, version, destDir }) { +async function install({ projectId, versionId, type, version, destDir, checkQuota }) { const list = await versions({ projectId, type, version }); const target = versionId ? list.find((v) => v.id === versionId) : list[0]; if (!target) throw new Error('没有找到与当前服务端类型/版本匹配的发布'); - const { filename, url, sha1 } = target.file; + const { filename, url, sha1, size: declaredSize } = target.file; if (!/^[\w.\-+ ()\[\]]+\.jar$/i.test(filename)) throw new Error(`文件名异常,拒绝安装: ${filename}`); if (!/^https:\/\/cdn\.modrinth\.com\//.test(url)) throw new Error('下载地址不是 Modrinth CDN,拒绝安装'); + if (checkQuota) { + const bad = checkQuota(declaredSize || 0); + if (bad) throw new Error(bad); + } await fsp.mkdir(destDir, { recursive: true }); const tmp = path.join(destDir, `.mcsp-dl-${crypto.randomUUID().slice(0, 8)}`); @@ -117,6 +126,13 @@ async function install({ projectId, versionId, type, version, destDir }) { for await (const chunk of res.body) { hash.update(chunk); size += chunk.length; + /* 边下边卡上限。声明体积只是 Modrinth 说的,真实流可以更长 —— + 没有这道闸,一个谎报体积的响应就能把配额写穿(sha1 会在事后失败, + 但那时字节已经落盘了)。 */ + if (checkQuota) { + const bad = checkQuota(size); + if (bad) throw new Error(`${bad}(下载中止于 ${(size / 1048576).toFixed(1)} MB)`); + } if (!ws.write(chunk)) await new Promise((r) => ws.once('drain', r)); } await new Promise((r, j) => ws.end((e) => (e ? j(e) : r()))); diff --git a/src/notify.js b/src/notify.js index 5678646..86d2223 100644 --- a/src/notify.js +++ b/src/notify.js @@ -10,6 +10,7 @@ * · 目标地址由管理员配置,发送前校验协议,避免被当成内网探测器。 */ const settings = require('./settings'); +const { checkOutboundUrl } = require('./utils'); const TIMEOUT_MS = 8000; const DEDUPE_MS = 5 * 60_000; @@ -40,11 +41,17 @@ function shouldSend(event, key) { } async function postJson(url, body) { + /* 三个通道都经过这里,内网校验就放在这一层 —— 以后加新通道自动覆盖。 + 不挡的话「测试推送」就是个带回显的内网探测器:填 http://127.0.0.1:25575 + 或 http://169.254.169.254/,靠响应时间和错误文本就能区分端口开没开。 */ + const bad = await checkOutboundUrl(url, { label: '推送地址' }); + if (bad) throw new Error(bad); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(TIMEOUT_MS), + redirect: 'error', // 不跟随跳转 —— 302 到内网能绕过上面这道校验 }); if (!res.ok) throw new Error(`HTTP ${res.status}`); } diff --git a/src/oauth.js b/src/oauth.js index 287084b..fbc02a9 100644 --- a/src/oauth.js +++ b/src/oauth.js @@ -10,6 +10,7 @@ const crypto = require('crypto'); const path = require('path'); const express = require('express'); +const { checkOutboundUrl } = require('./utils'); const { DATA_DIR } = require('./config'); const { readJson, writeJson } = require('./utils'); const auth = require('./auth'); @@ -72,6 +73,13 @@ function authorizeUrl(req, state) { /** code 换 token 再取用户信息;返回稳定身份标识 oauthId */ async function fetchIdentity(req, code) { + /* 这两个地址是管理员填的,面板拿着它们从**服务端**发请求 —— 指向内网就是 SSRF。 + 而且 token 请求会把 clientSecret 一起带过去,填错/被改成内网地址等于把密钥 + 送给那个地址。发之前校验一次。 */ + for (const [url, label] of [[config.tokenUrl, 'OAuth token 端点'], [config.userInfoUrl, 'OAuth userinfo 端点']]) { + const bad = await checkOutboundUrl(url, { label }); + if (bad) throw new Error(bad); + } // OAuth2 标准要求 token 端点用 form-urlencoded(linux.do 等不解析 JSON body) const tokenRes = await fetch(config.tokenUrl, { method: 'POST', diff --git a/src/registry.js b/src/registry.js index b35e0d5..46277c9 100644 --- a/src/registry.js +++ b/src/registry.js @@ -24,15 +24,36 @@ function loadRegistry() { const inst = new Instance(meta); if (fs.existsSync(inst.dir)) instances.set(inst.id, inst); } + /* 必须走 collaboratorList() 归一化成用户名字符串。 + bus.broadcast 用的是 `allowed.includes(username)`,而 collaborators 里存的 + 是 `{name, role}` 对象(PUT /:iid/collaborators 一律写成这个形态), + 直接展开的话 includes 永远匹配不上 —— 表现为**通过当前 UI 添加的协作者 + 收不到任何 log/state/metrics/players 事件**,控制台不滚、状态不刷新, + 而 REST 接口却正常,极容易被当成前端 bug 查半天。 + 老的纯字符串协作者反而是好的,所以这个问题只在新数据上出现。 */ bus.resolveAllowed = (iid) => { const i = instances.get(iid); - return i ? [i.owner, ...i.collaborators] : null; + return i ? [i.owner, ...i.collaboratorList().map((c) => c.name)] : null; }; saveRegistry(); } +/* 每个实例单独 try/catch:一个实例的采样炸了不该带走其他实例的曲线,更不该 + 带走整个面板 —— 这个回调在 setInterval 里,抛出去就是 uncaughtException。 + 不打 log:2 秒一次的重复报错会把控制台刷满,只记一次并置标志。 */ function startMetricsLoop() { - setInterval(() => { for (const inst of instances.values()) inst.tickMetrics(); }, 2000); + setInterval(() => { + for (const inst of instances.values()) { + try { + inst.tickMetrics(); + } catch (err) { + if (!inst._metricsErrLogged) { + inst._metricsErrLogged = true; + console.error(`[MCSP] 实例 ${inst.id} 指标采样异常(后续同类错误不再重复打印):`, err); + } + } + } + }, 2000); } /* 恢复时错峰:几个 MC 服同时启动会把磁盘和 CPU 顶满,谁都起不来 */ diff --git a/src/remotebackup.js b/src/remotebackup.js index 5c567dc..bb38b98 100644 --- a/src/remotebackup.js +++ b/src/remotebackup.js @@ -23,6 +23,7 @@ const crypto = require('crypto'); const http = require('http'); const https = require('https'); const { spawn } = require('child_process'); +const { checkOutboundUrl } = require('./utils'); const UPLOAD_TIMEOUT_MS = 30 * 60_000; // 几十 GB 的世界包走慢线路可能真要这么久 @@ -203,6 +204,14 @@ function validate(cfg) { async function upload(cfg, localPath, remoteKey) { const err = validate(cfg); if (err) throw new Error(err); + /* 内网校验放在这里而不是 validate():validate 是同步的,而 DNS 解析是异步的。 + test() 也走 upload,所以「连通性自检」那条带回显的路径一并覆盖到了。 + rclone 不填 URL(用的是本地 rclone config 里的 remote 名),跳过。 */ + const target = cfg.type === 's3' ? cfg.endpoint : cfg.type === 'webdav' ? cfg.url : null; + if (target) { + const bad = await checkOutboundUrl(target, { label: cfg.type === 's3' ? 'S3 endpoint' : 'WebDAV 地址' }); + if (bad) throw new Error(bad); + } const prefix = (cfg.prefix || '').replace(/^\/+|\/+$/g, ''); const key = prefix ? `${prefix}/${remoteKey}` : remoteKey; if (cfg.type === 's3') return uploadS3(cfg, localPath, key); diff --git a/src/routes/instances.js b/src/routes/instances.js index 6cd6dfe..e0d6d4f 100644 --- a/src/routes/instances.js +++ b/src/routes/instances.js @@ -176,18 +176,43 @@ const LEVEL = { viewer: 1, operator: 2, manager: 3, owner: 4 }; /* 日常运维:启停、发命令、做备份。这些不改配置也不动文件, 交给"能帮我看服但别乱改东西"的人正好 */ const OPERATOR_WRITES = [ - /^\/(start|stop|restart|kill)$/, + /* 启停的真实路由是 `/:iid/server/:action`,所以这里看到的 req.path 是 + `/server/start` 而不是 `/start`。原先写成 /^\/(start|…)$/ 永不匹配, + 启停于是被判成 manager —— 方向上是失效关闭(不越权),但 operator 档 + 因此形同虚设:想给人启停权就只能给 manager,而 manager 能改文件改配置。 + 净效果是权限被迫放大,也和 README 承诺的分档对不上。 */ + /^\/server\/(start|stop|restart|kill)$/, /^\/command$/, /^\/backups$/, /^\/players\/[^/]+\/(kick|ban|pardon|op|deop)$/, + // 注:`/rcon` 只有 GET(GET 一律 viewer),写操作是 `/rcon/enable` —— + // 那是往 server.properties 里写密码,属于改配置,留在 manager 是对的 +]; + +/* 会读出**凭据或任意文件内容**的 GET,不能留在 viewer。 + README 承诺的 viewer 是「看状态·日志·玩家」,而这几条一旦放开: + · /rcon 直接返回 rcon.password 明文 + · /properties readProps() 把整个 server.properties 端出来(含 rcon.password) + · /files/content /files/download 能读实例内任意文件,包括上面两个 + 和 Velocity 的 forwarding.secret(configs 里就列着) + · /backups/:id/download 一个归档 = 整个世界 + 全部配置 + 密钥 + 等于"只读"档能把实例的全部秘密拿走。文件访问在 README 的分档里本来 + 就属于 manager,这里只是让实现追上文档。 */ +const MANAGER_READS = [ /^\/rcon$/, + /^\/properties$/, + /^\/files\/content$/, + /^\/files\/download$/, + /^\/backups\/[^/]+\/download$/, ]; function requiredLevel(req) { - if (req.method === 'GET' || req.method === 'HEAD') return LEVEL.viewer; // 挂在 '/:iid' 上的 use,Express 已经把 /api/instances/ 剥掉了, - // req.path 就是实例内的子路径('/start'、'/'…)。别再自己剥一次 + // req.path 就是实例内的子路径('/server/start'、'/'…)。别再自己剥一次 const sub = req.path || '/'; + if (req.method === 'GET' || req.method === 'HEAD') { + return MANAGER_READS.some((re) => re.test(sub)) ? LEVEL.manager : LEVEL.viewer; + } return OPERATOR_WRITES.some((re) => re.test(sub)) ? LEVEL.operator : LEVEL.manager; } @@ -339,6 +364,9 @@ router.post('/', (req, res) => { installInstance(inst, { port: parseInt(port, 10) || 25565 + instances.size - 1, gamemode: ['survival', 'creative', 'adventure', 'spectator'].includes(gamemode) ? gamemode : 'survival', + // installInstance 支持 motd,之前调用方没传,那个参数一直是 undefined(死参数)。 + // 前端也没有对应输入框,所以这里给个和实例同名的默认值,把链路接上 + motd: String(name).trim().slice(0, 59), }); }); @@ -502,6 +530,12 @@ router.patch('/:iid', asyncHandler(async (req, res) => { /* 克隆:复制整个实例目录,换 id / 名字 / 端口。受实例数、内存、磁盘三项配额约束。 */ router.post('/:iid/clone', asyncHandler(async (req, res) => { const src = req.inst; + /* 克隆限主人/管理员。克隆出来的新实例 owner 是**调用者**,也就是说协作者 + 一旦能克隆,就等于能把别人的实例连同世界和 server.properties(里面有 + rcon.password、velocity 的 forwarding.secret)整份复制成自己完全掌控的 + 实例 —— 绕过了"协作者不能把实例拿走"这条直觉边界。配额算在克隆者头上 + 所以不是配额问题,是数据外流。 */ + if (!isOwnerOrAdmin(req, src)) return res.status(403).json({ ok: false, error: '只有实例主人可以克隆实例' }); const name = String((req.body && req.body.name) || '').trim().slice(0, 40); if (!name) return res.status(400).json({ ok: false, error: '新实例名称不能为空' }); // 边跑边拷世界会拿到一份撕裂的存档,而且往往要等玩家进服才暴露 @@ -578,8 +612,18 @@ router.post('/:iid/reinstall', asyncHandler(async (req, res) => { router.delete('/:iid', asyncHandler(async (req, res) => { const inst = req.inst; if (!isOwnerOrAdmin(req, inst)) return res.status(403).json({ ok: false, error: '只有实例主人可以删除实例' }); - if (inst.state !== 'stopped') return res.status(400).json({ ok: false, error: '请先停止实例再删除' }); - inst.cancelAutoRestart(); // 否则实例都删了,几秒后那个定时器还会来拉一次 + /* importing 也放行:那是 POST /import 建的空壳,没有进程也没有数据, + 而它唯一的出路 finalize 需要一个有效压缩包 —— 中途放弃的导入于是卡在这里, + 删不掉也用不了(得重启面板才会因为 state 不落盘而变回 stopped)。 + 没有理由让用户为了删一个空壳去重启整个面板。 */ + if (inst.state !== 'stopped' && inst.state !== 'importing') { + return res.status(400).json({ ok: false, error: '请先停止实例再删除' }); + } + // 压缩/备份/解压正在读写实例目录时删掉它,tar 会写出残缺产物 + if (archiveBusy.has(inst.id)) { + return res.status(409).json({ ok: false, error: '该实例有压缩/备份任务正在进行,请等它结束再删' }); + } + inst.cancelAutoRestart(); // 否则实例都删了,几秒后那两个定时器还会来拉一次 if (inst.tunnelProc) inst.stopTunnel(); if (inst.rconTunnelProc) inst.stopRconTunnel(); fs.rmSync(path.join(DATA_DIR, `frpc-${inst.id}.toml`), { force: true }); @@ -919,13 +963,19 @@ router.post('/:iid/plugins/install', asyncHandler(async (req, res) => { if (!projectId || !/^[\w-]{1,32}$/.test(String(projectId))) return res.status(400).json({ ok: false, error: '项目 ID 无效' }); if (versionId && !/^[\w-]{1,32}$/.test(String(versionId))) return res.status(400).json({ ok: false, error: '版本 ID 无效' }); - const dqerr = diskQuotaError(req, 64); // jar 一般几 MB,给个宽松的预检额度 + /* 原先这里写死 diskQuotaError(req, 64) —— 注释说"jar 一般几 MB,给个宽松的预检额度", + 但整合包和大型模组远不止 64 MB,而 install 的下载是无界流式写入:只要剩余配额 + ≥64 MB 就放行,之后写多少算多少。改成把真实体积交给 modrinth 去卡 + (它的版本信息里本来就带 file.size),并在下载过程中按已写字节持续复查。 */ + const checkQuota = (bytes) => diskQuotaError(req, bytes / 1048576); + const dqerr = checkQuota(0); // 配额已经满了就不必发起请求 if (dqerr) return res.status(403).json({ ok: false, error: dqerr }); try { const r = await modrinth.install({ projectId, versionId, type: inst.type, version: inst.version, destDir: path.join(inst.dir, ext.name), + checkQuota, }); disk.bump(inst.id, r.size / 1048576); inst.log('INFO', `[MCSP] 已安装${ext.noun} ${r.filename} (${(r.size / 1048576).toFixed(1)} MB${r.verified ? ', SHA-1 校验通过' : ''})` @@ -1003,6 +1053,23 @@ router.get('/:iid/backups/:id/inspect', asyncHandler(async (req, res) => { router.post('/:iid/backups/:id/restore', asyncHandler(async (req, res) => { if (!isBackupId(req.params.id)) return res.status(404).json({ ok: false, error: '备份不存在' }); if (req.inst.state !== 'stopped') return res.json({ ok: false, error: '请先停止实例再恢复备份' }); + + /* 恢复是第六条能往实例目录里写入大量数据的路径,原先它是唯一一条**不校验配额**的。 + 否则:备份 → 删掉实例里的文件(占用回落)→ 恢复 → 再来一轮,就能把占用推到配额之上。 + 算的是**净增长**(归档解开后的体积 - 当前实例占用):恢复是覆盖式的, + 恢复一个和现在差不多大的包净增长约等于 0,不该被拦。 + 增量链按各归档之和取上界(增量里改过的文件会重复计),宁可偏保守。 */ + const pre = await inspectBackup(req.inst, req.params.id); + if (!pre.ok) return res.json(pre); // 包损坏/链缺环,原样把原因回给用户 + let needBytes = pre.totalBytes || 0; + for (const other of (pre.chain || []).filter((c) => c !== req.params.id)) { + const s = await inspectBackup(req.inst, other); + if (s.ok) needBytes += s.totalBytes || 0; + } + const growthMB = Math.max(0, needBytes / 1048576 - disk.instanceUsage(req.inst.id).instMB); + const dqerr = diskQuotaError(req, growthMB); + if (dqerr) return res.status(403).json({ ok: false, error: dqerr }); + // 恢复要往实例目录里解包,和备份/解压/打包必须互斥,否则解一半被另一个覆盖 if (archiveBusy.has(req.inst.id)) { return res.status(409).json({ ok: false, error: '该实例已有压缩任务在进行中' }); diff --git a/src/settings.js b/src/settings.js index f82944a..41351dd 100644 --- a/src/settings.js +++ b/src/settings.js @@ -110,6 +110,27 @@ function maskRemote(r) { }; } +/* 推送目标同样是凭据,口径要和 maskRemote 一致。 + telegramToken 拿到就能以这个 bot 的身份发消息;Discord webhook URL **本身** + 就是凭据(持有即可往那个频道发);通用 webhook 地址里也常带 token 查询参数。 + 之前只掩了 backupRemote,这三个是原样返回的 —— 虽然只给管理员, + 但没有理由同一个响应里一半掩一半不掩。 */ +function maskNotify(n) { + return { + ...n, + webhookUrl: n.webhookUrl ? MASK : '', + discordUrl: n.discordUrl ? MASK : '', + telegramToken: n.telegramToken ? MASK : '', + }; +} + +/** GET 给管理员的完整视图:两处凭据都掩码 */ +const adminView = () => ({ + ...settings, + backupRemote: maskRemote(settings.backupRemote), + notify: maskNotify(settings.notify), +}); + /* 普通用户只该看到影响自己的那几项。notify 里存着 webhook 地址和 Telegram Bot Token —— 之前这个接口对任何登录用户都全量返回,等于把推送凭据发给租户。 */ function publicView(s) { @@ -123,7 +144,7 @@ function publicView(s) { } router.get('/', (req, res) => res.json(req.user.role === 'admin' - ? { ...settings, backupRemote: maskRemote(settings.backupRemote) } + ? adminView() : publicView(settings))); router.put('/', requireAdmin, (req, res) => { @@ -199,24 +220,37 @@ router.put('/', requireAdmin, (req, res) => { if (b.notify && typeof b.notify === 'object') { const n = b.notify; const str = (v, max) => String(v || '').trim().slice(0, max); + // 掩码原样传回来 = 没改,保留旧值 —— 否则管理员改个 chatId 就会把 + // webhook 地址和 bot token 全抹成一串圆点(backupRemote 那边同理) + const cur = settings.notify; + const keep = (v, max, old) => (v === MASK ? old : str(v, max)); settings.notify = { enabled: !!n.enabled, - webhookUrl: str(n.webhookUrl, 500), - discordUrl: str(n.discordUrl, 500), - telegramToken: str(n.telegramToken, 200), + webhookUrl: keep(n.webhookUrl, 500, cur.webhookUrl), + discordUrl: keep(n.discordUrl, 500, cur.discordUrl), + telegramToken: keep(n.telegramToken, 200, cur.telegramToken), telegramChatId: str(n.telegramChatId, 64), events: { ...settings.notify.events, ...(n.events || {}) }, }; } save(); - res.json({ ok: true, settings: { ...settings, backupRemote: maskRemote(settings.backupRemote) } }); + res.json({ ok: true, settings: adminView() }); }); /* 测试推送:同步等结果,逐条回显哪个通道通了 */ router.post('/notify/test', requireAdmin, async (req, res) => { const notify = require('./notify'); - // 用请求体里的配置试,这样用户不用先保存再测 - const cfg = (req.body && req.body.notify) || settings.notify; + // 用请求体里的配置试,这样用户不用先保存再测。 + // 但 GET 回显的是掩码,原样传回来的字段要还原成已存的真值 —— + // 否则"没改过 token 直接点测试"会拿着一串圆点去请求,报一个莫名其妙的错 + const raw = (req.body && req.body.notify) || settings.notify; + const un = (v, old) => (v === MASK ? old : v); + const cfg = { + ...raw, + webhookUrl: un(raw.webhookUrl, settings.notify.webhookUrl), + discordUrl: un(raw.discordUrl, settings.notify.discordUrl), + telegramToken: un(raw.telegramToken, settings.notify.telegramToken), + }; res.json({ ok: true, results: await notify.test(cfg) }); }); diff --git a/src/tasks.js b/src/tasks.js index a214427..0125f03 100644 --- a/src/tasks.js +++ b/src/tasks.js @@ -88,6 +88,14 @@ function startScheduler() { const now = new Date(); for (const task of store.tasks) { if (!task.enabled) continue; + /* 时钟往回拨(NTP 校正、手改系统时间)会让 lastRun 落在未来,于是 + `Date.now() - lastRun` 恒为负 —— interval 任务在时钟追上来之前**完全不执行**, + daily 的 90 秒去重窗口同理失效,而界面上 enabled 还是 true,看不出异常。 + 统一钳一下:未来的 lastRun 拉回当下。 */ + if (task.lastRun && task.lastRun > Date.now()) { + task.lastRun = Date.now(); + saveTasks(); + } if (task.schedule.type === 'interval') { const base = task.lastRun || task.createdAt; if (Date.now() - base >= task.schedule.minutes * 60000) runTask(task).catch(() => {}); diff --git a/src/utils.js b/src/utils.js index 60c6e86..b7b6ee8 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,7 +1,9 @@ -/** 通用工具:时间戳、ANSI 清洗、JSON 读写、目录大小、下载、子进程 */ +/** 通用工具:时间戳、ANSI 清洗、JSON 读写、目录大小、下载、子进程、出站地址校验 */ const fs = require('fs'); const fsp = require('fs/promises'); const path = require('path'); +const net = require('net'); +const dns = require('dns').promises; const { spawn } = require('child_process'); function ts() { @@ -86,6 +88,85 @@ async function githubLatestTag(repo, fallback) { /** Express 异步路由包装:未捕获的 rejection 交给错误中间件而不是打崩进程 */ const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +/* ── 出站地址校验(SSRF) ────────────────────────────────────────── + * + * 面板会拿**用户填的地址**发请求:告警 webhook / Discord / Telegram、异地备份的 + * S3 endpoint 与 WebDAV、OAuth 的 authorize/token/userinfo。原先只校验协议是 + * http(s),不校验地址 —— 于是 `http://169.254.169.254/latest/meta-data/` + * (云元数据,能读到实例角色的临时凭据)、`http://127.0.0.1:25575`、`http://10.0.0.x` + * 一律放行。而"测试推送"会把每个通道的错误文本回显给调用者,响应时间 + 错误内容 + * 足以区分"端口开着"和"连不上" —— 这就是一个带回显的内网探测原语。 + * + * ARCHITECTURE.md 写着"别把面板变成内网探测器",这里是把那句话真正落实。 + * + * 已知局限:解析完到 fetch 真正连接之间有 DNS rebinding 窗口。彻底堵死要自定义 + * agent 的 lookup,成本远高于收益 —— 这些接口都是 requireAdmin,威胁模型是 + * "管理员被钓鱼点了一下",不是"租户主动打内网"。这里挡住直接填内网地址的情形。 + */ +const PRIVATE_V4 = [ + [[0, 0, 0, 0], 8], // 本网络 + [[10, 0, 0, 0], 8], // 私有 + [[100, 64, 0, 0], 10], // CGNAT + [[127, 0, 0, 0], 8], // 环回 + [[169, 254, 0, 0], 16], // 链路本地(含 169.254.169.254 云元数据) + [[172, 16, 0, 0], 12], // 私有 + [[192, 0, 0, 0], 24], // IETF 保留 + [[192, 168, 0, 0], 16], // 私有 + [[198, 18, 0, 0], 15], // benchmark + [[224, 0, 0, 0], 4], // 组播 + [[240, 0, 0, 0], 4], // 保留 + 广播 +]; + +function v4InCidr(ip, base, bits) { + const a = ip.split('.').map(Number); + if (a.length !== 4 || a.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false; + const toInt = (p) => ((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0; + const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; + return (toInt(a) & mask) === (toInt(base) & mask); +} + +/** 这个 IP 是不是内网/环回/链路本地等不该被面板主动访问的地址 */ +function isPrivateAddress(ip) { + const v = net.isIP(ip); + if (v === 4) return PRIVATE_V4.some(([base, bits]) => v4InCidr(ip, base, bits)); + if (v === 6) { + const s = ip.toLowerCase(); + if (s === '::' || s === '::1') return true; + // IPv4-mapped(::ffff:10.0.0.1)必须按内嵌的 v4 判,否则是个现成的绕过口 + const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(s); + if (m) return isPrivateAddress(m[1]); + if (/^f[cd][0-9a-f]{2}:/.test(s)) return true; // fc00::/7 唯一本地 + if (/^fe[89ab][0-9a-f]:/.test(s)) return true; // fe80::/10 链路本地 + return false; + } + return false; +} + +/** + * 校验用户填的出站 URL。通过返回 null,不通过返回中文原因(可直接回给用户)。 + * 域名会走 DNS 解析,**所有**解析结果里只要有一个是内网地址就拒绝。 + */ +async function checkOutboundUrl(raw, { label = '目标地址' } = {}) { + let u; + try { u = new URL(String(raw)); } catch { return `${label}不是合法 URL`; } + if (u.protocol !== 'http:' && u.protocol !== 'https:') return `${label}只支持 http/https`; + + const host = u.hostname.replace(/^\[|\]$/g, ''); // IPv6 字面量带方括号 + if (net.isIP(host)) { + return isPrivateAddress(host) ? `${label}指向内网/环回地址(${host}),已拒绝` : null; + } + if (/^localhost$/i.test(host) || /\.localhost$/i.test(host)) return `${label}指向 localhost,已拒绝`; + + let addrs; + try { + addrs = (await dns.lookup(host, { all: true })).map((a) => a.address); + } catch { + return `${label}的域名解析失败(${host})`; + } + const bad = addrs.find((a) => isPrivateAddress(a)); + return bad ? `${label}的域名解析到内网地址(${host} → ${bad}),已拒绝` : null; +} + /** * 一个 `-Xmx=xmxMB` 的 JVM 实际会向宿主机要多少内存(RSS 口径)。 * @@ -116,4 +197,5 @@ const memFootprintMB = (xmxMB) => xmxMB + memOverheadMB(xmxMB); module.exports = { ts, stripAnsi, readJson, writeJson, dirSize, runCmd, downloadFile, githubLatestTag, asyncHandler, memOverheadMB, memFootprintMB, + isPrivateAddress, checkOutboundUrl, };