Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 104 additions & 2 deletions scripts/smoke.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)。
*
Expand Down Expand Up @@ -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', {
Expand Down
11 changes: 11 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
42 changes: 42 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
33 changes: 30 additions & 3 deletions src/backups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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; });
Expand All @@ -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();
Expand All @@ -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'),
Expand Down Expand Up @@ -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})`);
Expand Down
27 changes: 22 additions & 5 deletions src/instance.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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() });
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 18 additions & 2 deletions src/modrinth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
Expand All @@ -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())));
Expand Down
Loading
Loading