Files
tamakyi 6b0a23c38e feat: v0.12.2 —— 跨域只读支持(面板可部署在别处)
用户诉求:管理端与前端完全分离,本地探针只跑 /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 误以为失败。
2026-10-14 16:19:31 +08:00

267 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});