qq-bot-roost 0.8.10 → 0.8.12

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 CHANGED
@@ -10,7 +10,7 @@
10
10
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
11
11
  <img src="https://img.shields.io/badge/tests-600%2B%20passed-brightgreen" alt="测试 600+ 项">
12
12
  <img src="https://img.shields.io/badge/plugins-JS%20%7C%20Python%20%7C%20Go%20%7C%20any-blue" alt="多语言插件">
13
- <img src="https://img.shields.io/badge/version-0.8.9-blueviolet" alt="版本 0.8.9">
13
+ <img src="https://img.shields.io/badge/version-0.8.12-blueviolet" alt="版本 0.8.12">
14
14
  <img src="https://img.shields.io/badge/license-Non--Commercial-red" alt="禁止商用">
15
15
  <a href="https://github.com/tdcq9594/qq-bot-roost/actions"><img src="https://img.shields.io/github/actions/workflow/status/tdcq9594/qq-bot-roost/ci.yml?branch=master" alt="CI"></a>
16
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.8.10",
3
+ "version": "0.8.12",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -239,6 +239,42 @@ function venvPython(venvDir) {
239
239
  : path.join(venvDir, 'bin', 'python');
240
240
  }
241
241
 
242
+ /**
243
+ * requirements.txt 单行的白名单。
244
+ *
245
+ * 只接受「包名 + 版本约束」(可带 extras、可带环境标记)这一种形态,其它一律拒。
246
+ * 不能用黑名单——pip 的选项缩写太多(`-i` = `--index-url`、`-c` = 约束文件、
247
+ * `-r` = 嵌套 requirement、`--no-index`、`--pre`…),黑名单列不完,
248
+ * 漏一个就是一条供应链后门:插件作者能把 pip 指到任意源、拉任意代码。
249
+ *
250
+ * 环境标记里也不许出现 `--`,否则 `pkg; x --index-url evil` 会绕过这条意图。
251
+ */
252
+ const REQUIREMENT_RE =
253
+ /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\[[A-Za-z0-9._,-]+\])?(?:\s*(?:==|>=|<=|~=|!=|===|>|<)\s*[^\s,]+(?:\s*,\s*[^\s,]+)*)?(?:\s*;\s*(?:(?!--)[^\n])+)?$/;
254
+
255
+ /**
256
+ * pip 自己的注释规则(见 pip._internal.req.req_file.COMMENT_RE):(^|\s+)#.*$
257
+ * 套白名单前必须按同样规则剥掉,否则 `requests>=2.31 # 固定下限` 这种合法写法
258
+ * 会被误杀——这里是 throw,误杀等于整个插件装不上。
259
+ */
260
+ const stripReqComment = (l) => l.replace(/(^|\s+)#.*$/, '').trim();
261
+
262
+ /**
263
+ * 校验 requirements.txt 全文。
264
+ * 抽成独立函数是为了能直接测:白名单一旦被改坏(拒了合法行 / 放了危险行),
265
+ * 靠构造假 python 走 ensurePluginDeps 很难测全。
266
+ *
267
+ * @param {string} requirements 文件全文
268
+ * @returns {{ok: true} | {ok: false, line: string}} line 是第一条不安全的行(已剥注释)
269
+ */
270
+ export function checkRequirements(requirements) {
271
+ const bad = String(requirements ?? '')
272
+ .split(/\r?\n/)
273
+ .map((l) => stripReqComment(l.trim()))
274
+ .find((l) => l !== '' && !REQUIREMENT_RE.test(l));
275
+ return bad === undefined ? { ok: true } : { ok: false, line: bad };
276
+ }
277
+
242
278
  /**
243
279
  * 确保插件的依赖装好。
244
280
  *
@@ -311,27 +347,14 @@ export async function ensurePluginDeps({ dir, manifest = {}, log = null, enabled
311
347
  /**
312
348
  * requirements.txt 里能写 `--index-url`、`-e git+...`、直接写 URL——
313
349
  * 等于让插件作者把 pip 指到任意源、拉任意代码。
314
- *
315
- * 这里用**白名单**(fail-closed):只接受「包名 + 版本约束」这一种形态,
316
- * 其它一律拒。不能用黑名单——pip 的选项缩写太多(`-i` = `--index-url`、
317
- * `-c` = 约束文件、`-r` = 嵌套 requirement、`--no-index`、`--pre`…),
318
- * 黑名单列不完,漏一个就是一条供应链后门。
319
- *
320
- * 合法行只匹配:包名(字母数字 + `-_.`)后面可选地跟版本约束
321
- * (`==` / `>=` / `~=` / `<=` / `!=` / `>` / `<` 等,或逗号分隔的多个)。
322
- * 环境标记(`; python_version < "3.8"`)一并放行,它不含网络指令。
350
+ * 白名单的细节和理由见上面的 checkRequirements()。
323
351
  */
324
- const badLine = requirements
325
- .split(/\r?\n/)
326
- .map((l) => l.trim())
327
- .find((l) => {
328
- if (!l || l.startsWith('#')) return false; // 空行 / 注释放行
329
- return !/^[A-Za-z0-9][A-Za-z0-9._-]*(?:\[[A-Za-z0-9._,-]+\])?(?:\s*(?:==|>=|<=|~=|!=|===|>|<)\s*[^\s,]+(?:\s*,\s*[^\s,]+)*)?(?:\s*;\s*[^\n]+)?$/.test(l);
330
- });
331
- if (badLine) {
352
+ const rq = checkRequirements(requirements);
353
+ if (!rq.ok) {
332
354
  throw new Error(
333
- `requirements.txt 里有不安全的行,已拒绝安装:${badLine}\n` +
334
- '只允许「包名 + 版本约束」(如 requests>=2.31),不接受 -e / --index-url / 直接 URL / pip 选项。',
355
+ `requirements.txt 里有不安全的行,已拒绝安装:${rq.line}\n` +
356
+ '只允许「包名 + 版本约束」(如 requests>=2.31),不接受 -e / --index-url / 直接 URL / pip 选项。' +
357
+ '行尾注释可以写(`# xxx`),但不支持反斜杠续行。',
335
358
  );
336
359
  }
337
360
 
package/src/web/server.js CHANGED
@@ -111,6 +111,57 @@ const CSP_PAGE = [
111
111
  "connect-src http: https:",
112
112
  ].join('; ');
113
113
 
114
+ /**
115
+ * 插件页的「自动带令牌」补丁 —— 注入到插件页 HTML 最前面。
116
+ *
117
+ * **为什么需要(0.8.12 修的回归)**:iframe 地址上带的凭证以前是面板密钥
118
+ * `?key=`,插件页面自己把它转发给接口。0.8.9 起换成了一次性页面令牌
119
+ * `?pt=`,但**已有插件的页面还在转发 `key`** —— 于是它们发出的请求一个
120
+ * 凭证都没带;iframe 又是 opaque origin(`Origin: null`),`isSameOrigin()`
121
+ * 判跨站 → 403,页面永远停在「加载中…」,控制台只看到 `Failed to load
122
+ * resource: 403`。dice 的战绩页就是这么挂的。
123
+ *
124
+ * 不能指望每个插件作者都回来改这一行(市场里的插件都在外部仓库),
125
+ * 所以框架替页面把 `pt` 补上。
126
+ *
127
+ * **安全边界没变**:
128
+ * - 只补发给**面板自己**(目标 origin 必须等于文档基准的 origin),
129
+ * 第三方站点拿不到令牌;
130
+ * - 能碰哪些路径仍由 `pageTokenAllows()` 决定,拿着令牌也调不了
131
+ * /api/config、/api/logs、/api/bots。
132
+ *
133
+ * @param {string} pt 本次请求的页面令牌
134
+ * @returns {string} 一段 `<script>`,无令牌时返回空串
135
+ */
136
+ export const pageTokenPatch = (pt) => {
137
+ const token = String(pt ?? '').replace(/[^A-Za-z0-9]/g, '');
138
+ if (!token) return '';
139
+ return (
140
+ '<script>(function(){var PT=' +
141
+ JSON.stringify(token) +
142
+ ',BASE=document.baseURI||location.href,SELF="";' +
143
+ 'try{SELF=new URL(BASE).origin}catch(e){}' +
144
+ // 只给面板自己的地址补令牌:第三方 origin 原样发出,绝不外泄
145
+ 'function add(u){try{var n=new URL(u,BASE);' +
146
+ 'if(SELF&&n.origin!==SELF)return u;' +
147
+ 'if(n.searchParams.get("pt"))return n.href;' +
148
+ 'n.searchParams.set("pt",PT);return n.href}catch(e){return u}}' +
149
+ 'var f=window.fetch;if(f)window.fetch=function(i,o){try{' +
150
+ 'if(typeof i==="string")i=add(i);' +
151
+ 'else if(i instanceof URL)i=new URL(add(i.href));' +
152
+ 'else if(typeof Request!=="undefined"&&i instanceof Request){var h=add(i.url);' +
153
+ 'if(h!==i.url)i=new Request(h,i)}}catch(e){}return f.call(window,i,o)};' +
154
+ 'var X=window.XMLHttpRequest;' +
155
+ 'if(X&&X.prototype&&X.prototype.open){var O=X.prototype.open;' +
156
+ 'X.prototype.open=function(m,u){if(typeof u==="string"){' +
157
+ 'try{arguments[1]=add(u)}catch(e){}}return O.apply(this,arguments)}};' +
158
+ 'var E=window.EventSource;' +
159
+ 'if(E){var W=function(u,c){return new E(add(String(u)),c)};W.prototype=E.prototype;' +
160
+ 'try{W.CONNECTING=E.CONNECTING;W.OPEN=E.OPEN;W.CLOSED=E.CLOSED}catch(e){}' +
161
+ 'window.EventSource=W}})();<\/script>'
162
+ );
163
+ };
164
+
114
165
  const MIME = {
115
166
  '.html': 'text/html; charset=utf-8',
116
167
  '.css': 'text/css; charset=utf-8',
@@ -1637,9 +1688,15 @@ export async function startWebServer({
1637
1688
  * 所以有 doctype 就插在它后面。
1638
1689
  */
1639
1690
  const style = PAGE_THEME[theme];
1691
+ /**
1692
+ * 自动带令牌的补丁必须比页面自己的脚本**先**执行,所以跟主题样式
1693
+ * 一起插在最前面(doctype 之后 / 没有 doctype 就文档开头)。
1694
+ */
1695
+ const patch = pageTokenPatch(url.searchParams.get('pt') || ptPage?.token || '');
1696
+ const prefix = patch + style;
1640
1697
  const html = /^<!doctype/i.test(p.html.trimStart())
1641
- ? p.html.replace(/^(\s*<!doctype[^>]*>)/i, `$1${style}`)
1642
- : style + p.html;
1698
+ ? p.html.replace(/^(\s*<!doctype[^>]*>)/i, `$1${prefix}`)
1699
+ : prefix + p.html;
1643
1700
  return res.end(html);
1644
1701
  }
1645
1702