qq-bot-roost 0.8.8 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -36,7 +36,10 @@
36
36
  "create": "node scripts/create-plugin.mjs",
37
37
  "test:plugin": "node scripts/test-plugin.mjs",
38
38
  "live": "node scripts/live-test.mjs",
39
- "market:index": "node scripts/build-market-index.mjs"
39
+ "market:index": "node scripts/build-market-index.mjs",
40
+ "sums": "node scripts/build-sums.mjs",
41
+ "sums:check": "node scripts/build-sums.mjs --check",
42
+ "sums:verify": "node scripts/build-sums.mjs --verify"
40
43
  },
41
44
  "author": {
42
45
  "name": "倦飞之鸟"
package/src/fsutil.js CHANGED
@@ -23,7 +23,20 @@ export async function writeJsonAtomic(file, data) {
23
23
  const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
24
24
  const text = `${JSON.stringify(data, null, 2)}\n`;
25
25
  try {
26
- await fs.writeFile(tmp, text, 'utf8');
26
+ /**
27
+ * `mode: 0o600` —— 只有属主能读写。
28
+ *
29
+ * 这些 JSON 里有 `config/bot.json`、`config/bots/<id>.json`,都带明文
30
+ * AppSecret;还有权限表、群档案。Linux 默认 umask 022 下新建文件是 644,
31
+ * 同机其他用户直接 `cat` 就能拿到机器人凭证。
32
+ *
33
+ * 注意两点:
34
+ * - mode 只在**创建**时生效,而这里的 tmp 每次都是新文件,所以一定吃到
35
+ * - rename 会保留临时文件的权限,覆盖掉旧文件原来的 644 —— 也就是说
36
+ * 用户只要保存过一次配置,老文件的权限就被修正了,不需要单独做迁移
37
+ * - Windows 上 mode 被忽略,不影响运行
38
+ */
39
+ await fs.writeFile(tmp, text, { encoding: 'utf8', mode: 0o600 });
27
40
  await fs.rename(tmp, file);
28
41
  } catch (e) {
29
42
  await fs.rm(tmp, { force: true }).catch(() => {});
package/src/hashdir.js CHANGED
@@ -77,16 +77,24 @@ async function collect(root) {
77
77
  }
78
78
 
79
79
  /**
80
- * 算目录的内容指纹。
80
+ * 按给定的相对路径清单算指纹。
81
81
  *
82
- * @param {string} dir 插件目录
82
+ * 为什么要单独一个入口:仓库根目录里有 `data/`、`logs/`、`config/`、`plugins/`
83
+ * 这些**不会被 tarball 打包**的东西,直接 `hashDir(ROOT)` 会把它们算进去,
84
+ * 跟「解包后的 tarball」永远对不上。
85
+ *
86
+ * 所以发布流程用 `git ls-files` 拿到「真正会被打进包的文件」,再喂给这里;
87
+ * 更新校验则用 `hashDir(解包目录)`。两边必须用同一套拼接方式,否则校验必失败。
88
+ *
89
+ * @param {string} root 根目录
90
+ * @param {string[]} rels 相对路径(`/` 分隔);内部会去重并排序,顺序无关
83
91
  * @returns {Promise<string>} 64 位十六进制
84
92
  */
85
- export async function hashDir(dir) {
86
- const files = await collect(dir);
93
+ export async function hashFileList(root, rels) {
94
+ const files = [...new Set(rels)].sort();
87
95
  const h = crypto.createHash('sha256');
88
96
  for (const rel of files) {
89
- const buf = normalize(await fs.readFile(path.join(dir, rel)));
97
+ const buf = normalize(await fs.readFile(path.join(root, rel)));
90
98
  h.update(rel);
91
99
  h.update('\0');
92
100
  h.update(String(buf.length));
@@ -96,3 +104,38 @@ export async function hashDir(dir) {
96
104
  }
97
105
  return h.digest('hex');
98
106
  }
107
+
108
+ /**
109
+ * 算目录的内容指纹。
110
+ *
111
+ * @param {string} dir 插件目录
112
+ * @param {{exclude?: string[], only?: string[]}} [opts]
113
+ * - `exclude`:精确匹配要跳过的相对路径(如 `SHA256SUMS` 这种会自引用的文件)
114
+ * - `only`:只算这些路径(`src` 这类目录前缀,或 `package.json` 这类文件名)。
115
+ * 用途:GitHub 的 tarball 是**全量**(含 .github、scripts、sdk),
116
+ * 而 Gitee 源只拉会被更新覆盖的那几个条目,两边目录内容天生不同。
117
+ * 用 `only` 把范围收窄到「真正会被替换的代码」,两边才算得出同一个值。
118
+ * @returns {Promise<string>} 64 位十六进制
119
+ */
120
+ export async function hashDir(dir, opts = {}) {
121
+ /**
122
+ * 根目录不存在时必须报错,**不能静默返回空哈希**。
123
+ *
124
+ * `collect()` 里对每个目录都有 try/catch(遍历时子目录读不到要跳过),
125
+ * 但连根目录都不存在时它也会安静地返回空数组,算出来就是
126
+ * `e3b0c442…`(空字符串的 sha256)—— 那是一个**合法值**,会像真指纹一样
127
+ * 参与比对,现象是「内容全变了」而不是「路径写错了」,排查时非常误导。
128
+ */
129
+ const st = await fs.stat(dir).catch(() => null);
130
+ if (!st?.isDirectory()) throw new Error(`算指纹失败:目录不存在或不是目录 —— ${dir}`);
131
+
132
+ const skip = new Set(opts.exclude ?? []);
133
+ const only = (opts.only ?? []).map((p) => String(p));
134
+ let files = (await collect(dir)).filter((rel) => !skip.has(rel));
135
+ if (only.length) {
136
+ files = files.filter((rel) =>
137
+ only.some((p) => rel === p || rel.startsWith(p.endsWith('/') ? p : `${p}/`)),
138
+ );
139
+ }
140
+ return hashFileList(dir, files);
141
+ }
package/src/mirror.js CHANGED
@@ -86,9 +86,36 @@ const AUTO_TTL = 10 * 60_000;
86
86
  export function configureMirror(globalCfg) {
87
87
  const n = globalCfg?.network ?? {};
88
88
  const id = String(n.mirror ?? 'auto').trim() || 'auto';
89
+
90
+ /**
91
+ * 自定义下载前缀必须是 https。
92
+ *
93
+ * 这个值会被直接拼在 tarball 地址前面(见 applyMirror),而这个项目**更新包
94
+ * 没有内容校验**(updater.js 的 expectSha256 恒为 null)。也就是说:
95
+ * 填一个 http 地址,等于让链路上的任何人决定你下一版跑什么代码。
96
+ *
97
+ * 不合法就退回默认(不用它),而不是抛错 —— 配置文件写错了不该让整个框架起不来。
98
+ */
99
+ let custom = String(n.mirrorCustom ?? '').trim();
100
+ if (custom) {
101
+ let u = null;
102
+ try {
103
+ u = new URL(custom.includes('://') ? custom : `https://${custom}`);
104
+ } catch {
105
+ u = null;
106
+ }
107
+ if (!u || u.protocol !== 'https:') {
108
+ log.warn(
109
+ `network.mirrorCustom 必须是 https:// 开头,已忽略:${custom}\n` +
110
+ ' (自定义下载源会决定你下一版跑什么代码,明文 http 等于把它交给链路上的任何人)',
111
+ );
112
+ custom = '';
113
+ }
114
+ }
115
+
89
116
  current = {
90
117
  id: PRESETS.some((p) => p.id === id) ? id : 'auto',
91
- custom: String(n.mirrorCustom ?? '').trim(),
118
+ custom,
92
119
  // Gitee 私人令牌:选填。公开仓库匿名也能用,填了只是把 API 配额提上去
93
120
  giteeToken: String(n.giteeToken ?? '').trim(),
94
121
  };
@@ -160,6 +187,17 @@ export function applyMirror(url, preset, custom = current.custom) {
160
187
  // 允许用户只填域名,自动补协议和结尾斜杠
161
188
  let b = base.trim();
162
189
  if (!/^https?:\/\//i.test(b)) b = `https://${b}`;
190
+ /**
191
+ * 自定义前缀必须是 https —— 这里再挡一道。
192
+ *
193
+ * configureMirror 已经校验过配置里的值,但 `probeMirrors` 的 custom 是
194
+ * 面板请求体直接传进来的(「测速」按钮),不经过 configureMirror。
195
+ * 不合法就**退回原始地址**,不要拼一个 http 前缀上去。
196
+ */
197
+ if (preset.kind === 'custom' && !/^https:\/\//i.test(b)) {
198
+ log.warn(`自定义下载源必须是 https,已忽略:${base}`);
199
+ return url;
200
+ }
163
201
  if (!b.endsWith('/')) b += '/';
164
202
  // 已经是这个前缀了就别套第二次
165
203
  if (url.startsWith(b)) return url;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 更新会替换的代码条目(单一来源)
3
+ * ================================
4
+ *
5
+ * 三处共用:
6
+ * 1. `updater.js` 备份与替换(rsync 式覆盖,绝不碰 config/data/logs/plugins)
7
+ * 2. `gitee.js` 的前缀过滤(Gitee 没有 archive 直链,只能按清单逐个拉)
8
+ * 3. `scripts/build-sums.mjs` 算内容指纹的范围
9
+ *
10
+ * 为什么指纹也按这个范围算:GitHub 的 tarball 是**全量**(还带 .github、
11
+ * scripts、sdk、types),而 Gitee 源只拉下面这几个条目。如果指纹按「包里的
12
+ * 全部文件」算,两条下载路径永远对不上 —— 一边写死校验失败,一边等于没校验。
13
+ *
14
+ * 改这个清单要连带重跑 `npm run sums`,否则已发布的指纹会全部失配。
15
+ */
16
+ export const CODE_ENTRIES = [
17
+ 'src',
18
+ 'web',
19
+ 'test',
20
+ 'docs',
21
+ 'package.json',
22
+ 'README.md',
23
+ 'LICENSE',
24
+ 'start.bat',
25
+ ];
26
+
27
+ /** 绝对不能删/覆盖的目录(双保险) */
28
+ export const PROTECTED_ENTRIES = new Set(['config', 'data', 'logs', 'plugins', 'node_modules', '.git']);
package/src/updater.js CHANGED
@@ -20,6 +20,8 @@ import { createLogger } from './logger.js';
20
20
  import { fetchAndExtract } from './tarball.js';
21
21
  import { fetchViaMirror, currentMirror } from './mirror.js';
22
22
  import { fetchGiteeTree, detectGiteeRef, giteeRawUrl } from './gitee.js';
23
+ import { hashDir } from './hashdir.js';
24
+ import { CODE_ENTRIES, PROTECTED_ENTRIES as PROTECTED } from './update-scope.js';
23
25
  import { ROOT } from './config.js';
24
26
 
25
27
  const log = createLogger('update');
@@ -27,11 +29,11 @@ const log = createLogger('update');
27
29
  /** 仓库信息。换成自己的 fork 就改这里 */
28
30
  const REPO = 'tdcq9594/qq-bot-roost';
29
31
 
30
- /** 允许被更新覆盖的顶层条目。**用户数据一个都不在里面** */
31
- const CODE_ENTRIES = ['src', 'web', 'test', 'docs', 'package.json', 'README.md', 'LICENSE', 'start.bat'];
32
-
33
- /** 绝对不能删的目录,双保险 */
34
- const PROTECTED = new Set(['config', 'data', 'logs', 'plugins', 'node_modules', '.git']);
32
+ /**
33
+ * 允许被更新覆盖的顶层条目、以及绝对不能碰的目录,都在 ./update-scope.js。
34
+ * 内容指纹用的是**同一份清单**——GitHub tarball 是全量、Gitee 只拉这几个条目,
35
+ * 范围不一致的话两条下载路径算出来的指纹永远对不上。
36
+ */
35
37
 
36
38
  /**
37
39
  * Gitee 源下拉取的文件范围:只拉会被更新覆盖的代码条目。
@@ -100,9 +102,58 @@ async function updatedButNotRestarted() {
100
102
  * 包一层而不是往 checkUpdate 里塞:里面有好几个返回分支(Gitee /
101
103
  * 主站 302 / API 兜底 / 404 / 失败),每个都手写一遍 needsRestart 迟早漏一个。
102
104
  */
105
+ /**
106
+ * 拉 SHA256SUMS,得到「版本 → 内容指纹」的映射。
107
+ *
108
+ * 两个源都试:GitHub raw(经下载源改写)和 Gitee raw。任何一边拿到都行,
109
+ * 因为指纹是「代码内容」的,与从哪个源下载无关。
110
+ *
111
+ * @returns {Promise<Map<string,string>>} 版本号 → 64 位十六进制
112
+ */
113
+ async function fetchSumsMap() {
114
+ const urls = [
115
+ `https://raw.githubusercontent.com/${REPO}/master/SHA256SUMS`,
116
+ `https://gitee.com/${REPO}/raw/master/SHA256SUMS`,
117
+ ];
118
+ for (const u of urls) {
119
+ try {
120
+ const res = await fetchViaMirror(u, {
121
+ headers: { 'User-Agent': 'qq-bot-roost-updater' },
122
+ signal: AbortSignal.timeout(8000),
123
+ });
124
+ if (!res.ok) continue;
125
+ const text = await res.text();
126
+ const map = new Map();
127
+ for (const line of text.split(/\r?\n/)) {
128
+ const m = /^([a-f0-9]{64})\s+v?([0-9][\w.+-]*)$/i.exec(line.trim());
129
+ if (m) map.set(m[2].replace(/^v/i, ''), m[1].toLowerCase());
130
+ }
131
+ if (map.size) return map;
132
+ } catch {
133
+ // 换下一个源
134
+ }
135
+ }
136
+ return new Map();
137
+ }
138
+
139
+ /**
140
+ * 查某个版本的内容指纹。拿不到返回 null —— 真正的拒绝发生在 applyUpdate,
141
+ * 因为「只检查不安装」时面板仍应正常显示有新版本,只是点更新会失败。
142
+ */
143
+ async function fetchTreeSha256(version) {
144
+ try {
145
+ const map = await fetchSumsMap();
146
+ return map.get(String(version).replace(/^v/, '')) ?? null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
103
152
  export async function checkUpdate() {
104
153
  const needsRestart = await updatedButNotRestarted();
105
154
  const r = await checkUpdateCore();
155
+ // 有新版本才值得多跑一次网络请求
156
+ if (r?.ok && r.hasUpdate) r.treeSha256 = await fetchTreeSha256(r.latest);
106
157
  return { ...r, needsRestart };
107
158
  }
108
159
 
@@ -442,6 +493,37 @@ async function applyUpdateOnce(info) {
442
493
  throw new Error('下载的包里没有 src/main.js,不是合法的框架包,已中止');
443
494
  });
444
495
 
496
+ /**
497
+ * 内容指纹校验 —— **必须放在备份和替换之前**。
498
+ *
499
+ * 这一步失败就等于什么都没发生过:文件没动、备份也没建。
500
+ *
501
+ * 为什么必须严格:更新会直接覆盖 `src/`、`web/` 这些要执行的代码。
502
+ * 只要能改掉下载地址(自定义镜像、上游仓库被入侵、链路劫持),
503
+ * 就能决定用户下一版跑什么。指纹对不上就说明「下载到的东西不是作者发的」。
504
+ *
505
+ * 为什么没有指纹也要拒绝:留一个「没指纹就放行」的口子,攻击者只要把
506
+ * SHA256SUMS 里那条删掉或让仓库回到没有该文件的形态就能绕过。
507
+ * 代价是发布流程必须每版都带指纹 —— 这由 CI 的 `npm run sums:check` 守着。
508
+ */
509
+ if (!rel.treeSha256) {
510
+ throw new Error(
511
+ `v${rel.latest} 没有发布内容指纹(SHA256SUMS 里查不到),已拒绝自动更新。\n` +
512
+ '这不是你的网络问题:作者这一版可能漏了生成指纹。\n' +
513
+ '可以手动到 GitHub Releases 下载安装,或等下一版。你的代码没有任何改动。',
514
+ );
515
+ }
516
+ const gotHash = await hashDir(workDir, { only: CODE_ENTRIES });
517
+ if (gotHash !== rel.treeSha256) {
518
+ throw new Error(
519
+ `更新内容校验失败,已中止(你的代码没有任何改动)。\n` +
520
+ ` 期望 ${rel.treeSha256}\n` +
521
+ ` 实际 ${gotHash}\n` +
522
+ '可能是下载源被篡改,或者发布时改了代码却忘了重跑 npm run sums。',
523
+ );
524
+ }
525
+ log.info(`内容指纹校验通过(${gotHash.slice(0, 12)}…)`);
526
+
445
527
  // 备份现有代码
446
528
  await fs.mkdir(backupDir, { recursive: true });
447
529
  for (const name of CODE_ENTRIES) {
package/src/web/server.js CHANGED
@@ -43,6 +43,19 @@ 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
+
46
59
  /**
47
60
  * 主面板的 CSP。
48
61
  *
@@ -433,6 +446,11 @@ export async function startWebServer({
433
446
  : people.find((p) => p.openid === c.id)?.name ?? '',
434
447
  }));
435
448
  }
449
+ /**
450
+ * 进程实例标识:前端一看到它变了就知道服务重启过,
451
+ * 页面令牌和会话都已作废,需要立刻重载插件页而不是等下轮轮询。
452
+ */
453
+ snap.instanceId = `${process.pid}-${PROCESS_STARTED_AT}`;
436
454
  return snap;
437
455
  };
438
456
 
@@ -884,11 +902,25 @@ export async function startWebServer({
884
902
  */
885
903
  if (req.method === 'OPTIONS') {
886
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'] || '');
887
920
  res.writeHead(204, {
888
921
  ...(origin === 'null' ? { 'Access-Control-Allow-Origin': 'null', Vary: 'Origin' } : {}),
889
922
  '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',
923
+ 'Access-Control-Allow-Headers': wantHeaders || 'Content-Type, X-Access-Key, X-Token',
892
924
  });
893
925
  return res.end();
894
926
  }
@@ -910,9 +942,41 @@ export async function startWebServer({
910
942
  * 完全不受保护——跨站页面一个 text/plain 表单就能把它们打出去。
911
943
  */
912
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
+ );
913
955
  return json(res, 415, { error: '写操作需要 Content-Type: application/json' });
914
956
  }
915
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
+ );
916
980
  return json(res, 403, { error: '跨站请求被拒绝' });
917
981
  }
918
982
 
@@ -1146,7 +1210,36 @@ export async function startWebServer({
1146
1210
  if (typeof body.appSecret === 'string' && body.appSecret && body.appSecret !== '********') {
1147
1211
  cfg.appSecret = body.appSecret.trim();
1148
1212
  }
1149
- 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
+ }
1150
1243
  if (body.intents !== undefined) {
1151
1244
  const n = Number(body.intents);
1152
1245
  if (!Number.isInteger(n) || n < 0 || n > 0x7fffffff) {
@@ -1269,7 +1362,12 @@ export async function startWebServer({
1269
1362
  * 面板据此显示「统计不可用」而不是画一堆 0。
1270
1363
  */
1271
1364
  if (pathname === '/api/stats' && req.method === 'GET') {
1272
- 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));
1273
1371
  try {
1274
1372
  return json(res, 200, rt.stats?.overview(days) ?? { available: false });
1275
1373
  } catch (e) {
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
 
package/web/app.js CHANGED
@@ -725,9 +725,25 @@ function renderStatus(s) {
725
725
  }
726
726
  }
727
727
 
728
+ /** 上一次看到的进程实例标识,用来判断服务是不是重启过 */
729
+ let lastInstanceId = '';
730
+
728
731
  async function pollStatus() {
729
732
  try {
730
- renderStatus(await api('/api/status'));
733
+ const d = await api('/api/status');
734
+ /**
735
+ * 进程重启 ⇒ 页面令牌全部重新生成、会话也全失效。
736
+ * 已打开的插件页 iframe 手里还是旧令牌,接下来每个请求都会被判成跨站(403),
737
+ * 用户看到的就是「切过去先红一下,等一会儿才好」。
738
+ *
739
+ * 这里一发现实例换了就立刻刷新令牌并重载 iframe,不必等兜底轮询。
740
+ */
741
+ const iid = String(d?.instanceId || '');
742
+ if (lastInstanceId && iid && iid !== lastInstanceId) {
743
+ refreshPageTokens();
744
+ }
745
+ lastInstanceId = iid;
746
+ renderStatus(d);
731
747
  } catch (e) {
732
748
  $('#connText').textContent = '面板断开';
733
749
  $('#connPill').className = 'status-pill offline';
@@ -1351,6 +1367,17 @@ async function loadPluginPages() {
1351
1367
  frame.dataset.key = p.key;
1352
1368
  // 页面令牌:不是面板密钥,只对这一个插件自己的接口有效
1353
1369
  frame.dataset.token = p.token || '';
1370
+ /**
1371
+ * **直接设上地址(预加载),不再等用户点开。**
1372
+ *
1373
+ * 以前这里是懒加载:注释写「地址在点开的时候才算」,好处是不点就不请求。
1374
+ * 但代价是第一次点插件标签要走完「下载 35KB HTML → 解析几百行内联脚本 →
1375
+ * 拉数据 → 渲染」,主观上就是卡一下,用户容易以为坏了。
1376
+ *
1377
+ * 现在建标签时就开始加载,点过去就是现成的。切换机器人时这个函数会重跑,
1378
+ * 地址里带上了新的 bot 参数,所以也不会串台。
1379
+ */
1380
+ frame.src = pluginPageUrl(p.key, p.token || '');
1354
1381
  sec.appendChild(frame);
1355
1382
  main.appendChild(sec);
1356
1383
  }
@@ -1385,6 +1412,64 @@ function pluginPageUrl(key, token) {
1385
1412
  return `/api/pages/${encodeURIComponent(key)}/html${s ? `?${s}` : ''}`;
1386
1413
  }
1387
1414
 
1415
+ /**
1416
+ * 跟着进程重启自动换令牌。
1417
+ *
1418
+ * 页面令牌只存在服务端内存里,进程重启就全部重新生成。而面板页如果是在重启
1419
+ * **之前**打开的,iframe 里还挂着旧令牌,插件页的所有请求都会被判成跨站
1420
+ * (403),表现为「数据空白」或者直接 `Failed to fetch` —— 用户完全看不出
1421
+ * 是重启导致的,只会觉得面板坏了。
1422
+ *
1423
+ * 这里定期问一次页面列表:令牌变了就更新 iframe 的地址并重载它。
1424
+ * 只在令牌真的变化时才重载(也就是重启过),平时不会打断用户操作。
1425
+ */
1426
+ async function refreshPageTokens() {
1427
+ let list = null;
1428
+ try {
1429
+ list = await api('/api/pages');
1430
+ } catch {
1431
+ return 0;
1432
+ }
1433
+ if (!Array.isArray(list)) return 0;
1434
+
1435
+ let reloaded = 0;
1436
+ for (const p of list) {
1437
+ for (const f of $$('.plugin-page')) {
1438
+ if (f.dataset.key !== p.key) continue;
1439
+ if (f.dataset.token === (p.token || '')) continue;
1440
+ f.dataset.token = p.token || '';
1441
+ // 只重载已经打开过的(没加载过的不去碰,免得白跑一次请求)
1442
+ if (f.getAttribute('src')) {
1443
+ f.src = pluginPageUrl(f.dataset.key, f.dataset.token);
1444
+ reloaded += 1;
1445
+ }
1446
+ }
1447
+ }
1448
+ return reloaded;
1449
+ }
1450
+
1451
+ // 15 秒足够快,又不会给面板添负担;令牌不变时这里什么都不做
1452
+ setInterval(() => {
1453
+ refreshPageTokens();
1454
+ }, 15000);
1455
+
1456
+ /**
1457
+ * 切回面板时立刻补一次。
1458
+ *
1459
+ * 两个真实场景:
1460
+ * 1. 浏览器会把后台标签页的定时器降频(Chrome 最长到 1 分钟一次),
1461
+ * 所以「去改个配置再切回来」最容易撞上旧令牌
1462
+ * 2. 保存配置会触发 config-watch 热加载,插件被卸载重载 → 页面令牌
1463
+ * 跟着重新生成 → 已打开的 iframe 立刻变无效
1464
+ * 回来这一下立刻修,比等定时器体感好得多。
1465
+ */
1466
+ window.addEventListener('focus', () => {
1467
+ refreshPageTokens();
1468
+ });
1469
+ document.addEventListener('visibilitychange', () => {
1470
+ if (!document.hidden) refreshPageTokens();
1471
+ });
1472
+
1388
1473
  /* ---------------- 统计 ---------------- */
1389
1474
 
1390
1475
  /** 每天一根柱子,纯 CSS 不用图表库——多引一个库就破了零依赖 */