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:
tamakyi
2026-10-14 16:19:31 +08:00
parent f345626840
commit 6b0a23c38e
16 changed files with 835 additions and 575 deletions
+64 -7
View File
@@ -39,7 +39,7 @@ const CONCURRENCY = 8;
const WRITE_DEBOUNCE_MS = 300;
const DEFAULT_INTERVAL_SEC = 60;
const PORT = Number(process.env.PORT || 8787);
const VERSION = '0.12.1';
const VERSION = '0.12.2';
const UR_DEFAULT_POLL_SEC = 300;
const UR_MIN_REFRESH_GAP_MS = 60000;
@@ -875,6 +875,56 @@ function sendJson(res, data, { status = 200, headers = {} } = {}) {
res.end(JSON.stringify(data));
}
/* ---------- 跨域只读(前端可部署在别处,数据源指向本机) ---------- */
// 允许跨域读取的前端来源。默认放行本机:本地开发时前端页面与探针常常跑在不同端口上
// (例如 Vite 的 5173),而 origin 是「协议 + 主机 + 端口」的完整组合,
// 因此默认项按「忽略端口」匹配,主机相同即放行。
// 部署在 GitHub Pages / Cloudflare 等其它域名时,在 settings.corsOrigins 里显式列出
// 完整 origin(含协议与端口,如 https://you.github.io)。绝不使用 '*':
// 那等于让任意网站都能读走监控数据。
function corsOrigins(config) {
const extra = Array.isArray(config.settings?.corsOrigins)
? config.settings.corsOrigins.map((s) => String(s).trim()).filter(Boolean)
: [];
return { exact: new Set(extra), loopbackHosts: new Set(['127.0.0.1', 'localhost', '[::1]']) };
}
// 请求的 origin 是否在白名单内。主机是本机回环地址时忽略端口(本地开发常见形态),
// 其余情况要求完整 origin 精确匹配。
function originAllowed(reqOrigin, config) {
let u;
try { u = new URL(reqOrigin); } catch { return false; }
const { exact, loopbackHosts } = corsOrigins(config);
if (exact.has(reqOrigin)) return true;
return u.protocol === 'http:' && loopbackHosts.has(u.hostname);
}
// 返回跨域响应头;origin 不在白名单时返回空对象(请求本身照常处理,只是浏览器读不到响应)
function corsHeaders(req, config) {
const origin = req.headers.origin;
if (!origin || !originAllowed(origin, config)) return {};
return {
'access-control-allow-origin': origin, // 回显具体 origin,由白名单保证安全
'access-control-allow-methods': 'GET, POST, OPTIONS',
'access-control-allow-headers': 'content-type',
'access-control-max-age': '86400',
vary: 'Origin', // 缓存必须区分 Origin,否则会串味
};
}
// 只读接口的预检响应。写接口(/api/refresh、/api/admin/*)不放行 OPTIONS,
// 因此外部页面无法用简单请求之外的方式触发有副作用的调用。
function handlePreflight(req, res, config) {
const headers = corsHeaders(req, config);
if (!headers['access-control-allow-origin']) {
res.writeHead(403);
return res.end('origin not allowed');
}
res.writeHead(204, headers);
res.end();
}
/* ---------- /api/refresh 滥用防护 ---------- */
// 仅信任 socket 的对端地址,不解析 X-Forwarded-For(可被伪造);若前方挂了反向代理,请改用 allowRefresh:false
@@ -985,15 +1035,22 @@ function createServer(runtime) {
res.writeHead(200, { 'content-type': MIME['.html'], 'cache-control': 'no-store' });
return void fs.createReadStream(file).pipe(res);
}
if (url.pathname === '/api/status') return sendJson(res, snapshot(runtime));
if (url.pathname === '/api/monitors') return sendJson(res, publicConfig(config));
// 预检:只对只读接口与 /api/refresh 放行(/api/admin/* 一律不放行,管理端必须同源访问)。
// /api/refresh 虽然有副作用,但已有 refreshDecision 的限流与 refreshLocalOnly 兜底,
// 且它不带自定义头时属于「简单请求」——浏览器无论如何都会把它发出去,
// 加跨域头只影响响应能否被 JS 读到(前端据此退回只读),不影响安全性。
if (req.method === 'OPTIONS' && (url.pathname === '/api/status' || url.pathname === '/api/monitors' || url.pathname === '/api/events' || url.pathname === '/api/refresh' || url.pathname === '/api/v2/getMonitors' || url.pathname === '/v2/getMonitors')) {
return handlePreflight(req, res, config);
}
if (url.pathname === '/api/status') return sendJson(res, snapshot(runtime), { headers: corsHeaders(req, config) });
if (url.pathname === '/api/monitors') return sendJson(res, publicConfig(config), { headers: corsHeaders(req, config) });
if (url.pathname === '/api/refresh') {
const decide = refreshDecision(config, req.socket.remoteAddress);
if (!decide.ok) {
// 只回错误码,不回显目标地址与内部细节
return sendJson(res, { error: decide.error }, {
status: decide.status,
headers: decide.retryAfter ? { 'retry-after': String(decide.retryAfter) } : {},
headers: { ...(decide.retryAfter ? { 'retry-after': String(decide.retryAfter) } : {}), ...corsHeaders(req, config) },
});
}
// scope=external:只同步 UptimeRobot,不跑本地探测轮次(面板在「只看 UR」模式下用)
@@ -1010,19 +1067,19 @@ function createServer(runtime) {
]);
// 浏览器中断请求(切页/关标签)是常态:等待期间连接可能已断开,此时不能再写响应
if (res.writableEnded || res.destroyed) return;
return sendJson(res, snapshot(runtime));
return sendJson(res, snapshot(runtime), { headers: corsHeaders(req, config) });
}
// UptimeRobot 兼容的只读接口(GET/POST 均可):已有脚本/看板可直接消费本服务数据。
// 不做鉴权,因为其暴露内容与公开面板一致;如需鉴权请在其前方加代理。
if (url.pathname === '/api/v2/getMonitors' || url.pathname === '/v2/getMonitors') {
return sendJson(res, toUptimeRobotResponse(runtime));
return sendJson(res, toUptimeRobotResponse(runtime), { headers: corsHeaders(req, config) });
}
// 事件时间线:面板「事件」视图取这里(含通知去抖前的全部状态翻转记录)
if (url.pathname === '/api/events') {
const list = [...(runtime.events ?? [])].reverse();
return sendJson(res, { total: list.length, events: list.slice(0, 200) });
return sendJson(res, { total: list.length, events: list.slice(0, 200) }, { headers: corsHeaders(req, config) });
}
let rel = decodeURIComponent(url.pathname);