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 误以为失败。
This commit is contained in:
@@ -172,6 +172,77 @@ async function main() {
|
||||
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, '/')}`);
|
||||
|
||||
Reference in New Issue
Block a user