qq-bot-roost 0.8.7 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/plugin/deps.js +160 -26
- package/src/plugin/pages.js +38 -2
- package/src/web/server.js +306 -9
- package/web/app.js +3275 -3265
- package/web/index.html +2 -10
- package/web/theme-boot.js +17 -0
package/src/web/server.js
CHANGED
|
@@ -43,6 +43,61 @@ import {
|
|
|
43
43
|
const log = createLogger('webui');
|
|
44
44
|
const WEB_ROOT = path.join(ROOT, 'web');
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* 主面板的 CSP。
|
|
48
|
+
*
|
|
49
|
+
* 几个取舍说明一下,免得后来人以为写错了:
|
|
50
|
+
* - `script-src 'self'` 是硬要求:主题脚本已外置成 /theme-boot.js,
|
|
51
|
+
* 面板里不再有内联 <script>。开 'unsafe-inline' 等于放弃脚本防护,
|
|
52
|
+
* 而面板 localStorage 里存着访问密钥。
|
|
53
|
+
* - `style-src` **必须**留 'unsafe-inline':app.js 里有二十多处
|
|
54
|
+
* `style="width:...%"` 这类动态样式,去掉它布局直接散架。
|
|
55
|
+
* - `img-src` 带 data: 和 blob::二维码是 SVG 内联,插件图标可能用 data URI。
|
|
56
|
+
* - 不设 HSTS:本地是 http,加了会让浏览器以后强制走 https 打不开。
|
|
57
|
+
* 反代终止 TLS 时由 Nginx/Caddy 那边加。
|
|
58
|
+
* - `frame-src 'self'` 是给插件页面 iframe 用的,与 `frame-ancestors 'none'`
|
|
59
|
+
* 不冲突:前者管「我能嵌谁」,后者管「谁能嵌我」。
|
|
60
|
+
*/
|
|
61
|
+
const CSP_MAIN = [
|
|
62
|
+
"default-src 'self'",
|
|
63
|
+
"script-src 'self'",
|
|
64
|
+
"style-src 'self' 'unsafe-inline'",
|
|
65
|
+
"img-src 'self' data: blob:",
|
|
66
|
+
"font-src 'self' data:",
|
|
67
|
+
"connect-src 'self'",
|
|
68
|
+
"frame-src 'self'",
|
|
69
|
+
"object-src 'none'",
|
|
70
|
+
"base-uri 'none'",
|
|
71
|
+
"form-action 'self'",
|
|
72
|
+
"frame-ancestors 'none'",
|
|
73
|
+
].join('; ');
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 插件页面的 CSP —— 必须比主面板宽,而且**一条 `'self'` 都不能有**。
|
|
77
|
+
*
|
|
78
|
+
* 两个坑,都是实测踩出来的:
|
|
79
|
+
*
|
|
80
|
+
* 1. 插件页跑在 `sandbox="allow-scripts allow-forms"` 的 iframe 里(没有
|
|
81
|
+
* allow-same-origin),它的源是 **opaque origin**。CSP 里 `'self'` 的语义是
|
|
82
|
+
* 「文档自己的源」,而 opaque origin 跟谁都不相等 —— 于是 `connect-src 'self'`
|
|
83
|
+
* 会把插件页**所有** fetch 拦死。现象是页面渲染正常、数据永远加载不出来,
|
|
84
|
+
* 控制台报 `Failed to fetch`,而服务端一条请求记录都没有。
|
|
85
|
+
* 2. 所以这里改用协议通配(`http:` / `https:`)而不是源通配。
|
|
86
|
+
* 敢放宽的前提是隔离靠 sandbox 不靠 CSP:插件页读不到父页的 localStorage /
|
|
87
|
+
* Cookie / DOM,调接口必须带 URL 上那把只属于本页面的 `pt` 令牌。
|
|
88
|
+
*
|
|
89
|
+
* 也不设 `frame-ancestors`:opaque origin 下它同样认不出「谁在嵌我」,
|
|
90
|
+
* 反而可能连面板自己都嵌不进去。防外部嵌套由主面板那套 X-Frame-Options 负责。
|
|
91
|
+
*/
|
|
92
|
+
const CSP_PAGE = [
|
|
93
|
+
"default-src 'none'",
|
|
94
|
+
"script-src 'unsafe-inline' http: https:",
|
|
95
|
+
"style-src 'unsafe-inline' http: https:",
|
|
96
|
+
"img-src data: blob: http: https:",
|
|
97
|
+
"font-src data: http: https:",
|
|
98
|
+
"connect-src http: https:",
|
|
99
|
+
].join('; ');
|
|
100
|
+
|
|
46
101
|
const MIME = {
|
|
47
102
|
'.html': 'text/html; charset=utf-8',
|
|
48
103
|
'.css': 'text/css; charset=utf-8',
|
|
@@ -447,15 +502,101 @@ export async function startWebServer({
|
|
|
447
502
|
* timingSafeEqual 只防时序侧信道,挡不住「弱 key 被顺序猜」。
|
|
448
503
|
* 正确 key 不受影响(成功后清零),误伤面很小。
|
|
449
504
|
*/
|
|
505
|
+
/**
|
|
506
|
+
* 面板会话:sid -> 过期时间戳。
|
|
507
|
+
*
|
|
508
|
+
* 为什么要有它:accessKey 是长期固定的,挂在 URL 上会进浏览器历史、书签同步、
|
|
509
|
+
* Referer、反向代理 access_log,每一条都是明文泄露面。换成 HttpOnly Cookie 之后,
|
|
510
|
+
* 页面脚本读不到、外链带不出去,而且重启进程就全部作废(等于一键踢掉所有会话)。
|
|
511
|
+
*
|
|
512
|
+
* 只存内存。重启后要重新用带 ?key= 的地址进一次 —— 这是有意为之:
|
|
513
|
+
* 落盘就等于多存一份长期凭据,和「减少凭据扩散」的目标相反。
|
|
514
|
+
*/
|
|
515
|
+
const sessions = new Map();
|
|
516
|
+
const SESSION_TTL_MS = 7 * 864e5;
|
|
517
|
+
const sessionTimer = setInterval(() => {
|
|
518
|
+
const now = Date.now();
|
|
519
|
+
for (const [k, exp] of sessions) if (exp < now) sessions.delete(k);
|
|
520
|
+
}, 60_000);
|
|
521
|
+
sessionTimer.unref?.();
|
|
522
|
+
|
|
450
523
|
const authFails = new Map(); // ip -> { count, start }
|
|
451
524
|
const AUTH_WINDOW = 60_000;
|
|
452
525
|
const AUTH_MAX_FAILS = 20;
|
|
453
|
-
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* 从 URL 上的 `pt` 反查插件页面。
|
|
529
|
+
*
|
|
530
|
+
* 为什么需要它:插件页在 iframe 里是 **opaque origin**(sandbox 不给
|
|
531
|
+
* allow-same-origin),既带不了 Cookie,也带不了 X-Access-Key 头,
|
|
532
|
+
* 唯一的凭证只能挂在地址上。所以给它一把只属于那个页面的随机令牌。
|
|
533
|
+
*/
|
|
534
|
+
const pickPageToken = (url) => {
|
|
535
|
+
const pt = url.searchParams.get('pt');
|
|
536
|
+
if (!pt) return null;
|
|
537
|
+
for (const r of runtimes) {
|
|
538
|
+
const hit = r?.plugins?.pages?.findByToken?.(pt);
|
|
539
|
+
if (hit) return hit;
|
|
540
|
+
}
|
|
541
|
+
return null;
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* 页面令牌能碰哪些路径 —— **最小权限**,不是「只读」。
|
|
546
|
+
*
|
|
547
|
+
* 只放行这个插件自己的入口:
|
|
548
|
+
* /api/pages/<插件id>:<key>/html iframe 载入页面本体
|
|
549
|
+
* /api/plugins/<插件id>/panel 面板数据(dice 的战绩就是这么取的)
|
|
550
|
+
* /api/plugins/<插件id>/r/* 插件自己的路由(斗罗后台全在这一层)
|
|
551
|
+
* /api/status 只读状态
|
|
552
|
+
*
|
|
553
|
+
* **方法不限制**。斗罗那种运营后台要 POST/PATCH 改数据,只给 GET 会直接
|
|
554
|
+
* 把它废掉;而「限制插件写自己的数据」没有安全收益 —— 插件本来就有子进程
|
|
555
|
+
* 执行权。真正的边界是**不让它碰面板接口**:/api/config、/api/logs、
|
|
556
|
+
* /api/bots、/api/qq/call 一个都不在名单里。
|
|
557
|
+
*/
|
|
558
|
+
const pageTokenAllows = (pathname, ptPage) => {
|
|
559
|
+
const id = String(ptPage?.pluginId ?? '');
|
|
560
|
+
if (!id) return false;
|
|
561
|
+
if (pathname === `/api/pages/${ptPage.key}/html`) return true;
|
|
562
|
+
if (pathname === `/api/plugins/${id}/panel`) return true;
|
|
563
|
+
if (pathname.startsWith(`/api/plugins/${id}/r/`)) return true;
|
|
564
|
+
if (pathname === '/api/status') return true;
|
|
565
|
+
return false;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const authorized = (req, url, ptPage = null) => {
|
|
454
569
|
if (!accessKey) return true;
|
|
570
|
+
|
|
571
|
+
// 插件页面令牌:不是面板密钥,权限由 pageTokenAllows() 再收一道
|
|
572
|
+
if (ptPage) return true;
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* 会话 Cookie —— 浏览器正常路径都走这里。
|
|
576
|
+
*
|
|
577
|
+
* 只在正则上限制 64 位十六进制是防「畸形 cookie 撑爆 Map」,
|
|
578
|
+
* 不是安全边界;真正的校验是下面查表 + 过期判断。
|
|
579
|
+
*/
|
|
580
|
+
const cookie = String(req.headers.cookie || '');
|
|
581
|
+
const sid = /(?:^|;\s*)roost_sid=([a-f0-9]{64})/.exec(cookie)?.[1];
|
|
582
|
+
if (sid) {
|
|
583
|
+
const exp = sessions.get(sid);
|
|
584
|
+
if (exp && exp > Date.now()) return true;
|
|
585
|
+
sessions.delete(sid);
|
|
586
|
+
}
|
|
587
|
+
|
|
455
588
|
const ip = req.socket?.remoteAddress || 'unknown';
|
|
456
589
|
const now = Date.now();
|
|
457
590
|
const rec = authFails.get(ip);
|
|
458
|
-
if (rec && now - rec.start < AUTH_WINDOW && rec.count >= AUTH_MAX_FAILS)
|
|
591
|
+
if (rec && now - rec.start < AUTH_WINDOW && rec.count >= AUTH_MAX_FAILS) {
|
|
592
|
+
/**
|
|
593
|
+
* 锁定期要留痕。以前这里直接 `return false` 一句话都没有,
|
|
594
|
+
* 于是管理员只看到「访问密钥无效」,分不清是自己敲错还是有人在打,
|
|
595
|
+
* 也拿不到来源 IP。
|
|
596
|
+
*/
|
|
597
|
+
log.error(`面板认证失败次数过多,已临时封禁 ${ip}(${Math.ceil((AUTH_WINDOW - (now - rec.start)) / 1000)} 秒后解除)`);
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
459
600
|
// 兼容旧的 X-Token / ?token= 写法
|
|
460
601
|
const given =
|
|
461
602
|
req.headers['x-access-key'] ||
|
|
@@ -469,6 +610,8 @@ export async function startWebServer({
|
|
|
469
610
|
const cur = rec && now - rec.start < AUTH_WINDOW ? rec : { count: 0, start: now };
|
|
470
611
|
cur.count += 1;
|
|
471
612
|
authFails.set(ip, cur);
|
|
613
|
+
// 记失败但不记密钥本身:只记来源与累计次数
|
|
614
|
+
log.warn(`面板认证失败:来源 ${ip},本窗口第 ${cur.count} 次`);
|
|
472
615
|
if (authFails.size > 5000) authFails.clear(); // 防内存膨胀
|
|
473
616
|
}
|
|
474
617
|
return ok;
|
|
@@ -491,7 +634,17 @@ export async function startWebServer({
|
|
|
491
634
|
*
|
|
492
635
|
* 端口不固定(会自动顺延),所以只比 host 部分。
|
|
493
636
|
*/
|
|
494
|
-
const isSameOrigin = (req) => {
|
|
637
|
+
const isSameOrigin = (req, ptPage = null) => {
|
|
638
|
+
/**
|
|
639
|
+
* 插件页的例外:sandbox 不给 allow-same-origin 之后,它就是 opaque origin,
|
|
640
|
+
* 发请求时 `Origin` 恒为字符串 `"null"`,new URL('null') 会抛错 → 当跨站拒掉。
|
|
641
|
+
*
|
|
642
|
+
* 只对「带有效页面令牌」的请求放行,而且这类请求的路径已经被
|
|
643
|
+
* pageTokenAllows() 限死在该插件自己的接口上。没有 pt 的 `Origin: null`
|
|
644
|
+
* 依旧按跨站处理。
|
|
645
|
+
*/
|
|
646
|
+
if (ptPage && String(req.headers.origin || '') === 'null') return true;
|
|
647
|
+
|
|
495
648
|
const host = String(req.headers['host'] || '');
|
|
496
649
|
for (const h of ['origin', 'referer']) {
|
|
497
650
|
const v = req.headers[h];
|
|
@@ -615,6 +768,17 @@ export async function startWebServer({
|
|
|
615
768
|
}, 60000).unref?.();
|
|
616
769
|
|
|
617
770
|
const server = http.createServer(async (req, res) => {
|
|
771
|
+
/**
|
|
772
|
+
* 安全响应头:面板一旦反代到公网,这几条是浏览器侧的最后一道。
|
|
773
|
+
*
|
|
774
|
+
* `Referrer-Policy: no-referrer` 尤其重要——它顺手堵掉「点面板里的外链
|
|
775
|
+
* 把 URL 上的密钥带到别人服务器」那条路(GitHub/Gitee 链接就在面板里)。
|
|
776
|
+
*/
|
|
777
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
778
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
779
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
780
|
+
res.setHeader('Content-Security-Policy', CSP_MAIN);
|
|
781
|
+
|
|
618
782
|
/**
|
|
619
783
|
* URL 解析必须在 try 里面。
|
|
620
784
|
*
|
|
@@ -632,10 +796,112 @@ export async function startWebServer({
|
|
|
632
796
|
return json(res, 400, { error: '请求地址不合法' });
|
|
633
797
|
}
|
|
634
798
|
|
|
799
|
+
/**
|
|
800
|
+
* 首跳把 `?key=` 换成会话 Cookie,然后 302 到干净地址。
|
|
801
|
+
*
|
|
802
|
+
* 只对**页面导航**这么做(`/api/` 不动):脚本类调用方(roost-mcp 这类)
|
|
803
|
+
* 还在用 `?key=` 直连接口,一刀切会让它们全挂。等它们改成 X-Access-Key 头
|
|
804
|
+
* 再收掉 query 分支。
|
|
805
|
+
*
|
|
806
|
+
* 换成 Cookie 之后浏览器地址栏、历史记录、书签同步里就没有密钥了,
|
|
807
|
+
* 而 Cookie 是 HttpOnly,页面脚本读不到。
|
|
808
|
+
*/
|
|
809
|
+
if (!pathname.startsWith('/api/')) {
|
|
810
|
+
const qKey = url.searchParams.get('key') || url.searchParams.get('token');
|
|
811
|
+
if (qKey && accessKey && safeEqual(qKey, accessKey)) {
|
|
812
|
+
const sid = crypto.randomBytes(32).toString('hex');
|
|
813
|
+
sessions.set(sid, Date.now() + SESSION_TTL_MS);
|
|
814
|
+
const rest = new URLSearchParams(url.search);
|
|
815
|
+
rest.delete('key');
|
|
816
|
+
rest.delete('token');
|
|
817
|
+
const qs = rest.toString();
|
|
818
|
+
// 用 url.pathname(原始编码形式)而不是解码后的,否则带 %2F 的路径会被改坏
|
|
819
|
+
res.writeHead(302, {
|
|
820
|
+
'Set-Cookie': `roost_sid=${sid}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.floor(
|
|
821
|
+
SESSION_TTL_MS / 1000,
|
|
822
|
+
)}`,
|
|
823
|
+
Location: url.pathname + (qs ? `?${qs}` : ''),
|
|
824
|
+
});
|
|
825
|
+
return res.end();
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* 面板首页的「没凭证」提示。
|
|
831
|
+
*
|
|
832
|
+
* 会话改成 Cookie 之后,最常见的新问题是「书签里的地址没有 key 了,打开一片空白」。
|
|
833
|
+
* 静态文件本身不鉴权(也不该鉴权,不然 CSS/JS 都拿不到),但首页可以给一句人话,
|
|
834
|
+
* 而不是让用户对着空面板怀疑是不是坏了。
|
|
835
|
+
*/
|
|
836
|
+
if ((pathname === '/' || pathname === '/index.html') && accessKey) {
|
|
837
|
+
const ck = String(req.headers.cookie || '');
|
|
838
|
+
const sid0 = /(?:^|;\s*)roost_sid=([a-f0-9]{64})/.exec(ck)?.[1];
|
|
839
|
+
const exp0 = sid0 ? sessions.get(sid0) : 0;
|
|
840
|
+
if (!exp0 || exp0 <= Date.now()) {
|
|
841
|
+
res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
842
|
+
return res.end(
|
|
843
|
+
'<!doctype html><meta charset="utf-8"><title>需要访问密钥</title>' +
|
|
844
|
+
'<body style="font:15px/1.6 system-ui;max-width:34em;margin:14vh auto;padding:0 1.2em">' +
|
|
845
|
+
'<h1 style="font-size:18px">需要访问密钥</h1>' +
|
|
846
|
+
'<p>面板已开启访问密钥,但当前浏览器还没有会话(进程重启后会话会全部失效)。</p>' +
|
|
847
|
+
'<p>请用带密钥的地址打开一次,之后会在浏览器里记住 7 天:</p>' +
|
|
848
|
+
'<pre style="background:rgba(128,128,128,.14);padding:10px 12px;border-radius:6px;overflow:auto">' +
|
|
849
|
+
`http://${shownHost}:${actualPort}/?key=你的密钥</pre>` +
|
|
850
|
+
'<p style="opacity:.65;font-size:13px">密钥在 config/bot.json 的 webui.accessKey,或启动时设置的环境变量里。</p>',
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
635
855
|
try {
|
|
636
856
|
// ---------------- API ----------------
|
|
637
857
|
if (pathname.startsWith('/api/')) {
|
|
638
|
-
|
|
858
|
+
const ptPage = pickPageToken(url);
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* 插件页是 opaque origin(跨源),浏览器要求响应带 CORS 头才肯把内容
|
|
862
|
+
* 交给页面。只在带有效 pt 时回,且回 `null` —— 这只匹配 opaque origin,
|
|
863
|
+
* 普通网页拿到的 `Access-Control-Allow-Origin: null` 与自己的 Origin
|
|
864
|
+
* 不相等,仍然读不到。
|
|
865
|
+
*/
|
|
866
|
+
if (ptPage && String(req.headers.origin || '') === 'null') {
|
|
867
|
+
res.setHeader('Access-Control-Allow-Origin', 'null');
|
|
868
|
+
res.setHeader('Vary', 'Origin');
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* CORS 预检直接答,**不要先认令牌**。
|
|
873
|
+
*
|
|
874
|
+
* 这里踩过一次:原来写成 `if (ptPage && req.method === 'OPTIONS')`,
|
|
875
|
+
* 于是「令牌没认出来」的预检会继续往下走,撞到「写操作必须
|
|
876
|
+
* application/json」那道闸门 —— 预检没有 body,于是回 415,
|
|
877
|
+
* 浏览器判定预检失败,页面拿到的是 `Failed to fetch`。
|
|
878
|
+
*
|
|
879
|
+
* 而插件页里 `fetch` 只要带了 `Content-Type: application/json`
|
|
880
|
+
* (斗罗对所有请求都带),连 GET 都会触发预检,所以这个坑表现为
|
|
881
|
+
* 「页面渲染正常、数据一条都没有」。
|
|
882
|
+
*
|
|
883
|
+
* 预检不返回任何数据,谁来问都答 204 即可,不需要鉴权。
|
|
884
|
+
*/
|
|
885
|
+
if (req.method === 'OPTIONS') {
|
|
886
|
+
const origin = String(req.headers.origin || '');
|
|
887
|
+
res.writeHead(204, {
|
|
888
|
+
...(origin === 'null' ? { 'Access-Control-Allow-Origin': 'null', Vary: 'Origin' } : {}),
|
|
889
|
+
'Access-Control-Allow-Methods': 'GET, POST, PATCH, PUT, DELETE, OPTIONS',
|
|
890
|
+
'Access-Control-Allow-Headers': 'Content-Type, X-Access-Key, X-Token',
|
|
891
|
+
'Access-Control-Max-Age': '600',
|
|
892
|
+
});
|
|
893
|
+
return res.end();
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (!authorized(req, url, ptPage)) return json(res, 401, { error: '访问密钥无效' });
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* 页面令牌的权限闸门:**紧跟着鉴权做**,不能放到路由里。
|
|
900
|
+
* 否则拿到任意插件页 token 就能调 /api/config、/api/logs。
|
|
901
|
+
*/
|
|
902
|
+
if (ptPage && !pageTokenAllows(pathname, ptPage)) {
|
|
903
|
+
return json(res, 403, { error: '插件页面无权访问这个接口' });
|
|
904
|
+
}
|
|
639
905
|
|
|
640
906
|
/**
|
|
641
907
|
* CSRF / 同源校验必须在**路由之前**。
|
|
@@ -646,7 +912,7 @@ export async function startWebServer({
|
|
|
646
912
|
if (req.method !== 'GET' && req.method !== 'HEAD' && !isJsonRequest(req)) {
|
|
647
913
|
return json(res, 415, { error: '写操作需要 Content-Type: application/json' });
|
|
648
914
|
}
|
|
649
|
-
if (!isSameOrigin(req)) {
|
|
915
|
+
if (!isSameOrigin(req, ptPage)) {
|
|
650
916
|
return json(res, 403, { error: '跨站请求被拒绝' });
|
|
651
917
|
}
|
|
652
918
|
|
|
@@ -1148,6 +1414,23 @@ export async function startWebServer({
|
|
|
1148
1414
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1149
1415
|
return res.end('页面不存在(插件可能已卸载或重载)');
|
|
1150
1416
|
}
|
|
1417
|
+
/**
|
|
1418
|
+
* 插件页要单独换一套 CSP:它允许内联脚本和样式。
|
|
1419
|
+
* 不换的话会继承入口那套 `script-src 'self'`,插件页的内联脚本
|
|
1420
|
+
* 直接不执行 —— 表现是页面渲染出来了、但按钮全都没反应。
|
|
1421
|
+
*/
|
|
1422
|
+
res.setHeader('Content-Security-Policy', CSP_PAGE);
|
|
1423
|
+
/**
|
|
1424
|
+
* `X-Frame-Options` 必须从入口的 DENY 改成 SAMEORIGIN。
|
|
1425
|
+
*
|
|
1426
|
+
* 入口给所有响应都加了 DENY(防止面板被别的网站嵌套),但插件页
|
|
1427
|
+
* **本来就该被面板自己嵌**。留着 DENY 的后果是:iframe 加载这个地址时
|
|
1428
|
+
* 被浏览器拒绝渲染,Chrome 显示「127.0.0.1 拒绝连接」——
|
|
1429
|
+
* 看起来像服务没起来,实际上是安全头把它自己挡在门外。
|
|
1430
|
+
*
|
|
1431
|
+
* SAMEORIGIN 仍然挡住「外部站点嵌插件页」,这是需要的。
|
|
1432
|
+
*/
|
|
1433
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
1151
1434
|
res.writeHead(200, {
|
|
1152
1435
|
'Content-Type': 'text/html; charset=utf-8',
|
|
1153
1436
|
// 插件页面可能随插件更新而变,不缓存
|
|
@@ -1992,10 +2275,24 @@ export async function startWebServer({
|
|
|
1992
2275
|
log.warn(`端口租约写不进去:${e.message}`);
|
|
1993
2276
|
}
|
|
1994
2277
|
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
2278
|
+
/**
|
|
2279
|
+
* 两把地址:一把给人看(进日志),一把给 openBrowser 用(带密钥,不进日志)。
|
|
2280
|
+
*
|
|
2281
|
+
* 以前只有一把,`log.info` 直接把带 `?key=` 的完整 URL 打出去 —— 于是密钥
|
|
2282
|
+
* 落到 logs/、docker logs、截图、贴到群里求助的日志里。而面板 key 等价于
|
|
2283
|
+
* 「能装插件 = 子进程执行代码」,一条日志就是一次长期凭据泄露。
|
|
2284
|
+
*
|
|
2285
|
+
* 服务商日志、容器 json-file、journald 都会把它留存很久,所以只能保证
|
|
2286
|
+
* 「从不产生」,不能靠事后清理。
|
|
2287
|
+
*/
|
|
2288
|
+
const baseUrl = `http://${shownHost}:${actualPort}/`;
|
|
2289
|
+
// 自动打开浏览器用(main.js → openBrowser),带密钥,只走内存
|
|
2290
|
+
const url = accessKey ? `${baseUrl}?key=${encodeURIComponent(accessKey)}` : baseUrl;
|
|
2291
|
+
|
|
2292
|
+
log.info(`可视化面板: ${baseUrl}`);
|
|
2293
|
+
if (accessKey) {
|
|
2294
|
+
log.info('面板已开启访问密钥:浏览器会自动带上;手动访问时在地址后加 ?key=<你的密钥>(日志里不打印)');
|
|
2295
|
+
}
|
|
1999
2296
|
|
|
2000
2297
|
return {
|
|
2001
2298
|
server,
|