/** * shiroko-monitor 安全与探测能力验证(开发用) * * 覆盖: * A. banner 探测:SSH 标识串识别、expectBanner 策略匹配/不符、静默端口超时、默认不回显版本 * B. 信息泄露控制:detail 不含地址/版本、redactDetails 脱敏、公开配置不含真实地址 * C. 私有配置分层:local.monitors.json 覆盖 host/port 后探测才可连通 * D. /api/refresh 滥用防护:冷却限流 429 + Retry-After、allowRefresh=false 时 403 * E. 来源判定 isPrivateAddress 单元校验 * * 用法:node scripts/verify-security.cjs */ const fs = require('node:fs'); const os = require('node:os'); const net = require('node:net'); const path = require('node:path'); const { spawn } = require('node:child_process'); const ROOT = path.join(__dirname, '..'); const PROBER = path.join(ROOT, 'prober', 'node', 'index.js'); const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-sec-')); const SSH_PORT = 2222; const SILENT_PORT = 2223; const results = []; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function check(name, pass, detail) { results.push({ name, pass }); console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`); } async function waitForHttp(url, timeoutMs = 8000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { await fetch(url, { cache: 'no-store' }); return true; } catch { await sleep(200); } } return false; } function startProber(args, env = {}) { return spawn(process.execPath, [PROBER, ...args], { env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'], }); } function runOnce(args) { return new Promise((resolve) => { const child = startProber(args); let out = ''; child.stdout.on('data', (d) => (out += d)); child.stderr.on('data', (d) => (out += d)); child.on('exit', (code) => resolve({ code, out })); }); } async function main() { // ---------- 准备:本地假 SSH 服务 + 静默端口 ---------- const sshServer = net.createServer((sock) => { // 忠实模拟 OpenSSH:连接建立后立即发送标识串(RFC 4253) sock.write('SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13\r\n'); }); const silentServer = net.createServer(() => { /* 接受连接但从不发送数据 */ }); await new Promise((r) => sshServer.listen(SSH_PORT, '127.0.0.1', r)); await new Promise((r) => silentServer.listen(SILENT_PORT, '127.0.0.1', r)); console.log(`[准备] 假 SSH 服务 127.0.0.1:${SSH_PORT},静默端口 127.0.0.1:${SILENT_PORT},临时目录 ${TMP}`); // ---------- A/B/C:banner 探测 + 脱敏 + 私有配置分层 ---------- const pubConfig = path.join(TMP, 'monitors.json'); const localConfig = path.join(TMP, 'local.monitors.json'); const outJson = path.join(TMP, 'status.json'); fs.writeFileSync(pubConfig, JSON.stringify({ settings: { title: 'sec-test', redactDetails: false }, monitors: [ { id: 'ssh-banner', name: 'SSH 服务', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: 22, timeout: 5, expectBanner: '^SSH-2\\.0-' }, { id: 'ssh-reveal', name: 'SSH(回显)', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: 22, timeout: 5, expectBanner: '^SSH-2\\.0-', revealBanner: true }, { id: 'ssh-mismatch', name: 'SSH(策略不符)', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: 22, timeout: 5, expectBanner: '^SSH-1\\.99-' }, { id: 'silent-banner', name: '静默端口 banner', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: SILENT_PORT, timeout: 2 }, { id: 'silent-tcp', name: '静默端口 TCP', group: '基础设施', type: 'tcp', host: 'placeholder.invalid', port: SILENT_PORT, timeout: 2 }, ], }, null, 2)); // 真实地址只存在于私有覆盖中(模拟「公开仓库不含内网地址」) fs.writeFileSync(localConfig, JSON.stringify({ monitors: [ { id: 'ssh-banner', host: '127.0.0.1', port: SSH_PORT }, { id: 'ssh-reveal', host: '127.0.0.1', port: SSH_PORT }, { id: 'ssh-mismatch', host: '127.0.0.1', port: SSH_PORT }, { id: 'silent-banner', host: '127.0.0.1', port: SILENT_PORT }, { id: 'silent-tcp', host: '127.0.0.1', port: SILENT_PORT }, ], }, null, 2)); console.log('\n[A] banner 探测(私有配置覆盖地址后执行一轮探测)'); const once = await runOnce(['--once', `--config=${pubConfig}`, `--local-config=${localConfig}`, `--out=${outJson}`]); const snap = JSON.parse(fs.readFileSync(outJson, 'utf8')); const m = snap.monitors; check('私有配置分层生效(占位地址被覆盖为本地地址后探测成功)', m['ssh-banner'].status === 'up', `ssh-banner=${m['ssh-banner'].status} · ${m['ssh-banner'].detail}`); check('SSH 标识串识别为在线且只给协议族摘要', m['ssh-banner'].detail === 'SSH 响应正常', `detail="${m['ssh-banner'].detail}"`); check('默认不回显版本号(detail 不含 OpenSSH)', !/OpenSSH/.test(m['ssh-banner'].detail), m['ssh-banner'].detail); check('expectBanner 策略不符时判为离线且不回显实际标识串', m['ssh-mismatch'].status === 'down' && !/OpenSSH/.test(m['ssh-mismatch'].detail), `detail="${m['ssh-mismatch'].detail}"`); check('显式开启 revealBanner 后才回显标识串', /OpenSSH/.test(m['ssh-reveal'].detail), `detail="${m['ssh-reveal'].detail}"`); check('静默端口 banner 探测超时判离线', m['silent-banner'].status === 'down', `detail="${m['silent-banner'].detail}"`); check('同一静默端口 TCP 探测可达(证明两者判定逻辑不同)', m['silent-tcp'].status === 'up', `detail="${m['silent-tcp'].detail}"`); check('TCP detail 不再包含地址', !m['silent-tcp'].detail.includes('127.0.0.1') && !m['silent-tcp'].detail.includes(':'), m['silent-tcp'].detail); const rawSnapshot = fs.readFileSync(outJson, 'utf8'); check('快照中不出现公开配置的占位地址', !rawSnapshot.includes('placeholder.invalid'), '未泄露 host'); check('快照中不出现真实内网地址', !rawSnapshot.includes('127.0.0.1'), '未泄露 host'); console.log('\n[B] redactDetails 脱敏'); const redacted = JSON.parse(fs.readFileSync(pubConfig, 'utf8')); redacted.settings.redactDetails = true; fs.writeFileSync(pubConfig, JSON.stringify(redacted, null, 2)); const outRedacted = path.join(TMP, 'status-redacted.json'); await runOnce(['--once', `--config=${pubConfig}`, `--local-config=${localConfig}`, `--out=${outRedacted}`]); const rSnap = JSON.parse(fs.readFileSync(outRedacted, 'utf8')); check('redactDetails 开启后 detail 只保留正常/异常', rSnap.monitors['ssh-reveal'].detail === '正常' && rSnap.monitors['silent-tcp'].detail === '正常', JSON.stringify(Object.fromEntries(Object.entries(rSnap.monitors).map(([k, v]) => [k, v.detail])))); // ---------- D:刷新接口滥用防护 ---------- console.log('\n[D] /api/refresh 滥用防护'); const cooldownCfg = path.join(TMP, 'cooldown.json'); fs.writeFileSync(cooldownCfg, JSON.stringify({ settings: { title: 't', refreshCooldownSec: 3 }, monitors: [] }, null, 2)); const srvA = startProber([`--config=${cooldownCfg}`, `--local-config=${cooldownCfg}`, `--out=${path.join(TMP, 'a.json')}`], { PORT: '8897' }); await waitForHttp('http://127.0.0.1:8897/api/status'); const r1 = await fetch('http://127.0.0.1:8897/api/refresh'); const r2 = await fetch('http://127.0.0.1:8897/api/refresh'); check('首次刷新放行', r1.status === 200, `status=${r1.status}`); check('冷却期内再次刷新被限流', r2.status === 429, `status=${r2.status}, retry-after=${r2.headers.get('retry-after')}s`); check('限流响应带 Retry-After 头', Number(r2.headers.get('retry-after')) >= 1, `retry-after=${r2.headers.get('retry-after')}`); await sleep(3300); const r3 = await fetch('http://127.0.0.1:8897/api/refresh'); check('冷却结束后可再次刷新', r3.status === 200, `status=${r3.status}`); srvA.kill(); const disabledCfg = path.join(TMP, 'disabled.json'); fs.writeFileSync(disabledCfg, JSON.stringify({ settings: { title: 't', allowRefresh: false }, monitors: [] }, null, 2)); const srvB = startProber([`--config=${disabledCfg}`, `--local-config=${disabledCfg}`, `--out=${path.join(TMP, 'b.json')}`], { PORT: '8898' }); await waitForHttp('http://127.0.0.1:8898/api/status'); const r4 = await fetch('http://127.0.0.1:8898/api/refresh'); const body4 = await r4.json().catch(() => ({})); check('allowRefresh:false 时刷新被拒绝', r4.status === 403, `status=${r4.status}, error=${body4.error}`); check('拒绝响应不泄露目标信息', !JSON.stringify(body4).includes('host') && !JSON.stringify(body4).includes('/'), JSON.stringify(body4)); const r5 = await fetch('http://127.0.0.1:8898/api/status'); check('只读模式下面板数据接口仍可用', r5.status === 200, `status=${r5.status}`); srvB.kill(); // ---------- D2:接口不得泄露私有层密钥 ---------- console.log('\n[D2] 配置接口的密钥泄露'); // 私有层放一个可识别的假 key:/api/monitors 无鉴权,绝不能把它回吐给面板或访客 const SECRET = 'urFAKE-0123456789abcdefSECRET'; const pubCfg = path.join(TMP, 'secret-pub.json'); const privCfg = path.join(TMP, 'secret-priv.json'); fs.writeFileSync(pubCfg, JSON.stringify({ settings: { uptimeRobot: { enabled: false, apiKey: '' } }, monitors: [] }, null, 2)); fs.writeFileSync(privCfg, JSON.stringify({ settings: { uptimeRobot: { enabled: true, apiKey: SECRET } } }, null, 2)); const srvC = startProber([`--config=${pubCfg}`, `--local-config=${privCfg}`, `--out=${path.join(TMP, 'c.json')}`], { PORT: '8896' }); await waitForHttp('http://127.0.0.1:8896/api/status'); const mResp = await fetch('http://127.0.0.1:8896/api/monitors'); const mText = await mResp.text(); check('/api/monitors 不回吐私有层的 API Key', !mText.includes(SECRET) && !mText.includes('FAKE'), mText.includes(SECRET) ? '泄露了!' : '未泄露'); const mJson = JSON.parse(mText); check('/api/monitors 的 uptimeRobot.apiKey 被剥离', !('apiKey' in (mJson.settings?.uptimeRobot ?? {})), JSON.stringify(mJson.settings?.uptimeRobot)); check('/api/monitors 仍返回非敏感配置(面板要读)', typeof mJson.settings?.uptimeRobot?.enabled === 'boolean' && Array.isArray(mJson.monitors), `enabled=${mJson.settings?.uptimeRobot?.enabled}`); const sText = await (await fetch('http://127.0.0.1:8896/api/status')).text(); check('/api/status 快照不含 API Key', !sText.includes(SECRET), ''); const pubText = await (await fetch('http://127.0.0.1:8896/monitors.json')).text(); check('静态 monitors.json 不含私有层内容', !pubText.includes(SECRET), ''); srvC.kill(); // ---------- D3:跨域只读(CORS 白名单) ---------- // 场景:前端部署在 GitHub Pages / Cloudflare,探针在另一台机器, // 需要让指定页面能跨域读状态与配置。核心风险是「任意网站都能读走监控数据」。 console.log('\n[D3] 跨域只读白名单'); const corsCfg = path.join(TMP, 'cors.json'); fs.writeFileSync(corsCfg, JSON.stringify({ settings: { title: 't', corsOrigins: ['https://you.github.io', 'https://monitor.example.com'], uptimeRobot: { enabled: true, apiKey: SECRET }, }, monitors: [], }, null, 2)); const srvD = startProber([`--config=${corsCfg}`, `--local-config=${corsCfg}`, `--out=${path.join(TMP, 'd.json')}`], { PORT: '8895' }); await waitForHttp('http://127.0.0.1:8895/api/status'); const acaoOf = async (pathname, origin, method = 'GET') => { const res = await fetch(`http://127.0.0.1:8895${pathname}`, { method, headers: { Origin: origin } }); return res.headers.get('access-control-allow-origin'); }; check('白名单内的 GitHub Pages 来源被放行', await acaoOf('/api/status', 'https://you.github.io') === 'https://you.github.io'); check('白名单内的自定义域名被放行', await acaoOf('/api/status', 'https://monitor.example.com') === 'https://monitor.example.com'); check('白名单外的站点被拒(无跨域头)', await acaoOf('/api/status', 'https://evil.example') === null); check('前缀伪造的域名被拒(如 https://you.github.io.evil.com)', await acaoOf('/api/status', 'https://you.github.io.evil.com') === null); check('协议不符的同域被拒(https 的 localhost 不匹配 http 默认项)', await acaoOf('/api/status', 'https://localhost') === null); // 本机回环:默认放行且忽略端口(本地前端与探针常跑在不同端口) check('本机 localhost 放行(任意端口)', await acaoOf('/api/status', 'http://localhost:5173') === 'http://localhost:5173'); check('本机 127.0.0.1 放行(任意端口)', await acaoOf('/api/status', 'http://127.0.0.1:3000') === 'http://127.0.0.1:3000'); check('局域网 IP 未列入白名单时被拒', await acaoOf('/api/status', 'http://192.168.1.5:5173') === null); // 预检 const pre = await fetch('http://127.0.0.1:8895/api/status', { method: 'OPTIONS', headers: { Origin: 'https://you.github.io', 'Access-Control-Request-Method': 'GET' } }); check('合法来源的预检返回 204 且带跨域头', pre.status === 204 && pre.headers.get('access-control-allow-origin') === 'https://you.github.io', `HTTP ${pre.status}`); const preBad = await fetch('http://127.0.0.1:8895/api/status', { method: 'OPTIONS', headers: { Origin: 'https://evil.example', 'Access-Control-Request-Method': 'GET' } }); check('非法来源的预检被拒 403', preBad.status === 403, `HTTP ${preBad.status}`); // 各只读接口都放行 const readOnly = ['/api/status', '/api/monitors', '/api/events', '/api/v2/getMonitors']; const allowed = []; for (const p of readOnly) allowed.push((await acaoOf(p, 'https://you.github.io')) === 'https://you.github.io'); check('四个只读接口都支持跨域', allowed.every(Boolean), readOnly.filter((_, i) => !allowed[i]).join(',') || '全部放行'); // 关键:管理端绝不能跨域 const adminAcao = []; for (const p of ['/api/admin/status', '/api/admin/overview', '/api/admin/login']) { const r = await fetch(`http://127.0.0.1:8895${p}`, { method: 'GET', headers: { Origin: 'https://you.github.io' } }); adminAcao.push(r.headers.get('access-control-allow-origin')); } check('管理端接口一律不带跨域头(必须同源访问)', adminAcao.every((v) => v === null), JSON.stringify(adminAcao)); const adminPre = await fetch('http://127.0.0.1:8895/api/admin/overview', { method: 'OPTIONS', headers: { Origin: 'https://you.github.io', 'Access-Control-Request-Method': 'POST' } }); check('管理端的预检被拒 403(外部页面无法发写操作)', adminPre.status === 403, `HTTP ${adminPre.status}`); // 跨域读到的配置同样不得含密钥 const corsMonitors = await (await fetch('http://127.0.0.1:8895/api/monitors', { headers: { Origin: 'https://you.github.io' } })).text(); check('跨域读取配置时不泄露 API Key', !corsMonitors.includes(SECRET), corsMonitors.includes(SECRET) ? '泄露了!' : '未泄露'); srvD.kill(); // corsOrigins 里不允许 '*' console.log('\n[D4] corsOrigins 的写法校验'); const starCfg = path.join(TMP, 'star.json'); fs.writeFileSync(starCfg, JSON.stringify({ settings: { title: 't', corsOrigins: ['*'] }, monitors: [] }, null, 2)); const { corsOrigins: corsFn } = await import(`file://${PROBER.replace(/\\/g, '/')}`).catch(() => ({})); // corsOrigins 未导出时以 HTTP 层验证:配置 '*' 后不应变成任意来源可读 const srvE = startProber([`--config=${starCfg}`, `--local-config=${starCfg}`, `--out=${path.join(TMP, 'e.json')}`], { PORT: '8894' }); await waitForHttp('http://127.0.0.1:8894/api/status'); const starRes = await fetch('http://127.0.0.1:8894/api/status', { headers: { Origin: 'https://anything.example' } }); check("corsOrigins 配成 '*' 不会放行任意来源(必须写完整 origin)", starRes.headers.get('access-control-allow-origin') === null, starRes.headers.get('access-control-allow-origin') ?? '无跨域头'); srvE.kill(); // ---------- E:来源判定 ---------- console.log('\n[E] 来源判定'); const { isPrivateAddress } = await import(`file://${PROBER.replace(/\\/g, '/')}`); const priv = ['127.0.0.1', '::1', '10.1.2.3', '192.168.1.10', '172.16.0.1', '172.31.255.254', 'fc00::1', 'fe80::1', '::ffff:127.0.0.1']; const pub = ['8.8.8.8', '1.1.1.1', '172.32.0.1', '11.0.0.1', '2001:4860:4860::8888']; check('内网/回环地址被允许', priv.every(isPrivateAddress), priv.filter((i) => !isPrivateAddress(i)).join(',') || '全部通过'); check('公网地址被拒绝', pub.every((i) => !isPrivateAddress(i)), pub.filter(isPrivateAddress).join(',') || '全部通过'); // ---------- 收尾 ---------- sshServer.close(); silentServer.close(); fs.rmSync(TMP, { recursive: true, force: true }); const failed = results.filter((r) => !r.pass); console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`); if (failed.length) process.exit(1); } main().catch((err) => { console.error('FAILED:', err.stack || err.message); process.exit(1); });