用户诉求:管理端与前端完全分离,本地探针只跑 /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 误以为失败。
267 lines
17 KiB
JavaScript
267 lines
17 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|