qq-bot-roost 0.8.7 → 0.8.9

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/src/web/server.js CHANGED
@@ -43,6 +43,74 @@ import {
43
43
  const log = createLogger('webui');
44
44
  const WEB_ROOT = path.join(ROOT, 'web');
45
45
 
46
+ /**
47
+ * 进程实例标识。
48
+ *
49
+ * 面板用它判断「服务是不是重启过」。重启意味着两件事同时发生:
50
+ * - 页面令牌全部重新生成 → 已打开的插件页 iframe 手里拿的全是废牌,
51
+ * 之后每个请求都会被判成跨站(403)
52
+ * - 会话 Cookie 全部失效
53
+ *
54
+ * 前端拿到这个值一变化,就可以立刻重载 iframe,而不是干等兜底轮询
55
+ * (用户会看到一条红色报错闪过,体验很差)。
56
+ */
57
+ const PROCESS_STARTED_AT = Date.now();
58
+
59
+ /**
60
+ * 主面板的 CSP。
61
+ *
62
+ * 几个取舍说明一下,免得后来人以为写错了:
63
+ * - `script-src 'self'` 是硬要求:主题脚本已外置成 /theme-boot.js,
64
+ * 面板里不再有内联 <script>。开 'unsafe-inline' 等于放弃脚本防护,
65
+ * 而面板 localStorage 里存着访问密钥。
66
+ * - `style-src` **必须**留 'unsafe-inline':app.js 里有二十多处
67
+ * `style="width:...%"` 这类动态样式,去掉它布局直接散架。
68
+ * - `img-src` 带 data: 和 blob::二维码是 SVG 内联,插件图标可能用 data URI。
69
+ * - 不设 HSTS:本地是 http,加了会让浏览器以后强制走 https 打不开。
70
+ * 反代终止 TLS 时由 Nginx/Caddy 那边加。
71
+ * - `frame-src 'self'` 是给插件页面 iframe 用的,与 `frame-ancestors 'none'`
72
+ * 不冲突:前者管「我能嵌谁」,后者管「谁能嵌我」。
73
+ */
74
+ const CSP_MAIN = [
75
+ "default-src 'self'",
76
+ "script-src 'self'",
77
+ "style-src 'self' 'unsafe-inline'",
78
+ "img-src 'self' data: blob:",
79
+ "font-src 'self' data:",
80
+ "connect-src 'self'",
81
+ "frame-src 'self'",
82
+ "object-src 'none'",
83
+ "base-uri 'none'",
84
+ "form-action 'self'",
85
+ "frame-ancestors 'none'",
86
+ ].join('; ');
87
+
88
+ /**
89
+ * 插件页面的 CSP —— 必须比主面板宽,而且**一条 `'self'` 都不能有**。
90
+ *
91
+ * 两个坑,都是实测踩出来的:
92
+ *
93
+ * 1. 插件页跑在 `sandbox="allow-scripts allow-forms"` 的 iframe 里(没有
94
+ * allow-same-origin),它的源是 **opaque origin**。CSP 里 `'self'` 的语义是
95
+ * 「文档自己的源」,而 opaque origin 跟谁都不相等 —— 于是 `connect-src 'self'`
96
+ * 会把插件页**所有** fetch 拦死。现象是页面渲染正常、数据永远加载不出来,
97
+ * 控制台报 `Failed to fetch`,而服务端一条请求记录都没有。
98
+ * 2. 所以这里改用协议通配(`http:` / `https:`)而不是源通配。
99
+ * 敢放宽的前提是隔离靠 sandbox 不靠 CSP:插件页读不到父页的 localStorage /
100
+ * Cookie / DOM,调接口必须带 URL 上那把只属于本页面的 `pt` 令牌。
101
+ *
102
+ * 也不设 `frame-ancestors`:opaque origin 下它同样认不出「谁在嵌我」,
103
+ * 反而可能连面板自己都嵌不进去。防外部嵌套由主面板那套 X-Frame-Options 负责。
104
+ */
105
+ const CSP_PAGE = [
106
+ "default-src 'none'",
107
+ "script-src 'unsafe-inline' http: https:",
108
+ "style-src 'unsafe-inline' http: https:",
109
+ "img-src data: blob: http: https:",
110
+ "font-src data: http: https:",
111
+ "connect-src http: https:",
112
+ ].join('; ');
113
+
46
114
  const MIME = {
47
115
  '.html': 'text/html; charset=utf-8',
48
116
  '.css': 'text/css; charset=utf-8',
@@ -378,6 +446,11 @@ export async function startWebServer({
378
446
  : people.find((p) => p.openid === c.id)?.name ?? '',
379
447
  }));
380
448
  }
449
+ /**
450
+ * 进程实例标识:前端一看到它变了就知道服务重启过,
451
+ * 页面令牌和会话都已作废,需要立刻重载插件页而不是等下轮轮询。
452
+ */
453
+ snap.instanceId = `${process.pid}-${PROCESS_STARTED_AT}`;
381
454
  return snap;
382
455
  };
383
456
 
@@ -447,15 +520,101 @@ export async function startWebServer({
447
520
  * timingSafeEqual 只防时序侧信道,挡不住「弱 key 被顺序猜」。
448
521
  * 正确 key 不受影响(成功后清零),误伤面很小。
449
522
  */
523
+ /**
524
+ * 面板会话:sid -> 过期时间戳。
525
+ *
526
+ * 为什么要有它:accessKey 是长期固定的,挂在 URL 上会进浏览器历史、书签同步、
527
+ * Referer、反向代理 access_log,每一条都是明文泄露面。换成 HttpOnly Cookie 之后,
528
+ * 页面脚本读不到、外链带不出去,而且重启进程就全部作废(等于一键踢掉所有会话)。
529
+ *
530
+ * 只存内存。重启后要重新用带 ?key= 的地址进一次 —— 这是有意为之:
531
+ * 落盘就等于多存一份长期凭据,和「减少凭据扩散」的目标相反。
532
+ */
533
+ const sessions = new Map();
534
+ const SESSION_TTL_MS = 7 * 864e5;
535
+ const sessionTimer = setInterval(() => {
536
+ const now = Date.now();
537
+ for (const [k, exp] of sessions) if (exp < now) sessions.delete(k);
538
+ }, 60_000);
539
+ sessionTimer.unref?.();
540
+
450
541
  const authFails = new Map(); // ip -> { count, start }
451
542
  const AUTH_WINDOW = 60_000;
452
543
  const AUTH_MAX_FAILS = 20;
453
- const authorized = (req, url) => {
544
+
545
+ /**
546
+ * 从 URL 上的 `pt` 反查插件页面。
547
+ *
548
+ * 为什么需要它:插件页在 iframe 里是 **opaque origin**(sandbox 不给
549
+ * allow-same-origin),既带不了 Cookie,也带不了 X-Access-Key 头,
550
+ * 唯一的凭证只能挂在地址上。所以给它一把只属于那个页面的随机令牌。
551
+ */
552
+ const pickPageToken = (url) => {
553
+ const pt = url.searchParams.get('pt');
554
+ if (!pt) return null;
555
+ for (const r of runtimes) {
556
+ const hit = r?.plugins?.pages?.findByToken?.(pt);
557
+ if (hit) return hit;
558
+ }
559
+ return null;
560
+ };
561
+
562
+ /**
563
+ * 页面令牌能碰哪些路径 —— **最小权限**,不是「只读」。
564
+ *
565
+ * 只放行这个插件自己的入口:
566
+ * /api/pages/<插件id>:<key>/html iframe 载入页面本体
567
+ * /api/plugins/<插件id>/panel 面板数据(dice 的战绩就是这么取的)
568
+ * /api/plugins/<插件id>/r/* 插件自己的路由(斗罗后台全在这一层)
569
+ * /api/status 只读状态
570
+ *
571
+ * **方法不限制**。斗罗那种运营后台要 POST/PATCH 改数据,只给 GET 会直接
572
+ * 把它废掉;而「限制插件写自己的数据」没有安全收益 —— 插件本来就有子进程
573
+ * 执行权。真正的边界是**不让它碰面板接口**:/api/config、/api/logs、
574
+ * /api/bots、/api/qq/call 一个都不在名单里。
575
+ */
576
+ const pageTokenAllows = (pathname, ptPage) => {
577
+ const id = String(ptPage?.pluginId ?? '');
578
+ if (!id) return false;
579
+ if (pathname === `/api/pages/${ptPage.key}/html`) return true;
580
+ if (pathname === `/api/plugins/${id}/panel`) return true;
581
+ if (pathname.startsWith(`/api/plugins/${id}/r/`)) return true;
582
+ if (pathname === '/api/status') return true;
583
+ return false;
584
+ };
585
+
586
+ const authorized = (req, url, ptPage = null) => {
454
587
  if (!accessKey) return true;
588
+
589
+ // 插件页面令牌:不是面板密钥,权限由 pageTokenAllows() 再收一道
590
+ if (ptPage) return true;
591
+
592
+ /**
593
+ * 会话 Cookie —— 浏览器正常路径都走这里。
594
+ *
595
+ * 只在正则上限制 64 位十六进制是防「畸形 cookie 撑爆 Map」,
596
+ * 不是安全边界;真正的校验是下面查表 + 过期判断。
597
+ */
598
+ const cookie = String(req.headers.cookie || '');
599
+ const sid = /(?:^|;\s*)roost_sid=([a-f0-9]{64})/.exec(cookie)?.[1];
600
+ if (sid) {
601
+ const exp = sessions.get(sid);
602
+ if (exp && exp > Date.now()) return true;
603
+ sessions.delete(sid);
604
+ }
605
+
455
606
  const ip = req.socket?.remoteAddress || 'unknown';
456
607
  const now = Date.now();
457
608
  const rec = authFails.get(ip);
458
- if (rec && now - rec.start < AUTH_WINDOW && rec.count >= AUTH_MAX_FAILS) return false;
609
+ if (rec && now - rec.start < AUTH_WINDOW && rec.count >= AUTH_MAX_FAILS) {
610
+ /**
611
+ * 锁定期要留痕。以前这里直接 `return false` 一句话都没有,
612
+ * 于是管理员只看到「访问密钥无效」,分不清是自己敲错还是有人在打,
613
+ * 也拿不到来源 IP。
614
+ */
615
+ log.error(`面板认证失败次数过多,已临时封禁 ${ip}(${Math.ceil((AUTH_WINDOW - (now - rec.start)) / 1000)} 秒后解除)`);
616
+ return false;
617
+ }
459
618
  // 兼容旧的 X-Token / ?token= 写法
460
619
  const given =
461
620
  req.headers['x-access-key'] ||
@@ -469,6 +628,8 @@ export async function startWebServer({
469
628
  const cur = rec && now - rec.start < AUTH_WINDOW ? rec : { count: 0, start: now };
470
629
  cur.count += 1;
471
630
  authFails.set(ip, cur);
631
+ // 记失败但不记密钥本身:只记来源与累计次数
632
+ log.warn(`面板认证失败:来源 ${ip},本窗口第 ${cur.count} 次`);
472
633
  if (authFails.size > 5000) authFails.clear(); // 防内存膨胀
473
634
  }
474
635
  return ok;
@@ -491,7 +652,17 @@ export async function startWebServer({
491
652
  *
492
653
  * 端口不固定(会自动顺延),所以只比 host 部分。
493
654
  */
494
- const isSameOrigin = (req) => {
655
+ const isSameOrigin = (req, ptPage = null) => {
656
+ /**
657
+ * 插件页的例外:sandbox 不给 allow-same-origin 之后,它就是 opaque origin,
658
+ * 发请求时 `Origin` 恒为字符串 `"null"`,new URL('null') 会抛错 → 当跨站拒掉。
659
+ *
660
+ * 只对「带有效页面令牌」的请求放行,而且这类请求的路径已经被
661
+ * pageTokenAllows() 限死在该插件自己的接口上。没有 pt 的 `Origin: null`
662
+ * 依旧按跨站处理。
663
+ */
664
+ if (ptPage && String(req.headers.origin || '') === 'null') return true;
665
+
495
666
  const host = String(req.headers['host'] || '');
496
667
  for (const h of ['origin', 'referer']) {
497
668
  const v = req.headers[h];
@@ -615,6 +786,17 @@ export async function startWebServer({
615
786
  }, 60000).unref?.();
616
787
 
617
788
  const server = http.createServer(async (req, res) => {
789
+ /**
790
+ * 安全响应头:面板一旦反代到公网,这几条是浏览器侧的最后一道。
791
+ *
792
+ * `Referrer-Policy: no-referrer` 尤其重要——它顺手堵掉「点面板里的外链
793
+ * 把 URL 上的密钥带到别人服务器」那条路(GitHub/Gitee 链接就在面板里)。
794
+ */
795
+ res.setHeader('X-Content-Type-Options', 'nosniff');
796
+ res.setHeader('X-Frame-Options', 'DENY');
797
+ res.setHeader('Referrer-Policy', 'no-referrer');
798
+ res.setHeader('Content-Security-Policy', CSP_MAIN);
799
+
618
800
  /**
619
801
  * URL 解析必须在 try 里面。
620
802
  *
@@ -632,10 +814,126 @@ export async function startWebServer({
632
814
  return json(res, 400, { error: '请求地址不合法' });
633
815
  }
634
816
 
817
+ /**
818
+ * 首跳把 `?key=` 换成会话 Cookie,然后 302 到干净地址。
819
+ *
820
+ * 只对**页面导航**这么做(`/api/` 不动):脚本类调用方(roost-mcp 这类)
821
+ * 还在用 `?key=` 直连接口,一刀切会让它们全挂。等它们改成 X-Access-Key 头
822
+ * 再收掉 query 分支。
823
+ *
824
+ * 换成 Cookie 之后浏览器地址栏、历史记录、书签同步里就没有密钥了,
825
+ * 而 Cookie 是 HttpOnly,页面脚本读不到。
826
+ */
827
+ if (!pathname.startsWith('/api/')) {
828
+ const qKey = url.searchParams.get('key') || url.searchParams.get('token');
829
+ if (qKey && accessKey && safeEqual(qKey, accessKey)) {
830
+ const sid = crypto.randomBytes(32).toString('hex');
831
+ sessions.set(sid, Date.now() + SESSION_TTL_MS);
832
+ const rest = new URLSearchParams(url.search);
833
+ rest.delete('key');
834
+ rest.delete('token');
835
+ const qs = rest.toString();
836
+ // 用 url.pathname(原始编码形式)而不是解码后的,否则带 %2F 的路径会被改坏
837
+ res.writeHead(302, {
838
+ 'Set-Cookie': `roost_sid=${sid}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.floor(
839
+ SESSION_TTL_MS / 1000,
840
+ )}`,
841
+ Location: url.pathname + (qs ? `?${qs}` : ''),
842
+ });
843
+ return res.end();
844
+ }
845
+ }
846
+
847
+ /**
848
+ * 面板首页的「没凭证」提示。
849
+ *
850
+ * 会话改成 Cookie 之后,最常见的新问题是「书签里的地址没有 key 了,打开一片空白」。
851
+ * 静态文件本身不鉴权(也不该鉴权,不然 CSS/JS 都拿不到),但首页可以给一句人话,
852
+ * 而不是让用户对着空面板怀疑是不是坏了。
853
+ */
854
+ if ((pathname === '/' || pathname === '/index.html') && accessKey) {
855
+ const ck = String(req.headers.cookie || '');
856
+ const sid0 = /(?:^|;\s*)roost_sid=([a-f0-9]{64})/.exec(ck)?.[1];
857
+ const exp0 = sid0 ? sessions.get(sid0) : 0;
858
+ if (!exp0 || exp0 <= Date.now()) {
859
+ res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8' });
860
+ return res.end(
861
+ '<!doctype html><meta charset="utf-8"><title>需要访问密钥</title>' +
862
+ '<body style="font:15px/1.6 system-ui;max-width:34em;margin:14vh auto;padding:0 1.2em">' +
863
+ '<h1 style="font-size:18px">需要访问密钥</h1>' +
864
+ '<p>面板已开启访问密钥,但当前浏览器还没有会话(进程重启后会话会全部失效)。</p>' +
865
+ '<p>请用带密钥的地址打开一次,之后会在浏览器里记住 7 天:</p>' +
866
+ '<pre style="background:rgba(128,128,128,.14);padding:10px 12px;border-radius:6px;overflow:auto">' +
867
+ `http://${shownHost}:${actualPort}/?key=你的密钥</pre>` +
868
+ '<p style="opacity:.65;font-size:13px">密钥在 config/bot.json 的 webui.accessKey,或启动时设置的环境变量里。</p>',
869
+ );
870
+ }
871
+ }
872
+
635
873
  try {
636
874
  // ---------------- API ----------------
637
875
  if (pathname.startsWith('/api/')) {
638
- if (!authorized(req, url)) return json(res, 401, { error: '访问密钥无效' });
876
+ const ptPage = pickPageToken(url);
877
+
878
+ /**
879
+ * 插件页是 opaque origin(跨源),浏览器要求响应带 CORS 头才肯把内容
880
+ * 交给页面。只在带有效 pt 时回,且回 `null` —— 这只匹配 opaque origin,
881
+ * 普通网页拿到的 `Access-Control-Allow-Origin: null` 与自己的 Origin
882
+ * 不相等,仍然读不到。
883
+ */
884
+ if (ptPage && String(req.headers.origin || '') === 'null') {
885
+ res.setHeader('Access-Control-Allow-Origin', 'null');
886
+ res.setHeader('Vary', 'Origin');
887
+ }
888
+
889
+ /**
890
+ * CORS 预检直接答,**不要先认令牌**。
891
+ *
892
+ * 这里踩过一次:原来写成 `if (ptPage && req.method === 'OPTIONS')`,
893
+ * 于是「令牌没认出来」的预检会继续往下走,撞到「写操作必须
894
+ * application/json」那道闸门 —— 预检没有 body,于是回 415,
895
+ * 浏览器判定预检失败,页面拿到的是 `Failed to fetch`。
896
+ *
897
+ * 而插件页里 `fetch` 只要带了 `Content-Type: application/json`
898
+ * (斗罗对所有请求都带),连 GET 都会触发预检,所以这个坑表现为
899
+ * 「页面渲染正常、数据一条都没有」。
900
+ *
901
+ * 预检不返回任何数据,谁来问都答 204 即可,不需要鉴权。
902
+ */
903
+ if (req.method === 'OPTIONS') {
904
+ const origin = String(req.headers.origin || '');
905
+ /**
906
+ * 允许的头**回显浏览器要的那个**,不写死。
907
+ *
908
+ * 踩过的坑:原来写死 `Content-Type`,看着没错,但浏览器报的是
909
+ * 「Request header field content-type is not allowed by
910
+ * Access-Control-Allow-Headers in preflight response」。
911
+ * 回显 `Access-Control-Request-Headers` 就永远不会对不上 ——
912
+ * 它问什么我给什么,反正是插件页自己的只读请求。
913
+ *
914
+ * **不要加 `Access-Control-Max-Age`**:加了浏览器会把这次预检结果
915
+ * 缓存好几分钟,一旦某次响应是错的(或者服务端刚改过逻辑),
916
+ * 后面再改代码都没用 —— 浏览器压根不会重新来问,只会一直报同一个错,
917
+ * 排查时会以为是代码没生效。本地面板的预检几乎没有开销,不值得缓存。
918
+ */
919
+ const wantHeaders = String(req.headers['access-control-request-headers'] || '');
920
+ res.writeHead(204, {
921
+ ...(origin === 'null' ? { 'Access-Control-Allow-Origin': 'null', Vary: 'Origin' } : {}),
922
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, PUT, DELETE, OPTIONS',
923
+ 'Access-Control-Allow-Headers': wantHeaders || 'Content-Type, X-Access-Key, X-Token',
924
+ });
925
+ return res.end();
926
+ }
927
+
928
+ if (!authorized(req, url, ptPage)) return json(res, 401, { error: '访问密钥无效' });
929
+
930
+ /**
931
+ * 页面令牌的权限闸门:**紧跟着鉴权做**,不能放到路由里。
932
+ * 否则拿到任意插件页 token 就能调 /api/config、/api/logs。
933
+ */
934
+ if (ptPage && !pageTokenAllows(pathname, ptPage)) {
935
+ return json(res, 403, { error: '插件页面无权访问这个接口' });
936
+ }
639
937
 
640
938
  /**
641
939
  * CSRF / 同源校验必须在**路由之前**。
@@ -644,9 +942,41 @@ export async function startWebServer({
644
942
  * 完全不受保护——跨站页面一个 text/plain 表单就能把它们打出去。
645
943
  */
646
944
  if (req.method !== 'GET' && req.method !== 'HEAD' && !isJsonRequest(req)) {
945
+ /**
946
+ * 这里以前完全静默 —— 而它恰恰是「插件页一片空白」最可能的入口:
947
+ * CORS 预检(OPTIONS)没有 body,如果哪天 OPTIONS 分支漏了或顺序错了,
948
+ * 预检就会掉进这里拿 415,浏览器判定预检失败,页面只看到
949
+ * `TypeError: Failed to fetch`,服务端一条日志都没有,排查全靠猜。
950
+ */
951
+ log.warn(
952
+ `拒绝写操作:${req.method} ${pathname}` +
953
+ `(Content-Type: ${req.headers['content-type'] || '无'},需要 application/json)`,
954
+ );
647
955
  return json(res, 415, { error: '写操作需要 Content-Type: application/json' });
648
956
  }
649
- if (!isSameOrigin(req)) {
957
+ if (!isSameOrigin(req, ptPage)) {
958
+ /**
959
+ * 这里踩过一个很隐蔽的坑:插件页是 opaque origin,请求 `Origin` 恒为
960
+ * 字符串 `null`。它发的请求如果没带有效 `pt`(比如面板重启后 iframe
961
+ * 还拿着旧令牌),就会落到这个分支。
962
+ *
963
+ * 两个后果叠加,导致排查极其困难:
964
+ * 1. 403 响应没带 CORS 头 → 浏览器不给页面读状态码,页面只看到
965
+ * `TypeError: Failed to fetch`,看起来像网络断了或服务没起
966
+ * 2. 这里不写日志 → 服务端一条记录都没有,像是请求根本没到
967
+ *
968
+ * 所以:给 opaque origin 补上 CORS 头(让它能读到 403 和错误文案),
969
+ * 并且**一定要留一条 warn**。没带令牌的情况单独说清楚,否则下次
970
+ * 还是得靠猜。
971
+ */
972
+ if (String(req.headers.origin || '') === 'null') {
973
+ res.setHeader('Access-Control-Allow-Origin', 'null');
974
+ res.setHeader('Vary', 'Origin');
975
+ }
976
+ log.warn(
977
+ `拒绝跨站请求:${req.method} ${pathname}` +
978
+ `(Origin: ${req.headers.origin || '无'}${ptPage ? ',页面令牌有效' : ',无有效页面令牌'})`,
979
+ );
650
980
  return json(res, 403, { error: '跨站请求被拒绝' });
651
981
  }
652
982
 
@@ -880,7 +1210,36 @@ export async function startWebServer({
880
1210
  if (typeof body.appSecret === 'string' && body.appSecret && body.appSecret !== '********') {
881
1211
  cfg.appSecret = body.appSecret.trim();
882
1212
  }
883
- if (body.baseUrl) cfg.baseUrl = body.baseUrl.trim();
1213
+ /**
1214
+ * baseUrl 必须校验,否则这里等于「把 access_token 送人」的开关。
1215
+ *
1216
+ * `api.js` 用这个 baseUrl 拼请求,并带上 `Authorization: QQBot <token>`。
1217
+ * 面板一旦被改成第三方地址,下一次 API 调用就把机器人令牌发到那台机器,
1218
+ * 拿到令牌就能冒充机器人在它所在的任何群里发消息 —— 面板弱口令会因此
1219
+ * 直接升级成「机器人身份被完全接管」。
1220
+ *
1221
+ * 用 `*.qq.com` 而不是写死 `api.sgroup.qq.com`:官方历史上用过
1222
+ * `api.bot.qq.com` 这类别名(现有配置里就有),写死会把用户自己的
1223
+ * 合法配置挡回去。qq.com 是腾讯的域,第三方拿不到子域。
1224
+ */
1225
+ if (body.baseUrl !== undefined && body.baseUrl !== '') {
1226
+ let u = null;
1227
+ try {
1228
+ u = new URL(String(body.baseUrl).trim());
1229
+ } catch {
1230
+ return json(res, 400, { error: 'baseUrl 不是合法 URL' });
1231
+ }
1232
+ const host = u.hostname.toLowerCase();
1233
+ const official = host === 'qq.com' || host.endsWith('.qq.com');
1234
+ if (u.protocol !== 'https:' || !official) {
1235
+ return json(res, 400, {
1236
+ error:
1237
+ 'baseUrl 只允许 https 的腾讯官方域名(如 https://api.sgroup.qq.com)。\n' +
1238
+ '改成第三方地址会把 access_token 发过去,等于交出机器人身份。',
1239
+ });
1240
+ }
1241
+ cfg.baseUrl = u.origin;
1242
+ }
884
1243
  if (body.intents !== undefined) {
885
1244
  const n = Number(body.intents);
886
1245
  if (!Number.isInteger(n) || n < 0 || n > 0x7fffffff) {
@@ -1003,7 +1362,12 @@ export async function startWebServer({
1003
1362
  * 面板据此显示「统计不可用」而不是画一堆 0。
1004
1363
  */
1005
1364
  if (pathname === '/api/stats' && req.method === 'GET') {
1006
- const days = Number(url.searchParams.get('days')) || 7;
1365
+ /**
1366
+ * `days` 必须夹紧。同文件下面的 `/api/stats/export` 早就 clamp 到
1367
+ * 1~365 了,这里漏了 —— 传个极大值就能让 SQLite 扫全表,
1368
+ * 面板上一次点击就变成一次自伤式 DoS。
1369
+ */
1370
+ const days = Math.max(1, Math.min(365, Number(url.searchParams.get('days')) || 7));
1007
1371
  try {
1008
1372
  return json(res, 200, rt.stats?.overview(days) ?? { available: false });
1009
1373
  } catch (e) {
@@ -1148,6 +1512,23 @@ export async function startWebServer({
1148
1512
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
1149
1513
  return res.end('页面不存在(插件可能已卸载或重载)');
1150
1514
  }
1515
+ /**
1516
+ * 插件页要单独换一套 CSP:它允许内联脚本和样式。
1517
+ * 不换的话会继承入口那套 `script-src 'self'`,插件页的内联脚本
1518
+ * 直接不执行 —— 表现是页面渲染出来了、但按钮全都没反应。
1519
+ */
1520
+ res.setHeader('Content-Security-Policy', CSP_PAGE);
1521
+ /**
1522
+ * `X-Frame-Options` 必须从入口的 DENY 改成 SAMEORIGIN。
1523
+ *
1524
+ * 入口给所有响应都加了 DENY(防止面板被别的网站嵌套),但插件页
1525
+ * **本来就该被面板自己嵌**。留着 DENY 的后果是:iframe 加载这个地址时
1526
+ * 被浏览器拒绝渲染,Chrome 显示「127.0.0.1 拒绝连接」——
1527
+ * 看起来像服务没起来,实际上是安全头把它自己挡在门外。
1528
+ *
1529
+ * SAMEORIGIN 仍然挡住「外部站点嵌插件页」,这是需要的。
1530
+ */
1531
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
1151
1532
  res.writeHead(200, {
1152
1533
  'Content-Type': 'text/html; charset=utf-8',
1153
1534
  // 插件页面可能随插件更新而变,不缓存
@@ -1992,10 +2373,24 @@ export async function startWebServer({
1992
2373
  log.warn(`端口租约写不进去:${e.message}`);
1993
2374
  }
1994
2375
 
1995
- const url = `http://${shownHost}:${actualPort}/${
1996
- accessKey ? '?key=' + encodeURIComponent(accessKey) : ''
1997
- }`;
1998
- log.info(`可视化面板: ${url}`);
2376
+ /**
2377
+ * 两把地址:一把给人看(进日志),一把给 openBrowser 用(带密钥,不进日志)。
2378
+ *
2379
+ * 以前只有一把,`log.info` 直接把带 `?key=` 的完整 URL 打出去 —— 于是密钥
2380
+ * 落到 logs/、docker logs、截图、贴到群里求助的日志里。而面板 key 等价于
2381
+ * 「能装插件 = 子进程执行代码」,一条日志就是一次长期凭据泄露。
2382
+ *
2383
+ * 服务商日志、容器 json-file、journald 都会把它留存很久,所以只能保证
2384
+ * 「从不产生」,不能靠事后清理。
2385
+ */
2386
+ const baseUrl = `http://${shownHost}:${actualPort}/`;
2387
+ // 自动打开浏览器用(main.js → openBrowser),带密钥,只走内存
2388
+ const url = accessKey ? `${baseUrl}?key=${encodeURIComponent(accessKey)}` : baseUrl;
2389
+
2390
+ log.info(`可视化面板: ${baseUrl}`);
2391
+ if (accessKey) {
2392
+ log.info('面板已开启访问密钥:浏览器会自动带上;手动访问时在地址后加 ?key=<你的密钥>(日志里不打印)');
2393
+ }
1999
2394
 
2000
2395
  return {
2001
2396
  server,
package/src/webhook.js CHANGED
@@ -14,7 +14,16 @@
14
14
  * 这点和官方示例一致——一个回调地址服务多个机器人,省得每个机器人开一个端口。
15
15
  */
16
16
  import http from 'node:http';
17
+ import fs from 'node:fs';
18
+ /**
19
+ * 这里**必须具名导入** `join` / `dirname`,不能 `import path from 'node:path'`。
20
+ * constructor 有个参数就叫 `path`(回调路径),会把模块级的 `path` 遮蔽掉,
21
+ * 于是 `path.join(...)` 变成「字符串.join」→ `TypeError: path.join is not a function`,
22
+ * 整个 Webhook 服务直接起不来。这个坑是靠实测抓到的,不是看代码能看出来的。
23
+ */
24
+ import { join, dirname } from 'node:path';
17
25
  import { createLogger } from './logger.js';
26
+ import { DIRS } from './paths.js';
18
27
  import { verifyRequest, verifyEventSignature } from './sign.js';
19
28
 
20
29
  const log = createLogger('webhook');
@@ -43,6 +52,63 @@ export class WebhookServer {
43
52
  this.running = false;
44
53
  /** 收到过多少事件,面板显示用 */
45
54
  this.counters = { received: 0, rejected: 0, verify: 0 };
55
+ /**
56
+ * 回调验证(op=13)的状态,按 appId 记:`{ okAt: 时间戳, hits: [最近一分钟的尝试] }`。
57
+ *
58
+ * 为什么需要它,见 #handle 里 op=13 那段长注释。简单说:这个接口会用
59
+ * AppSecret 给「任意提交内容」签名,而事件验签用的是同一把密钥、同一种
60
+ * 拼接方式 —— 放开了就等于对外提供签名服务。
61
+ */
62
+ this.verifyState = new Map();
63
+ this.verifyFile = join(DIRS.data, 'webhook-verified.json');
64
+ this.#loadVerifyState();
65
+ }
66
+
67
+ /** 读回落盘的回调验证记录:重启不该让平台被迫重新验证一次 */
68
+ #loadVerifyState() {
69
+ try {
70
+ const obj = JSON.parse(fs.readFileSync(this.verifyFile, 'utf8'));
71
+ for (const [id, at] of Object.entries(obj)) {
72
+ const t = Number(at);
73
+ if (Number.isFinite(t) && t > 0) this.verifyState.set(id, { okAt: t, hits: [] });
74
+ }
75
+ } catch (e) {
76
+ // 文件不存在是正常的(第一次跑),其它错误才值得说一句
77
+ if (e.code !== 'ENOENT') log.warn(`回调验证记录读不出来(忽略):${e.message}`);
78
+ }
79
+ }
80
+
81
+ #saveVerifyState() {
82
+ try {
83
+ fs.mkdirSync(dirname(this.verifyFile), { recursive: true });
84
+ const obj = {};
85
+ for (const [id, s] of this.verifyState) if (s.okAt) obj[id] = s.okAt;
86
+ const tmp = `${this.verifyFile}.tmp`;
87
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
88
+ fs.renameSync(tmp, this.verifyFile); // 原子替换,避免写一半断电
89
+ } catch (e) {
90
+ log.warn(`回调验证记录写不进去(不影响运行):${e.message}`);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 重置某个机器人的回调验证状态。
96
+ *
97
+ * 用途:平台那边改了回调地址 / 换了域名,需要重新走一次 op=13 验证。
98
+ * 面板可以挂一个「重新验证回调」按钮调它,也可以直接删
99
+ * `data/webhook-verified.json` 里对应的那条。
100
+ *
101
+ * @returns {boolean} 是否真的重置了(原来有记录才返回 true)
102
+ */
103
+ resetVerify(appId) {
104
+ const id = String(appId ?? '').trim();
105
+ if (!id) return false;
106
+ const s = this.verifyState.get(id);
107
+ if (!s) return false;
108
+ this.verifyState.set(id, { okAt: 0, hits: [] });
109
+ this.#saveVerifyState();
110
+ log.info(`[${id}] 已重置回调验证状态,等待平台重新验证`);
111
+ return true;
46
112
  }
47
113
 
48
114
  /**
@@ -173,14 +239,68 @@ export class WebhookServer {
173
239
 
174
240
  // op=13:平台验证回调地址,用 AppSecret 签名后原样返回
175
241
  if (payload?.op === 13) {
242
+ /**
243
+ * ⚠️ 整段 Webhook 里最危险的就是这里,改之前把这段读完。
244
+ *
245
+ * op=13 的签名内容是 `${event_ts}${plain_token}`,事件验签是
246
+ * `${timestamp}${rawBody}` —— **同一把密钥、同一种拼接、没有域分离**。
247
+ * 而 op=13 在验签之前、也不需要任何认证,只要 appId 在册就回签。
248
+ *
249
+ * 于是它可以被当成「任意内容签名服务」:把要伪造的事件体当 plain_token
250
+ * 提交,拿回来的签名直接能过事件验签 —— 冒充任意 openid 触发管理命令。
251
+ * 也就是说 `verifySignature: true` 在攻击者面前是形同虚设的。
252
+ *
253
+ * 签名内容由官方协议规定,应用层改不了(改了平台就不认)。所以只能
254
+ * 收紧「谁能调、能调几次」:
255
+ *
256
+ * 1. **plain_token 形态校验**:事件体必然是 JSON,一定含 { } " \ 或空白;
257
+ * 合法 token 是平台生成的随机串。这条零成本、不影响正常流程,是主防线。
258
+ * 2. **24 小时内只回签一次**:平台只在配置回调地址时验证。
259
+ * 不做成「永久一次」是因为换域名/端口要重新验证,永久锁死会让用户
260
+ * 卡在「回调地址验证失败」上,反而制造新的排查成本。
261
+ * 3. **每分钟最多 3 次尝试**,把爆破与滥用压到没有价值。
262
+ */
263
+ const st = this.verifyState.get(appId) ?? { okAt: 0, hits: [] };
264
+ const now = Date.now();
265
+ st.hits = st.hits.filter((t) => now - t < 60_000);
266
+
267
+ if (st.okAt && now - st.okAt < 24 * 3600_000) {
268
+ this.counters.rejected += 1;
269
+ log.warn(
270
+ `[${appId}] 拒绝重复的 op=13:该 appId 24 小时内已通过验证。` +
271
+ `如果确实要重新验证(换回调地址等),调 resetVerify() 或删掉 ` +
272
+ `data/webhook-verified.json 里这一条`,
273
+ );
274
+ return this.#json(res, 403, { error: 'already verified' });
275
+ }
276
+ if (st.hits.length >= 3) {
277
+ this.counters.rejected += 1;
278
+ log.warn(`[${appId}] op=13 尝试过频(每分钟上限 3 次),已限速`);
279
+ return this.#json(res, 429, { error: 'too many validation attempts' });
280
+ }
281
+ st.hits.push(now);
282
+ this.verifyState.set(appId, st);
283
+
284
+ const tok = String(payload?.d?.plain_token ?? '');
285
+ if (!tok || tok.length > 128 || /[\s{}"\\]/.test(tok)) {
286
+ this.counters.rejected += 1;
287
+ log.warn(
288
+ `[${appId}] op=13 的 plain_token 形态异常,已拒绝(疑似把事件体当 token 提交)`,
289
+ );
290
+ return this.#json(res, 400, { error: 'invalid validation' });
291
+ }
292
+
176
293
  const signed = verifyRequest(bot.secret, payload);
177
294
  if (!signed) {
178
295
  this.counters.rejected += 1;
179
296
  log.warn(`[${appId}] 回调验证缺少 plain_token / event_ts`);
180
297
  return this.#json(res, 400, { error: 'invalid validation' });
181
298
  }
299
+ st.okAt = now;
300
+ this.verifyState.set(appId, st);
301
+ this.#saveVerifyState();
182
302
  this.counters.verify += 1;
183
- log.info(`[${appId}] Webhook 回调验证通过`);
303
+ log.info(`[${appId}] Webhook 回调验证通过(24 小时内不再重复回签)`);
184
304
  return this.#json(res, 200, signed);
185
305
  }
186
306