@sleep2agi/commhub-server 0.9.0-preview.146 → 0.9.0-preview.147

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": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.146",
3
+ "version": "0.9.0-preview.147",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -640,3 +640,46 @@ describe("#506 cross-network isolation of /health sse_sessions (same alias, two
640
640
  expect(keys.some((k) => k.startsWith(`${aNet}:`))).toBe(false);
641
641
  });
642
642
  });
643
+
644
+ // #2149 —— 错误路径**不能回显凭据**。
645
+ //
646
+ // 背景:2026-10-23 TMA云鲸 对 CommHub MCP 做只读扫描时收到 502,代理生成的
647
+ // 非 JSON 错误页把本次请求的鉴权相关头回显在正文里。那个 502 页很可能来自
648
+ // 调用链上的代理(本仓没有 502 生成逻辑),但**这条防线必须在本仓也有**:
649
+ // 任何 4xx/5xx 的响应体都不该把调用方递上来的 `Authorization` / `Cookie` /
650
+ // query 里的 token 原样吐回去 —— 一旦有人写出这种错误文案,它就会进日志、
651
+ // 进回执、进诊断归档。
652
+ //
653
+ // 做法是**注入一个形状像真凭据的 canary**,然后打一圈错误路径,断言响应正文
654
+ // 里一次都不出现它。canary 用 `ntok_` 前缀,正是本仓 `redactTokens` 认得的形状。
655
+ describe("#2149 error paths never echo credentials", () => {
656
+ const CANARY = "ntok_LEAKCANARY_0123456789abcdef";
657
+ const COOKIE = "session=COOKIELEAKCANARY0123456789";
658
+
659
+ const paths: Array<{ method: string; path: string; body?: string }> = [
660
+ { method: "GET", path: "/api/nope-not-a-route" },
661
+ { method: "GET", path: "/api/requirements" },
662
+ { method: "GET", path: "/api/nodes" },
663
+ { method: "POST", path: "/mcp", body: "{not json" },
664
+ { method: "POST", path: "/api/auth/login", body: JSON.stringify({ username: "nobody", password: "wrong-password-Aa1!" }) },
665
+ { method: "POST", path: "/api/auth/me/avatar/upload" },
666
+ ];
667
+
668
+ test("no 4xx/5xx body contains the presented token, cookie or query secret", async () => {
669
+ for (const c of paths) {
670
+ const res = await fetch(`${BASE}${c.path}${c.path.includes("?") ? "&" : "?"}access_token=${CANARY}`, {
671
+ method: c.method,
672
+ headers: {
673
+ authorization: `Bearer ${CANARY}`,
674
+ cookie: COOKIE,
675
+ "content-type": "application/json",
676
+ },
677
+ body: c.body,
678
+ });
679
+ const text = await res.text();
680
+ expect(text.includes(CANARY)).toBe(false);
681
+ expect(text.includes("LEAKCANARY")).toBe(false);
682
+ expect(text.includes("COOKIELEAKCANARY")).toBe(false);
683
+ }
684
+ });
685
+ });
@@ -312,13 +312,23 @@ describe("old clients unaffected", () => {
312
312
  });
313
313
 
314
314
  describe("scheduler re-checks the creating node as it is now", () => {
315
+ // #2115 —— `at` 原来是 `now - 5s - random(0..1000ms)`:两次调用只要「晚的毫秒差」正好等于两个
316
+ // 随机值之差,算出的 `at` 就完全相同,于是第二次按 `scheduled_for = at` 读到的是**上一轮那行**
317
+ // (于是 test638 L2 并发聚合偶发「期望 creator_node_gone、得到 creator_node_readonly」)。
318
+ // 注释里说「随机」的人以为碰撞概率是 1/1000,其实是「随机差 == 时间差」,慢机器上时间差几十毫秒
319
+ // 就落在这个窗口里。改成单调递推:每次减 1s,任何两次调用都不可能相等。
320
+ let dueSeq = 0;
315
321
  const dueAndRun = async (id: string) => {
316
- const at = new Date(Date.now() - 5_000 - Math.floor(Math.random() * 1000)).toISOString();
322
+ dueSeq += 1;
323
+ const at = new Date(Date.now() - 5_000 - dueSeq * 1_000).toISOString();
317
324
  db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [at, id]);
318
325
  const { runDueScheduledTasks } = await import("./scheduled-tasks.js");
319
326
  runDueScheduledTasks();
320
- return db.get<{ status: string; error_code: string | null; task_id: string | null }>(
321
- "SELECT status, error_code, task_id FROM scheduled_task_runs WHERE schedule_id = ?1 AND scheduled_for = ?2", id, at)!;
327
+ const row = db.get<{ status: string; error_code: string | null; task_id: string | null }>(
328
+ "SELECT status, error_code, task_id FROM scheduled_task_runs WHERE schedule_id = ?1 AND scheduled_for = ?2", id, at);
329
+ // 把「这一轮根本没跑出 run 行」和「跑出了但结论不对」分开报 —— 前者是夹具问题,后者才是行为问题。
330
+ expect(row).toBeTruthy();
331
+ return row!;
322
332
  };
323
333
  test("owner changes to a user not granted the target: send_task denied, and so is every scheduled run", async () => {
324
334
  const id = (await create("a", { target_node_id: node.b.id })).schedule.schedule_id;
package/src/server.ts CHANGED
@@ -99,7 +99,7 @@ import { handleSideThreadHttpRequest } from "./side-thread-http.js";
99
99
  import { createProductionSideThreadTransport } from "./side-thread-production.js";
100
100
  import { parseHubTimestamp } from "./hub-timestamp";
101
101
  import { logTaskRejection } from "./task-rejection-log.js";
102
- import { AVATAR_FIELD, avatarUrl, inspectAvatarImage, isMultipart, isValidAvatarVersion, MAX_AVATAR_BYTES } from "./user-avatar-image.js";
102
+ import { AVATAR_FIELD, avatarUrl, inspectAvatarImage, isMultipart, isValidAvatarVersion, MAX_AVATAR_BYTES, MAX_AVATAR_STORAGE_BYTES } from "./user-avatar-image.js";
103
103
 
104
104
  const PORT = resolvePort(process.env.PORT);
105
105
  const HOST = process.env.HOST || "127.0.0.1";
@@ -1553,6 +1553,14 @@ return Bun.serve({
1553
1553
  return withCors(req, Response.json({ ok: false, error: "invalid_avatar_body", reason: "expected exactly one `file` part" }, { status: 400 }));
1554
1554
  }
1555
1555
  if (file.size > MAX_AVATAR_BYTES) return withCors(req, Response.json({ ok: false, error: "too_large" }, { status: 413 }));
1556
+ // #2054 —— Hub-wide 总容量上界:公开注册可无限建账号,per-user 单条上限挡不住
1557
+ // 「轮换账号把主库撑大」。这里在**写入前**用当前总量 + 本次大小判一次;
1558
+ // 已存在的其他用户头像绝不删除(不回收别人的数据),只拒绝本次上传。
1559
+ const storedBytes = db.get<{ total: number }>("SELECT COALESCE(SUM(bytes), 0) AS total FROM user_avatar_uploads")?.total ?? 0;
1560
+ const ownBytes = db.get<{ bytes: number }>("SELECT bytes FROM user_avatar_uploads WHERE user_id = ?1", [resolved.user.user_id])?.bytes ?? 0;
1561
+ if (storedBytes - ownBytes + file.size > MAX_AVATAR_STORAGE_BYTES) {
1562
+ return withCors(req, Response.json({ ok: false, error: "avatar_storage_full" }, { status: 503 }));
1563
+ }
1556
1564
  const bytes = new Uint8Array(await file.arrayBuffer());
1557
1565
  const check = inspectAvatarImage(bytes);
1558
1566
  if (!check.ok) return withCors(req, Response.json({ ok: false, error: "invalid_image", reason: check.reason }, { status: 400 }));
@@ -10,6 +10,13 @@ import { inflateSync } from "node:zlib";
10
10
  // 校验强度:结构(签名/chunk/CRC)+ 真实解码(IDAT 解压且扫描行长度与 IHDR 相符)。
11
11
 
12
12
  export const MAX_AVATAR_BYTES = 8 * 1024 * 1024;
13
+
14
+ /**
15
+ * #2054 —— 单个 Hub 的头像总存储上界。公开注册可以持续创建账号,per-user 单条
16
+ * 上限(每人 1 条 × 8 MiB)挡不住轮换账号把主库撑大,所以再加一道总量边界。
17
+ * 超限时上传返回 503 `avatar_storage_full`;不回收/删除任何既有用户的头像。
18
+ */
19
+ export const MAX_AVATAR_STORAGE_BYTES = 1024 * 1024 * 1024;
13
20
  /** 客户端会把图重编码到最长边 512;服务端独立复核这条上限。 */
14
21
  export const MAX_AVATAR_EDGE = 512;
15
22
  /** version 只允许 URL 安全字符,长度有界(避免拼路径时引入 `.`、`/`)。 */
@@ -292,3 +292,57 @@ test("image inspector: signature, shape and size limits", () => {
292
292
  expect(inspectAvatarImage(jpeg)).toEqual({ ok: false, reason: "not_png" });
293
293
  expect(inspectAvatarImage(Buffer.alloc(MAX_AVATAR_BYTES + 1))).toEqual({ ok: false, reason: "too_large" });
294
294
  });
295
+
296
+ // #2054 —— Hub-wide 头像总容量上界:公开注册可无限建账号,per-user 单条上限挡不住
297
+ // 轮换账号把主库撑大。这里在库里塞一条接近上界的行,断言上传被 503 拒绝,
298
+ // 且**不删除**任何既有用户的头像(别人的数据不动),随后清掉该行恢复。
299
+ test("account avatar upload: a hub-wide storage cap rejects the write and keeps existing rows", async () => {
300
+ const { MAX_AVATAR_STORAGE_BYTES } = await import("./user-avatar-image.js");
301
+ const victim = register("avcap_alice", "AvatarCap1!");
302
+ const uploader = register("avcap_bob", "AvatarCap1!");
303
+ expect(victim.ok && uploader.ok).toBe(true);
304
+
305
+ const { bootServer } = await import("./server.js");
306
+ const hub = bootServer({ port: 0, hostname: "127.0.0.1" });
307
+ const base = `http://127.0.0.1:${hub.port}`;
308
+ const upload = async (token: string, bytes: Buffer) => {
309
+ const form = new FormData();
310
+ form.append("file", new Blob([bytes], { type: "image/png" }), "a.png");
311
+ const res = await fetch(`${base}/api/auth/me/avatar/upload`, {
312
+ method: "POST", headers: { Authorization: `Bearer ${token}` }, body: form,
313
+ });
314
+ return { status: res.status, body: (await res.json().catch(() => null)) as any };
315
+ };
316
+
317
+ const filler = Buffer.from("filler-avatar-bytes");
318
+ try {
319
+ // 先让 bob 正常上传一次,证明上界不是「谁都不能传」。
320
+ const ok = await upload(uploader.token!, PNG_1X1);
321
+ expect(ok.status).toBe(200);
322
+
323
+ // 塞一条「别人已经占满」的行:总量逼到上界之上。
324
+ db.run(
325
+ "INSERT INTO user_avatar_uploads (user_id, version, png, bytes, width, height, updated_at) VALUES (?1, ?2, ?3, ?4, 1, 1, datetime('now'))",
326
+ [victim.user!.user_id, "capver", filler, MAX_AVATAR_STORAGE_BYTES],
327
+ );
328
+ const before = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_avatar_uploads")!.n;
329
+
330
+ const blocked = await upload(uploader.token!, PNG_1X1);
331
+ expect(blocked.status).toBe(503);
332
+ expect(blocked.body.error).toBe("avatar_storage_full");
333
+
334
+ // 不回收别人的数据:行数与那条 filler 都还在。
335
+ const after = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_avatar_uploads")!.n;
336
+ expect(after).toBe(before);
337
+ expect(db.get<any>("SELECT bytes FROM user_avatar_uploads WHERE user_id = ?1", [victim.user!.user_id])?.bytes)
338
+ .toBe(MAX_AVATAR_STORAGE_BYTES);
339
+ // 自己的额度要扣掉:上限全算在自己头上时,替换**自己**的头像不应被拒。
340
+ db.run("DELETE FROM user_avatar_uploads WHERE user_id = ?1", [victim.user!.user_id]);
341
+ db.run("UPDATE user_avatar_uploads SET bytes = ?1 WHERE user_id = ?2", [MAX_AVATAR_STORAGE_BYTES, uploader.user!.user_id]);
342
+ const replaced = await upload(uploader.token!, PNG_1X1);
343
+ expect(replaced.status).toBe(200);
344
+ } finally {
345
+ db.run("DELETE FROM user_avatar_uploads WHERE user_id = ?1", [victim.user!.user_id]);
346
+ hub.stop?.();
347
+ }
348
+ });