qq-bot-roost 0.9.7 → 0.9.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/src/tarball.js CHANGED
@@ -34,18 +34,32 @@ export function parseTar(buf) {
34
34
  let off = 0;
35
35
 
36
36
  while (off + 512 <= buf.length) {
37
+ /** 本轮起点:用来判断「有没有真的往前走」,见下面的 next 检查 */
38
+ const start = off;
37
39
  const header = buf.subarray(off, off + 512);
38
40
  // 全零块 = 结束
39
41
  if (header.every((b) => b === 0)) break;
40
42
 
41
- const readStr = (start, len) => {
42
- const slice = header.subarray(start, start + len);
43
+ /**
44
+ * 参数叫 `pos` 而不是 `start`:外层那个 `start` 是「本轮在 buf 里的起点」,
45
+ * 两者含义完全不同,同名遮蔽会让人以为能互相引用。
46
+ */
47
+ const readStr = (pos, len) => {
48
+ const slice = header.subarray(pos, pos + len);
43
49
  const end = slice.indexOf(0);
44
50
  return slice.subarray(0, end === -1 ? len : end).toString('utf8');
45
51
  };
46
52
 
47
53
  const name = readStr(0, 100);
48
- const size = parseInt(readStr(124, 12).trim(), 8) || 0;
54
+ /**
55
+ * size 必须夹到非负。
56
+ *
57
+ * 八进制字段里出现 `-` 时 `parseInt` 会给出负数,后面
58
+ * `Math.ceil(size / 512) * 512` 就是负的,游标「前进 512 又倒退 512」
59
+ * 原地打转——而这个循环每轮都 push 一条条目,于是**内存直接爆掉**。
60
+ * 一个几十字节的畸形 tar 就能把机器人打死(2026-10-31 用 fuzz 撞出来的)。
61
+ */
62
+ const size = Math.max(0, parseInt(readStr(124, 12).trim(), 8) || 0);
49
63
  const type = String.fromCharCode(header[156]) || '0';
50
64
  // ustar 前缀,长路径会用它
51
65
  const prefix = readStr(345, 155);
@@ -53,7 +67,21 @@ export function parseTar(buf) {
53
67
 
54
68
  off += 512;
55
69
  const content = buf.subarray(off, off + size);
56
- off += Math.ceil(size / 512) * 512;
70
+ /**
71
+ * 游标必须严格前进。即使 size 已经夹到非负,这里再兜一道:
72
+ * 畸形头导致的任何「原地踏步」都会变成死循环 + 内存爆炸,
73
+ * 而正常的 tar 每轮至少前进 512 字节,不可能触发。
74
+ */
75
+ const next = off + Math.ceil(size / 512) * 512;
76
+ /**
77
+ * 和**本轮起点**比,不是和 `off` 比。
78
+ *
79
+ * `size = 0`(目录条目)时 `next` 正好等于 `off`,拿 `off` 比会把
80
+ * 正常的空条目误判成「没前进」而提前结束——第一版就是这么写的,
81
+ * 结果一个含目录的正常 tar 只解析出第一条。
82
+ */
83
+ if (next <= start) break;
84
+ off = next;
57
85
 
58
86
  if (type === '5') entries.push({ name: fullName, dir: true });
59
87
  else if (type === '0' || type === '\0') entries.push({ name: fullName, dir: false, content });
package/src/trace.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 链路追踪 id
3
+ * ===========
4
+ *
5
+ * 一条消息要穿过四段:QQ 事件 → Roost 内核 → ai-chat 插件 → dsh-roost → 模型。
6
+ * 出问题时现在只能翻四个日志文件对时间戳——用户说「刚才那条没回」,
7
+ * 根本不知道该看哪一段。
8
+ *
9
+ * 这里生成一个短 id,进站时打上,每段日志都带着它,面板也能按它串起来。
10
+ *
11
+ * 为什么不是 UUID:要能在日志里一眼扫到、能手打、能贴进群里问。
12
+ * 时间前缀(36 进制)让同一秒内的 id 也有序,后面 6 位十六进制够区分。
13
+ * 碰撞概率:同一毫秒内 1/1677 万,对一个群聊机器人足够。
14
+ */
15
+ import crypto from 'node:crypto';
16
+
17
+ /**
18
+ * @returns {string} 形如 `m3k9x2a1f3c4`
19
+ */
20
+ export function newTraceId() {
21
+ return `${Date.now().toString(36)}${crypto.randomBytes(3).toString('hex')}`;
22
+ }
23
+
24
+ /** 日志前缀:统一成 `[trace:xxxx]`,方便 grep */
25
+ export function traceTag(id) {
26
+ const t = String(id ?? '').trim();
27
+ return t ? `[trace:${t}]` : '';
28
+ }
package/src/trust.js ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * 供应链信任:签名与验签
3
+ * ======================
4
+ *
5
+ * 为什么需要它:`index.json` 里的 `sha256` 和索引本身同仓同源,能改索引的人
6
+ * 顺手就能把哈希一起改了,校验照过。哈希只证明「内容和这份清单一致」,
7
+ * 不证明「这份清单是作者发的」——后者需要**独立于业务仓的密钥**。
8
+ *
9
+ * 所以这里只做一件事:**用一把离线私钥给清单签名,用内置公钥验签**。
10
+ * 哈希仍然保留(它挡的是传输篡改和内容漂移),两者职责不同。
11
+ *
12
+ * 设计取舍:
13
+ * · 签的是**规范化后的 JSON**,不是原始字节。镜像站可能重新序列化
14
+ * (缩进、键序、末尾换行),签字节会被这些无关差异搞挂。
15
+ * · 规范化规则必须稳定且可复现:键排序、无空白、数组保序。
16
+ * · 信封格式 `{alg, keyId, sig}`,也接受裸 base64,方便手工签。
17
+ * · **没有公钥 = unavailable,不是通过也不是拒绝**。调用方必须显式决定
18
+ * 怎么处理,不能让「没配公钥」悄悄变成放行。
19
+ */
20
+
21
+ import crypto from 'node:crypto';
22
+
23
+ /** 发版时填进来的内置公钥(PEM 或 32 字节 base64)。留空 = 本版不启用验签。 */
24
+ export const EMBEDDED_PUBKEY = 'bqmZDZi/gnW7FppqKCOa5Zvc1XUvT/vw7fiiHR2hcIo=';
25
+
26
+ /**
27
+ * 内置强制开关。
28
+ *
29
+ * **发版时把它改成 `true`,所有没显式配置的用户就默认开启强制验签**——
30
+ * 这就是「后续一个更新就能马上打开」的那个开关。
31
+ *
32
+ * 为什么不在这次直接开:签名文件还没铺到索引里,现在开会让所有安装失败。
33
+ * 顺序必须是「先发签名 → 再开开关」。
34
+ *
35
+ * 优先级(见 `resolveTrust`):
36
+ * 配置 `security.trust.enforce` > 环境变量 `ROOST_TRUST_ENFORCE` > 本常量
37
+ */
38
+ export const EMBEDDED_ENFORCE = false;
39
+
40
+ /**
41
+ * **发版时保持 false。** 签名链路是 beta:
42
+ * · `index.json.sig` 还没跟着一次正式发版走完流程
43
+ * · 现在打开会让没签名的插件装不上
44
+ * 等发版验证过一轮,再决定是否默认开启(或继续让用户自己在面板里开)。
45
+ */
46
+
47
+ /**
48
+ * 规范化:键排序 + 无空白 + 数组保序。
49
+ *
50
+ * 不能直接用 `JSON.stringify(data)`——它按插入顺序输出键,
51
+ * 同一个对象经过一次 parse/stringify 往返就可能换序。
52
+ *
53
+ * @param {unknown} v
54
+ * @returns {string}
55
+ */
56
+ export function canonicalize(v) {
57
+ if (v === undefined) throw new Error('canonicalize 不接受 undefined');
58
+ if (v === null || typeof v !== 'object') return JSON.stringify(v);
59
+ if (Array.isArray(v)) return `[${v.map((x) => canonicalize(x)).join(',')}]`;
60
+ const keys = Object.keys(v).sort();
61
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(v[k])}`).join(',')}}`;
62
+ }
63
+
64
+ /**
65
+ * 文本规范化:CRLF → LF,去掉末尾空白。
66
+ *
67
+ * 为什么需要:`SHA256SUMS` 这类纯文本文件会被 git 的 `eol` 属性和编辑器改写
68
+ * 换行符。签原始字节的话,Windows 检出一次签名就失效了。
69
+ *
70
+ * 注意:JSON 不走这里,走 `canonicalize`。
71
+ *
72
+ * @param {string} s
73
+ */
74
+ export function normalizeText(s) {
75
+ return String(s ?? '').replace(/\r\n/g, '\n').replace(/\s+$/, '');
76
+ }
77
+
78
+ /**
79
+ * 把 PEM / 裸 base64 / JWK 统一成 KeyObject。
80
+ *
81
+ * @param {string} text
82
+ * @returns {import('node:crypto').KeyObject|null}
83
+ */
84
+ export function publicKeyFrom(text) {
85
+ const t = String(text ?? '').trim();
86
+ if (!t) return null;
87
+ try {
88
+ if (t.includes('BEGIN PUBLIC KEY')) return crypto.createPublicKey(t);
89
+ const raw = Buffer.from(t, 'base64');
90
+ if (raw.length !== 32) return null;
91
+ return crypto.createPublicKey({
92
+ key: { kty: 'OKP', crv: 'Ed25519', x: raw.toString('base64url') },
93
+ format: 'jwk',
94
+ });
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * 签名。Ed25519 的算法参数是 `null`(不是 'sha256')。
102
+ *
103
+ * @param {string} text 已规范化的内容
104
+ * @param {string} privateKeyPem
105
+ * @returns {string} base64 签名
106
+ */
107
+ export function signText(text, privateKeyPem) {
108
+ const key = crypto.createPrivateKey(privateKeyPem);
109
+ return crypto.sign(null, Buffer.from(text, 'utf8'), key).toString('base64');
110
+ }
111
+
112
+ /**
113
+ * 验签。任何异常都返回 false —— 验签失败的原因不该抛给上层,
114
+ * 上层只需要「过 / 不过」和一句能看懂的话。
115
+ *
116
+ * @param {string} text
117
+ * @param {string} sigB64
118
+ * @param {string} publicKeyPem
119
+ * @returns {boolean}
120
+ */
121
+ export function verifyText(text, sigB64, publicKeyPem) {
122
+ try {
123
+ const key = typeof publicKeyPem === 'string' ? publicKeyFrom(publicKeyPem) : publicKeyPem;
124
+ if (!key) return false;
125
+ return crypto.verify(null, Buffer.from(text, 'utf8'), key, Buffer.from(String(sigB64), 'base64'));
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * 解析签名信封。接受 `{alg,keyId,sig}` 或裸 base64。
133
+ *
134
+ * @param {string} raw
135
+ * @returns {{alg:string, keyId:string, sig:string}|null}
136
+ */
137
+ export function parseSig(raw) {
138
+ const t = String(raw ?? '').trim();
139
+ if (!t) return null;
140
+ if (t.startsWith('{')) {
141
+ try {
142
+ const o = JSON.parse(t);
143
+ if (!o || typeof o.sig !== 'string' || !o.sig) return null;
144
+ return { alg: String(o.alg ?? ''), keyId: String(o.keyId ?? ''), sig: o.sig };
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+ return { alg: 'ed25519', keyId: '', sig: t };
150
+ }
151
+
152
+ /**
153
+ * 从配置解析信任策略。
154
+ *
155
+ * `security.trust = { pubkey, enforce, allowUnsigned: [] }`
156
+ * 也接受直接传 trust 对象(测试用)。
157
+ *
158
+ * @param {object} cfg
159
+ */
160
+ export function resolveTrust(cfg = {}) {
161
+ const t = cfg?.security?.trust ?? cfg ?? {};
162
+
163
+ /**
164
+ * 优先级:配置(面板开关)> 环境变量 > 内置常量。
165
+ *
166
+ * **配置必须最后生效**:面板上的开关是用户能看见的唯一入口,
167
+ * 如果环境变量能压过它,就会出现「我明明关了它还拦」这种查不出来的怪事。
168
+ * 环境变量只作为没写配置时的兜底(排查、临时灰度)。
169
+ */
170
+ let enforce = EMBEDDED_ENFORCE;
171
+ const env = String(process.env.ROOST_TRUST_ENFORCE ?? '').trim().toLowerCase();
172
+ if (env === '1' || env === 'true') enforce = true;
173
+ else if (env === '0' || env === 'false') enforce = false;
174
+ if (t.enforce !== undefined) enforce = t.enforce === true;
175
+
176
+ return {
177
+ pubkey: String(t.pubkey ?? '') || EMBEDDED_PUBKEY,
178
+ enforce,
179
+ allowUnsigned: Array.isArray(t.allowUnsigned) ? t.allowUnsigned.map(String) : [],
180
+ /** 额外声明为「官方源」的地址(自建代理 / 镜像用),默认只认 market.js 里的官方地址 */
181
+ officialUrls: Array.isArray(t.officialUrls) ? t.officialUrls.map(String) : [],
182
+ };
183
+ }
184
+
185
+ /**
186
+ * 公钥能不能用。
187
+ *
188
+ * 面板要拿它区分「没配」和「配错了」——这两种都让开关不起作用,
189
+ * 但用户的修法完全不同:一个是去配,一个是去改。
190
+ *
191
+ * @param {string} pubkey
192
+ */
193
+ export function pubkeyUsable(pubkey) {
194
+ return Boolean(publicKeyFrom(pubkey));
195
+ }
196
+
197
+ /**
198
+ * 校验一份清单。
199
+ *
200
+ * 返回的 `status` 有五种,调用方必须显式处理每一种:
201
+ * ok 签名有效
202
+ * unsigned 有公钥、没签名文件
203
+ * bad 签名存在但不匹配(内容被改过 / 用错密钥)
204
+ * invalid 配了公钥但解析不了(**不是「没配」**,强制时必须拦)
205
+ * unavailable 没配公钥,本版无法验签
206
+ *
207
+ * @param {{data:unknown, sigText?:string, trust:object, kind?:string}} o
208
+ */
209
+ export function checkTrust({ data, sigText, trust, kind = '清单' }) {
210
+ if (!trust?.pubkey) return { status: 'unavailable', why: `没有配置信任公钥,${kind}未验签` };
211
+ /**
212
+ * 配了但解析不了 → `invalid`,不能并进 `unavailable`。
213
+ *
214
+ * 并进去的后果很严重:用户把公钥粘错一个字符、打开强制开关,然后
215
+ * **一切照常放行**——他以为受保护了,实际什么都没验。这类「安全开关
216
+ * 看起来开着、其实没生效」比没有开关更危险,所以必须单独一个状态。
217
+ */
218
+ if (!publicKeyFrom(trust.pubkey)) {
219
+ return { status: 'invalid', why: '信任公钥无法解析(应为 PEM 或 32 字节 base64)' };
220
+ }
221
+
222
+ const env = parseSig(sigText);
223
+ if (!env) return { status: 'unsigned', why: `${kind}没有签名文件` };
224
+ if (env.alg && env.alg !== 'ed25519') return { status: 'bad', why: `不支持的签名算法:${env.alg}` };
225
+
226
+ const ok = verifyText(canonicalize(data), env.sig, trust.pubkey);
227
+ if (!ok) return { status: 'bad', why: `${kind}的签名与内容不匹配` };
228
+ return { status: 'ok', keyId: env.keyId };
229
+ }
230
+
231
+ /**
232
+ * 该不该拦。
233
+ *
234
+ * 返回 `null` = 放行;返回字符串 = 拦截理由。
235
+ *
236
+ * **只对官方源强制**。理由:手写插件不走市场索引(`loader.js` 只扫 `plugins/`
237
+ * 目录,完全不碰验签),所以「用户自己写插件」这条路从来不受影响;
238
+ * 而用户自己填的 `plugins.marketUrl` 属于他自己的信任决定——他信那个仓库,
239
+ * 我们没资格替他拦。签名要防的是「官方渠道被悄悄换掉」,不是「用户自己的源」。
240
+ *
241
+ * `unavailable` **永远放行**:没配公钥就拦,等于让所有没升级配置的用户
242
+ * 一次性断掉,那不是安全,是事故。
243
+ *
244
+ * @param {object} trust
245
+ * @param {string} status
246
+ * @param {string} [id]
247
+ * @param {{official?: boolean}} [opts]
248
+ * @returns {string|null}
249
+ */
250
+ export function blockReason(trust, status, id = '', opts = {}) {
251
+ if (!trust?.enforce) return null;
252
+ if (opts.official === false) return null;
253
+ if (status === 'ok' || status === 'unavailable') return null;
254
+ /**
255
+ * 配错公钥必须拦:否则「开关开着、实际没验」,用户以为受保护了。
256
+ * 这条不能算进 `unavailable`——那个是「没配」,放行是有意的。
257
+ */
258
+ if (status === 'invalid') {
259
+ return '信任公钥无法解析,强制校验无法生效(检查 security.trust.pubkey,或等内置公钥的版本)';
260
+ }
261
+ if (status === 'unsigned' && trust.allowUnsigned.includes(String(id))) return null;
262
+ if (status === 'bad') return '签名校验失败:内容与签名不一致(可能被篡改,或用错了密钥)';
263
+ return `没有签名,且不在 allowUnsigned 白名单里(${id || '该项'})`;
264
+ }
package/src/updater.js CHANGED
@@ -23,6 +23,8 @@ import { fetchGiteeTree, detectGiteeRef, giteeRawUrl } from './gitee.js';
23
23
  import { hashDir } from './hashdir.js';
24
24
  import { CODE_ENTRIES, PROTECTED_ENTRIES as PROTECTED } from './update-scope.js';
25
25
  import { ROOT } from './config.js';
26
+ import { loadGlobalConfig } from './config.js';
27
+ import { checkTrust, resolveTrust, normalizeText, blockReason } from './trust.js';
26
28
 
27
29
  const log = createLogger('update');
28
30
 
@@ -108,9 +110,9 @@ async function updatedButNotRestarted() {
108
110
  * 两个源都试:GitHub raw(经下载源改写)和 Gitee raw。任何一边拿到都行,
109
111
  * 因为指纹是「代码内容」的,与从哪个源下载无关。
110
112
  *
111
- * @returns {Promise<Map<string,string>>} 版本号 → 64 位十六进制
113
+ * @returns {Promise<{map: Map<string,string>, trust: object}>} 版本号 → 64 位十六进制,以及验签结论
112
114
  */
113
- async function fetchSumsMap() {
115
+ async function fetchSums() {
114
116
  const urls = [
115
117
  `https://raw.githubusercontent.com/${REPO}/master/SHA256SUMS`,
116
118
  `https://gitee.com/${REPO}/raw/master/SHA256SUMS`,
@@ -128,12 +130,40 @@ async function fetchSumsMap() {
128
130
  const m = /^([a-f0-9]{64})\s+v?([0-9][\w.+-]*)$/i.exec(line.trim());
129
131
  if (m) map.set(m[2].replace(/^v/i, ''), m[1].toLowerCase());
130
132
  }
131
- if (map.size) return map;
133
+ if (!map.size) continue;
134
+
135
+ /**
136
+ * 验签。指纹和代码同仓,只验指纹等于「让被篡改的清单自己证明自己」——
137
+ * 必须由独立密钥签过的 `SHA256SUMS.sig` 背书。
138
+ *
139
+ * 本版只记录结论、不阻断(`enforce` 还没接进来);`bad` 要打 error,
140
+ * 因为它意味着「清单内容和签名对不上」,比「没签名」严重得多。
141
+ */
142
+ let sigText = '';
143
+ try {
144
+ const sres = await fetchViaMirror(`${u}.sig`, {
145
+ headers: { 'User-Agent': 'qq-bot-roost-updater' },
146
+ signal: AbortSignal.timeout(8000),
147
+ });
148
+ if (sres.ok) sigText = await sres.text();
149
+ } catch {
150
+ // 拿不到签名文件不是网络错误,继续按「未签名」处理
151
+ }
152
+ const trust = checkTrust({
153
+ data: normalizeText(text),
154
+ sigText,
155
+ trust: resolveTrust({}),
156
+ kind: 'SHA256SUMS',
157
+ });
158
+ if (trust.status === 'bad' || trust.status === 'invalid') log.error(trust.why);
159
+ else if (trust.status === 'ok') log.info('SHA256SUMS 签名校验通过');
160
+
161
+ return { map, trust };
132
162
  } catch {
133
163
  // 换下一个源
134
164
  }
135
165
  }
136
- return new Map();
166
+ return { map: new Map(), trust: { status: 'unavailable', why: '没有拿到 SHA256SUMS' } };
137
167
  }
138
168
 
139
169
  /**
@@ -142,10 +172,10 @@ async function fetchSumsMap() {
142
172
  */
143
173
  async function fetchTreeSha256(version) {
144
174
  try {
145
- const map = await fetchSumsMap();
146
- return map.get(String(version).replace(/^v/, '')) ?? null;
175
+ const { map, trust } = await fetchSums();
176
+ return { sha: map.get(String(version).replace(/^v/, '')) ?? null, trust };
147
177
  } catch {
148
- return null;
178
+ return { sha: null, trust: { status: 'unavailable', why: '查询指纹失败' } };
149
179
  }
150
180
  }
151
181
 
@@ -153,7 +183,11 @@ export async function checkUpdate() {
153
183
  const needsRestart = await updatedButNotRestarted();
154
184
  const r = await checkUpdateCore();
155
185
  // 有新版本才值得多跑一次网络请求
156
- if (r?.ok && r.hasUpdate) r.treeSha256 = await fetchTreeSha256(r.latest);
186
+ if (r?.ok && r.hasUpdate) {
187
+ const info = await fetchTreeSha256(r.latest);
188
+ r.treeSha256 = info.sha;
189
+ r.sumsTrust = info.trust;
190
+ }
157
191
  return { ...r, needsRestart };
158
192
  }
159
193
 
@@ -477,6 +511,22 @@ async function applyUpdateOnce(info) {
477
511
  if (!rel.hasUpdate) throw new Error(`已经是最新版本 ${cur}`);
478
512
  if (!rel.tarball) throw new Error('没有可下载的包');
479
513
 
514
+ /**
515
+ * 签名闸门:和插件市场共用面板上那个开关。
516
+ *
517
+ * 为什么放在下载之前:被拒的时候用户什么都没损失,不用先下一份再删。
518
+ * `rel.sumsTrust` 是 `checkUpdate()` 拉 `SHA256SUMS` 时顺手算出来的,
519
+ * 没拿到就当 `unavailable`(没配公钥 / 网络问题),按「不拦」处理。
520
+ */
521
+ const trustCfg = resolveTrust(await loadGlobalConfig().catch(() => ({})));
522
+ const trustWhy = blockReason(trustCfg, rel.sumsTrust?.status || 'unavailable', '', { official: true });
523
+ if (trustWhy) {
524
+ throw new Error(
525
+ `自更新被拒绝:${trustWhy}\n` +
526
+ '你的代码没有任何改动。可以到 GitHub Releases 手动下载安装,或先在设置里关掉「插件来源校验」。',
527
+ );
528
+ }
529
+
480
530
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
481
531
  const workDir = path.join(ROOT, 'data', `.update-${stamp}`);
482
532
  const backupDir = path.join(ROOT, 'data', 'backups', `${cur}-${stamp}`);
package/src/web/server.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  mergeWithInstalled,
17
17
  detectUpdates,
18
18
  compareVersion,
19
+ isOfficialSource,
19
20
  } from '../plugin/market.js';
20
21
  import {
21
22
  checkUpdate as checkFrameworkUpdate,
@@ -32,6 +33,7 @@ import { onMessage as onBusMessage } from '../message-bus.js';
32
33
  import { handleQqCall, capabilityPayload } from './qq-call.js';
33
34
  import { runDoctor, summarize } from '../doctor.js';
34
35
  import { createBindTask, pollBindResult } from '../qq/qrlogin.js';
36
+ import { resolveTrust, blockReason, pubkeyUsable } from '../trust.js';
35
37
  import { toSvg as qrToSvg } from '../qq/qrsvg.js';
36
38
  import {
37
39
  PRESETS,
@@ -851,7 +853,7 @@ export async function startWebServer({
851
853
 
852
854
  const out = { at: Date.now(), updates: [], error: '' };
853
855
  try {
854
- const idx = await fetchIndex(globalCfg.plugins?.marketUrl || undefined);
856
+ const idx = await fetchIndex(globalCfg.plugins?.marketUrl || undefined, { trust: resolveTrust(globalCfg) });
855
857
  const localVers = await r.plugins.localVersions();
856
858
  out.updates = detectUpdates(idx.plugins, localVers).map((u) => ({
857
859
  id: u.id,
@@ -1336,6 +1338,7 @@ export async function startWebServer({
1336
1338
 
1337
1339
  // 配置读取(隐藏密钥)
1338
1340
  if (pathname === '/api/config' && req.method === 'GET') {
1341
+ const trust = resolveTrust(globalCfg);
1339
1342
  return json(res, 200, {
1340
1343
  ...cfg,
1341
1344
  // webui 和 logLevel 是**全局**设置(config/bot.json),所有机器人共用,
@@ -1348,6 +1351,19 @@ export async function startWebServer({
1348
1351
  accessKeySet: Boolean(globalCfg.webui?.accessKey),
1349
1352
  },
1350
1353
  logLevel: globalCfg.logLevel ?? 'info',
1354
+ /**
1355
+ * 插件来源校验(全局设置)。只回「开没开」和「有没有公钥」——
1356
+ * 公钥本身是公开的,但配置里可能是整段 PEM,回显没意义还占地方。
1357
+ * `pubkeySet: false` 时面板要提示「开关不起作用」。
1358
+ */
1359
+ security: {
1360
+ trust: {
1361
+ enforce: trust.enforce,
1362
+ pubkeySet: Boolean(trust.pubkey),
1363
+ // 「配了但解析不了」和「没配」要分开:前者开开关等于没开
1364
+ pubkeyValid: pubkeyUsable(trust.pubkey),
1365
+ },
1366
+ },
1351
1367
  appSecret: cfg.appSecret ? '********' : '',
1352
1368
  appSecretSet: Boolean(cfg.appSecret),
1353
1369
  });
@@ -1409,6 +1425,21 @@ export async function startWebServer({
1409
1425
  await saveGlobalConfig?.(globalCfg).catch(() => {});
1410
1426
  configureMirror(globalCfg);
1411
1427
  }
1428
+ /**
1429
+ * 插件来源校验开关(全局,默认关)。
1430
+ *
1431
+ * 只接受布尔值:面板传字符串 "false" 时如果按真值判断,会把「关」
1432
+ * 读成「开」——这类错误在安全开关上代价最大,所以这里严格比类型。
1433
+ *
1434
+ * 立即生效,不需要重启:`resolveTrust` 每次调用都现读 `globalCfg`。
1435
+ */
1436
+ if (body.security?.trust && typeof body.security.trust.enforce === 'boolean') {
1437
+ globalCfg.security = {
1438
+ ...(globalCfg.security ?? {}),
1439
+ trust: { ...(globalCfg.security?.trust ?? {}), enforce: body.security.trust.enforce },
1440
+ };
1441
+ await saveGlobalConfig?.(globalCfg).catch(() => {});
1442
+ }
1412
1443
  if (body.whitelist && typeof body.whitelist === 'object') {
1413
1444
  cfg.whitelist = {
1414
1445
  enabled: Boolean(body.whitelist.enabled),
@@ -1948,7 +1979,7 @@ export async function startWebServer({
1948
1979
  if (pathname === '/api/market' && req.method === 'GET') {
1949
1980
  const url2 = url.searchParams.get('source') || globalCfg.plugins?.marketUrl || undefined;
1950
1981
  try {
1951
- const idx = await fetchIndex(url2);
1982
+ const idx = await fetchIndex(url2, { trust: resolveTrust(globalCfg) });
1952
1983
  const installedIds = new Set(plugins.loaded.keys());
1953
1984
  // 目录里存在但没加载成功的也算「已装」
1954
1985
  try {
@@ -1970,6 +2001,8 @@ export async function startWebServer({
1970
2001
  latest: u.latest,
1971
2002
  })),
1972
2003
  localVersions: localVers,
2004
+ // 索引签名状态:ok / unsigned / bad / unavailable(面板可据此提示)
2005
+ trust: idx?._trust?.status || 'unavailable',
1973
2006
  // 网络挂了用的是本地缓存,面板要提示用户「这是旧数据」
1974
2007
  stale: idx.stale === true,
1975
2008
  staleAge: idx.staleAge || '',
@@ -1986,10 +2019,19 @@ export async function startWebServer({
1986
2019
  if (!id) return json(res, 400, { error: '缺少插件 id' });
1987
2020
  try {
1988
2021
  const src = globalCfg.plugins?.marketUrl || undefined;
1989
- const idx = await fetchIndex(src);
2022
+ const idx = await fetchIndex(src, { trust: resolveTrust(globalCfg) });
1990
2023
  const entry = idx.plugins.find((p) => p.id === id);
1991
2024
  if (!entry) return json(res, 404, { error: `市场里没有 ${id}` });
1992
2025
 
2026
+ /**
2027
+ * 信任闸。**只对官方源生效**:手写插件走 `plugins/` 目录,根本不经过
2028
+ * 市场索引;用户自己填的市场源是他自己的信任决定,不拦。
2029
+ * 本版 `enforce` 默认 false,所以这里恒放行。
2030
+ */
2031
+ const official = isOfficialSource(src, globalCfg);
2032
+ const trustWhy = blockReason(resolveTrust(globalCfg), idx?._trust?.status || 'unavailable', id, { official });
2033
+ if (trustWhy) return json(res, 403, { error: trustWhy });
2034
+
1993
2035
  /**
1994
2036
  * 下载 + 解包期间要挂起插件热重载。
1995
2037
  *
@@ -2614,10 +2656,19 @@ export async function startWebServer({
2614
2656
  let repo = null;
2615
2657
  try {
2616
2658
  const src = globalCfg.plugins?.marketUrl || undefined;
2617
- const idx = await fetchIndex(src);
2659
+ const idx = await fetchIndex(src, { trust: resolveTrust(globalCfg) });
2618
2660
  const entry = idx.plugins.find((p) => p.id === id);
2619
2661
  if (!entry) return json(res, 404, { error: `市场里没有 ${id}` });
2620
2662
 
2663
+ /**
2664
+ * 信任闸。**只对官方源生效**:手写插件走 `plugins/` 目录,根本不经过
2665
+ * 市场索引;用户自己填的市场源是他自己的信任决定,不拦。
2666
+ * 本版 `enforce` 默认 false,所以这里恒放行。
2667
+ */
2668
+ const official = isOfficialSource(src, globalCfg);
2669
+ const trustWhy = blockReason(resolveTrust(globalCfg), idx?._trust?.status || 'unavailable', id, { official });
2670
+ if (trustWhy) return json(res, 403, { error: trustWhy });
2671
+
2621
2672
  const localVers = await plugins.localVersions();
2622
2673
  const cur = localVers[id];
2623
2674
  if (!cur) return json(res, 400, { error: `本地没装 ${id},先用「安装」` });
package/web/app.js CHANGED
@@ -1066,6 +1066,21 @@ function fillForm(c) {
1066
1066
  f.logLevel.value = c.logLevel || 'info';
1067
1067
 
1068
1068
  f.groupInvited.checked = Boolean(c.groupManage?.invited);
1069
+
1070
+ /**
1071
+ * 插件来源校验开关。`pubkeySet: false` 时必须说清楚「开了也没用」——
1072
+ * 否则用户打开开关、以为已经受保护,实际什么都没发生,这比没有开关更糟。
1073
+ */
1074
+ const trust = c.security?.trust ?? {};
1075
+ f.trustEnforce.checked = Boolean(trust.enforce);
1076
+ const trustHint = $('#trustHint');
1077
+ if (trustHint) {
1078
+ trustHint.textContent = !trust.pubkeySet
1079
+ ? '当前框架没有内置信任公钥,这个开关即使打开也不起作用(所有插件照常放行)。'
1080
+ : !trust.pubkeyValid
1081
+ ? '信任公钥配错了,解析不了——这个开关打开也不会生效。检查配置里的 security.trust.pubkey。'
1082
+ : '开启后,官方市场里未签名或签名不符的插件会被拒绝安装和更新;手写插件、以及你自己配置的市场源不受影响。';
1083
+ }
1069
1084
  }
1070
1085
 
1071
1086
  /* ---------------- 事件订阅 intents ---------------- */
@@ -1348,6 +1363,10 @@ $('#cfgForm').addEventListener('submit', async (e) => {
1348
1363
  groupManage: {
1349
1364
  invited: f.groupInvited.checked,
1350
1365
  },
1366
+ // 全局设置(config/bot.json),和 webui 一样不写进机器人配置
1367
+ security: {
1368
+ trust: { enforce: f.trustEnforce.checked },
1369
+ },
1351
1370
  };
1352
1371
 
1353
1372
  const btn = f.querySelector('button[type=submit]');
@@ -1804,6 +1823,31 @@ function stopPluginDashPolling() {
1804
1823
  pluginDashTimer = null;
1805
1824
  }
1806
1825
 
1826
+ /** 插件卡片上的声明摘要:命令/工具/钩子数量 + 版本要求 */
1827
+ function renderDecl(p) {
1828
+ const pr = p.provides || {};
1829
+ const bits = [];
1830
+ if (pr.commands?.length) bits.push(`命令 ${pr.commands.length}`);
1831
+ if (pr.tools?.length) bits.push(`工具 ${pr.tools.length}`);
1832
+ if (pr.hooks?.length) bits.push(`钩子 ${pr.hooks.length}`);
1833
+ const eng = Object.entries(p.engines || {});
1834
+ if (!bits.length && !eng.length) return '';
1835
+ const detail = [
1836
+ pr.commands?.length ? `命令:${pr.commands.join('、')}` : '',
1837
+ pr.tools?.length ? `工具:${pr.tools.join('、')}` : '',
1838
+ pr.hooks?.length ? `钩子:${pr.hooks.join('、')}` : '',
1839
+ ].filter(Boolean).join(';');
1840
+ const engText = eng.map(([k, v]) => `${k} ${v}`).join(' / ');
1841
+ return [
1842
+ bits.length
1843
+ ? `<span class="hint" title="${escapeHtml(detail)}">声明 ${escapeHtml(bits.join(' · '))}</span>`
1844
+ : '',
1845
+ eng.length
1846
+ ? `<span class="hint" title="插件声明的版本要求">${p.enginesOk === false ? '⚠ ' : ''}需 ${escapeHtml(engText)}</span>`
1847
+ : '',
1848
+ ].join('');
1849
+ }
1850
+
1807
1851
  async function loadPlugins() {
1808
1852
  const wrap = $('#pluginList');
1809
1853
  // 插件装了/卸了,它注册的面板页面也会变,顺带刷一下标签栏
@@ -1850,6 +1894,7 @@ async function loadPlugins() {
1850
1894
  ? `<span class="hint" title="插件声明的框架版本要求">需框架 ${escapeHtml(p.frameworkVersion)}</span>`
1851
1895
  : ''
1852
1896
  }
1897
+ ${renderDecl(p)}
1853
1898
  ${(p.shortDesc || p.description) ? '<br>' + escapeHtml(p.shortDesc || p.description) : ''}
1854
1899
  </div>
1855
1900
  ${chips ? `<div class="plugin-metrics">${chips}</div>` : ''}