@sleep2agi/commhub-server 0.9.0-preview.24 → 0.9.0-preview.26
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 +9 -6
- package/package.json +1 -1
- package/src/auth.ts +5 -0
- package/src/avatar-validate.test.ts +52 -2
- package/src/avatar-validate.ts +35 -1
- package/src/ext-validation-regression.test.ts +138 -0
- package/src/file-download-authz.test.ts +50 -16
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +731 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-scope.ts +81 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +298 -89
- package/src/tools.ts +148 -42
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +80 -3
- package/src/uploads.ts +81 -3
package/README.md
CHANGED
|
@@ -38,13 +38,16 @@ Once running:
|
|
|
38
38
|
|
|
39
39
|
## Pairs with
|
|
40
40
|
|
|
41
|
-
| Package |
|
|
41
|
+
| Package | |
|
|
42
42
|
|---|---|
|
|
43
|
-
| [`@sleep2agi/agent-network`](https://www.npmjs.com/package/@sleep2agi/agent-network) |
|
|
44
|
-
| [`@sleep2agi/agent-network-dashboard`](https://www.npmjs.com/package/@sleep2agi/agent-network-dashboard) |
|
|
45
|
-
| [`@sleep2agi/agent-node`](https://www.npmjs.com/package/@sleep2agi/agent-node) |
|
|
43
|
+
| [`@sleep2agi/agent-network`](https://www.npmjs.com/package/@sleep2agi/agent-network) | CLI |
|
|
44
|
+
| [`@sleep2agi/agent-network-dashboard`](https://www.npmjs.com/package/@sleep2agi/agent-network-dashboard) | Web UI |
|
|
45
|
+
| [`@sleep2agi/agent-node`](https://www.npmjs.com/package/@sleep2agi/agent-node) | Agent runtime host |
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
Install the matching versions with `npm install -g <pkg>` — the `latest` dist-tag on npm is
|
|
48
|
+
authoritative. Pinning versions here goes stale on every release and nobody comes back to fix it.
|
|
49
|
+
|
|
50
|
+
## MCP tools
|
|
48
51
|
|
|
49
52
|
### Agent-side
|
|
50
53
|
| Tool | Description |
|
|
@@ -96,7 +99,7 @@ Network-management endpoints (`/api/networks…`) are present and used by the cu
|
|
|
96
99
|
|
|
97
100
|
Auth: `Authorization: Bearer <token>` header, or `?token=<token>` query.
|
|
98
101
|
|
|
99
|
-
## SQLite schema
|
|
102
|
+
## SQLite schema
|
|
100
103
|
|
|
101
104
|
Auto-created on first run.
|
|
102
105
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.26",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/auth.ts
CHANGED
|
@@ -297,6 +297,11 @@ export function deleteNetwork(userId: string, networkId: string): { ok: boolean;
|
|
|
297
297
|
const sessions = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
|
|
298
298
|
if (sessions && sessions.cnt > 0) return { ok: false, error: `network has ${sessions.cnt} active session(s) — stop them first` };
|
|
299
299
|
db.run("DELETE FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
|
|
300
|
+
// PR #519 codex catch: leaving membership rows behind made deleted
|
|
301
|
+
// networks count toward getUserNetworkIds — a stale row could flip a
|
|
302
|
+
// single-network user to "ambiguous" or auto-resolve writes INTO the
|
|
303
|
+
// deleted network. Remove memberships with the network.
|
|
304
|
+
db.run("DELETE FROM network_members WHERE network_id = ?1", [networkId]);
|
|
300
305
|
return { ok: true };
|
|
301
306
|
}
|
|
302
307
|
|
|
@@ -60,8 +60,10 @@ describe("#462 validateAvatarUrl", () => {
|
|
|
60
60
|
expect(validateAvatarUrl("ftp://example.com/a.png").ok).toBe(false);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
test("rejects
|
|
64
|
-
|
|
63
|
+
test("rejects protocol-relative / non-URL / schemeless strings", () => {
|
|
64
|
+
// SPEC CHANGE (通信龙 裁定, avatar 接线单): same-origin pool paths
|
|
65
|
+
// (/avatars/<name>.<ext>) are now ACCEPTED — that case moved to the
|
|
66
|
+
// dedicated relative-branch suite below. Everything here stays rejected.
|
|
65
67
|
expect(validateAvatarUrl("//cdn.example.com/1.png").ok).toBe(false);
|
|
66
68
|
expect(validateAvatarUrl("not a url").ok).toBe(false);
|
|
67
69
|
expect(validateAvatarUrl("example.com/x.png").ok).toBe(false);
|
|
@@ -111,3 +113,51 @@ describe("#462 validateAvatarUrl", () => {
|
|
|
111
113
|
expect(validateAvatarUrl(ok + "a").ok).toBe(false);
|
|
112
114
|
});
|
|
113
115
|
});
|
|
116
|
+
|
|
117
|
+
// ── Same-origin relative branch (通信龙 裁定: relax to portable pool paths;
|
|
118
|
+
// absolute URLs would weld one hostname into the DB across multi-origin
|
|
119
|
+
// entry points). Each direction is its OWN test so any single guard
|
|
120
|
+
// being deleted turns its OWN assertion red (no merged catch-all).
|
|
121
|
+
describe("validateAvatarUrl — same-origin relative pool paths", () => {
|
|
122
|
+
test("accepts a pool path: /avatars/avatar-09.webp", () => {
|
|
123
|
+
const r = validateAvatarUrl("/avatars/avatar-09.webp");
|
|
124
|
+
expect(r).toEqual({ ok: true, value: "/avatars/avatar-09.webp" });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("accepts png and svg pool files", () => {
|
|
128
|
+
expect(validateAvatarUrl("/avatars/intern_avatar.png")).toEqual({ ok: true, value: "/avatars/intern_avatar.png" });
|
|
129
|
+
expect(validateAvatarUrl("/avatars/team-logo.svg")).toEqual({ ok: true, value: "/avatars/team-logo.svg" });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("rejects protocol-relative //host (classic same-origin bypass)", () => {
|
|
133
|
+
const r = validateAvatarUrl("//evil.com/x.png");
|
|
134
|
+
expect(r.ok).toBe(false);
|
|
135
|
+
if (!r.ok) expect(r.reason).toContain("protocol-relative");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("rejects javascript: scheme (still, after the relax)", () => {
|
|
139
|
+
expect(validateAvatarUrl("javascript:alert(1)").ok).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("rejects non-/avatars/ absolute paths (/etc/passwd)", () => {
|
|
143
|
+
expect(validateAvatarUrl("/etc/passwd").ok).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("rejects non-whitelisted extensions under /avatars/", () => {
|
|
147
|
+
expect(validateAvatarUrl("/avatars/payload.html").ok).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("rejects nested/traversal paths (charset forbids further slashes)", () => {
|
|
151
|
+
expect(validateAvatarUrl("/avatars/../secrets.webp").ok).toBe(false);
|
|
152
|
+
expect(validateAvatarUrl("/avatars/sub/x.webp").ok).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("rejects percent-encoding inside the filename (no encoded surprises)", () => {
|
|
156
|
+
expect(validateAvatarUrl("/avatars/a%2f..%2fx.webp").ok).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("absolute http(s) branch is UNTOUCHED by the relax (positive control)", () => {
|
|
160
|
+
const r = validateAvatarUrl("https://example.com/pic.png");
|
|
161
|
+
expect(r.ok).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
});
|
package/src/avatar-validate.ts
CHANGED
|
@@ -15,9 +15,26 @@ export type AvatarValidation =
|
|
|
15
15
|
* Normalize + validate an untrusted avatar_url patch value.
|
|
16
16
|
*
|
|
17
17
|
* null / undefined / "" / whitespace-only → { ok, value: null } (clear)
|
|
18
|
-
*
|
|
18
|
+
* same-origin pool path /avatars/<name>.<ext> → { ok, value: trimmed }
|
|
19
|
+
* valid absolute http(s) URL → { ok, value: normalized }
|
|
19
20
|
* anything else → { ok: false, reason }
|
|
21
|
+
*
|
|
22
|
+
* Relative branch rationale (通信龙 裁定, avatar 接线单): the hub is
|
|
23
|
+
* reached through several origins (localhost dev, public domains) — an
|
|
24
|
+
* absolute URL would weld ONE hostname into the DB and break the image
|
|
25
|
+
* for every other entry point; a same-origin relative path is portable.
|
|
26
|
+
* 🔴 Trap this branch must block: "starts with /" ≠ "same-origin" —
|
|
27
|
+
* "//evil.com/x.png" ALSO starts with "/" but is a protocol-relative URL
|
|
28
|
+
* the browser resolves to evil.com. Hence: single leading slash with the
|
|
29
|
+
* SECOND char not "/", then an exact value-set match (the /avatars/ pool
|
|
30
|
+
* prefix + extension whitelist), not a shape match.
|
|
20
31
|
*/
|
|
32
|
+
|
|
33
|
+
// Exact allowed set for same-origin values: the dashboard's designed pool
|
|
34
|
+
// under /avatars/. Filename charset excludes "/" (no traversal, no nested
|
|
35
|
+
// paths) and "%" (no encoded surprises); extensions mirror the actual
|
|
36
|
+
// pool contents. Widen ONLY by extending this list deliberately.
|
|
37
|
+
const SAME_ORIGIN_AVATAR_RE = /^\/avatars\/[A-Za-z0-9._-]+\.(webp|png|svg)$/;
|
|
21
38
|
export function validateAvatarUrl(raw: unknown): AvatarValidation {
|
|
22
39
|
if (raw === null || raw === undefined) return { ok: true, value: null };
|
|
23
40
|
if (typeof raw !== "string") {
|
|
@@ -35,6 +52,23 @@ export function validateAvatarUrl(raw: unknown): AvatarValidation {
|
|
|
35
52
|
if (/[\u0000-\u001f\u007f-\u009f\s]/.test(trimmed)) {
|
|
36
53
|
return { ok: false, reason: "avatar_url must not contain whitespace or control characters" };
|
|
37
54
|
}
|
|
55
|
+
// Same-origin relative branch (runs AFTER the whitespace/control gate
|
|
56
|
+
// above — that check protects this branch too). Order of the two tests
|
|
57
|
+
// matters for the reason string, not for safety: the exact-set regex
|
|
58
|
+
// alone already rejects "//…" (second char is "/", first segment must
|
|
59
|
+
// literally be "avatars"), the explicit double-slash check just names
|
|
60
|
+
// the classic bypass in its own words.
|
|
61
|
+
if (trimmed.startsWith("/")) {
|
|
62
|
+
if (trimmed.startsWith("//")) {
|
|
63
|
+
return { ok: false, reason: "avatar_url must not be protocol-relative (//host/…)" };
|
|
64
|
+
}
|
|
65
|
+
if (!SAME_ORIGIN_AVATAR_RE.test(trimmed)) {
|
|
66
|
+
return { ok: false, reason: "relative avatar_url must match /avatars/<name>.(webp|png|svg)" };
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, value: trimmed };
|
|
69
|
+
}
|
|
70
|
+
// Absolute http(s) branch — unchanged from #462 (do not touch while
|
|
71
|
+
// relaxing: 通信龙 裁定 condition 3).
|
|
38
72
|
let parsed: URL;
|
|
39
73
|
try {
|
|
40
74
|
parsed = new URL(trimmed);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Task #25 — regression pins for ext validation in validateIndexEntry.
|
|
2
|
+
//
|
|
3
|
+
// 🔴 SCOPE (lead d505179d): this suite covers ONLY the ext regex copy
|
|
4
|
+
// inside `validateIndexEntry` (uploads.ts). The same-shape regex has
|
|
5
|
+
// FOUR copies in this file:
|
|
6
|
+
// • L93 sanitizeExt /\.([A-Za-z0-9]{1,16})$/ (client-name parse; no leading-dot anchor, captures group)
|
|
7
|
+
// • L141 buildStoragePath /^\.[A-Za-z0-9]{1,16}$/ (upload-path build)
|
|
8
|
+
// • L165 pathForExistingBlob /^\.[A-Za-z0-9]{1,16}$/ (download-path build)
|
|
9
|
+
// • L219 validateIndexEntry /^\.[A-Za-z0-9]{1,16}$/ ← THIS SUITE
|
|
10
|
+
// Mutation reversal proved the other three copies get ZERO coverage
|
|
11
|
+
// from this suite (each was widened to `/./` in turn — this suite still
|
|
12
|
+
// passed 30/0). See docs/tests/p-25-ext-regression/mutation-red.txt.
|
|
13
|
+
// Do NOT read the file name "ext validation regression" as "the ext
|
|
14
|
+
// validation is regression-protected" — the ext validation exists in
|
|
15
|
+
// four independent copies and only ONE is pinned here.
|
|
16
|
+
// Follow-up issue tracks the dedup/alignment evaluation (see PR body).
|
|
17
|
+
//
|
|
18
|
+
// Background (通信龙 d01bb8ce): the current regex `/^\.[A-Za-z0-9]{1,16}$/`
|
|
19
|
+
// in validateIndexEntry (uploads.ts) correctly rejects 11 known malicious
|
|
20
|
+
// ext values (path-escape, traversal, absolute paths, doubles, NUL bytes,
|
|
21
|
+
// backslash forms, over-length, whitespace). Verified via real-run on
|
|
22
|
+
// origin/main by lead — 11/11 malicious values → false.
|
|
23
|
+
//
|
|
24
|
+
// But NO test locked that behavior. Anyone who widens the regex in the
|
|
25
|
+
// future (accidentally or on purpose) would not hit a red test — the
|
|
26
|
+
// live protection would degrade silently. This suite pins current
|
|
27
|
+
// behavior in two directions:
|
|
28
|
+
//
|
|
29
|
+
// 1. SAFETY (欠修 direction) — 11 malicious values → false, one row
|
|
30
|
+
// per value with the exact literal. If regex is widened to accept
|
|
31
|
+
// any of them, that row turns red.
|
|
32
|
+
// 2. NON-REGRESSION (过修 direction) — a set of common legitimate
|
|
33
|
+
// extensions → true. If regex is tightened to reject any of them
|
|
34
|
+
// (e.g. someone locks down to a whitelist), that row turns red.
|
|
35
|
+
//
|
|
36
|
+
// Witnessed-red for the SAFETY rows: temporarily widen the regex to
|
|
37
|
+
// `/./` (or comment it out) in validateIndexEntry, run this suite,
|
|
38
|
+
// confirm all 11 malicious rows turn red, then revert. The mutation
|
|
39
|
+
// itself is not committed; the assertion IS the mutation-red proof
|
|
40
|
+
// for the reader. Recording the mutation output in the followup PR
|
|
41
|
+
// body (per Constraint 3 discipline established in #503).
|
|
42
|
+
//
|
|
43
|
+
// Pure unit test — no server setup, no DB, no filesystem. The regex
|
|
44
|
+
// lives in a pure function, so the guard is testable at that level.
|
|
45
|
+
|
|
46
|
+
import { describe, expect, test } from "bun:test";
|
|
47
|
+
import { validateIndexEntry } from "./uploads.js";
|
|
48
|
+
|
|
49
|
+
// A structurally-valid base entry — file_id / date_bucket / size all
|
|
50
|
+
// pass their own checks — so `ext` is the only field a test row varies.
|
|
51
|
+
// This isolates the ext regex as the deciding factor.
|
|
52
|
+
const validBase = {
|
|
53
|
+
file_id: "0123456789abcdef0123456789abcdef",
|
|
54
|
+
date_bucket: "2026-07-30",
|
|
55
|
+
size: 0,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
describe("Task #25 (#503 followup) — ext validation regression pins", () => {
|
|
59
|
+
describe("SAFETY: the 11 malicious ext values from lead d01bb8ce all return false", () => {
|
|
60
|
+
// Each row uses the exact literal from lead's verification list so
|
|
61
|
+
// a widening regex catches on the same value the manual audit did.
|
|
62
|
+
const malicious: Array<{ label: string; ext: string }> = [
|
|
63
|
+
{ label: "path-escape prefix", ext: "../x" },
|
|
64
|
+
{ label: "deep traversal to /etc", ext: "../../etc/passwd" },
|
|
65
|
+
{ label: "absolute path", ext: "/abs/x" },
|
|
66
|
+
{ label: "double-dot alone", ext: ".." },
|
|
67
|
+
{ label: "single-dot alone", ext: "." },
|
|
68
|
+
{ label: "chained after legit prefix", ext: ".png/../../y" },
|
|
69
|
+
{ label: "embedded whitespace", ext: ".p ng" },
|
|
70
|
+
{ label: "over-length (>16 chars)", ext: "." + "a".repeat(17) },
|
|
71
|
+
{ label: "embedded NUL byte", ext: ".p\x00ng" },
|
|
72
|
+
{ label: "backslash traversal", ext: ".\\..\\x" },
|
|
73
|
+
{ label: "double-backslash prefix", ext: "..\\x" },
|
|
74
|
+
];
|
|
75
|
+
for (const { label, ext } of malicious) {
|
|
76
|
+
test(`rejects: ${label} — ext=${JSON.stringify(ext)}`, () => {
|
|
77
|
+
expect(validateIndexEntry({ ...validBase, ext })).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
test("precondition: 11 malicious cases enumerated (defends against silent test-shrink)", () => {
|
|
82
|
+
// If someone deletes rows from the malicious list, this fails
|
|
83
|
+
// instead of the deletion silently narrowing the guard. Number
|
|
84
|
+
// matches lead's verification list — bump only when lead adds a
|
|
85
|
+
// case and confirms it via real-run.
|
|
86
|
+
expect(malicious.length).toBe(11);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("NON-REGRESSION: common legitimate extensions all return true (防过修)", () => {
|
|
91
|
+
// These are the extensions the live upload path actually produces
|
|
92
|
+
// via sanitizeExt / mime-typed multipart uploads. If someone
|
|
93
|
+
// tightens the regex to a stricter whitelist, any of these turning
|
|
94
|
+
// red flags a break in a normal user path.
|
|
95
|
+
const legal = [".png", ".pdf", ".pptx", ".jpg", ".jpeg", ".txt", ".mp4", ".zip", ".json", ".md"];
|
|
96
|
+
for (const ext of legal) {
|
|
97
|
+
test(`accepts: ext=${JSON.stringify(ext)}`, () => {
|
|
98
|
+
expect(validateIndexEntry({ ...validBase, ext })).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test("empty ext is accepted (file with no extension is a legitimate upload)", () => {
|
|
103
|
+
expect(validateIndexEntry({ ...validBase, ext: "" })).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("precondition: at least 10 legitimate ext values pinned", () => {
|
|
107
|
+
// Same discipline as the malicious side — reject silent shrink.
|
|
108
|
+
expect(legal.length).toBeGreaterThanOrEqual(10);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("regex shape assumptions (documented, not enforced by the function)", () => {
|
|
113
|
+
// These rows document facts the current regex enforces beyond the
|
|
114
|
+
// 11 malicious literals. They are close to the boundary and would
|
|
115
|
+
// be the first things to move if someone reworked the regex.
|
|
116
|
+
test("upper-case extensions accepted (regex is case-insensitive by [A-Za-z])", () => {
|
|
117
|
+
expect(validateIndexEntry({ ...validBase, ext: ".PNG" })).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
test("mixed-case extensions accepted", () => {
|
|
120
|
+
expect(validateIndexEntry({ ...validBase, ext: ".PdF" })).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
test("digits after dot accepted (e.g. .mp4, .mp3)", () => {
|
|
123
|
+
expect(validateIndexEntry({ ...validBase, ext: ".mp4" })).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
test("exactly 16 chars after dot is accepted (upper bound of {1,16})", () => {
|
|
126
|
+
expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(16) })).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
test("exactly 17 chars after dot is rejected (immediately past upper bound)", () => {
|
|
129
|
+
expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(17) })).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
test("compound extensions like .tar.gz are rejected (contains inner dot)", () => {
|
|
132
|
+
// Documented: the current regex does not support compound
|
|
133
|
+
// extensions. If we ever want .tar.gz support, it needs its
|
|
134
|
+
// own explicit design + this row updated with lead sign-off.
|
|
135
|
+
expect(validateIndexEntry({ ...validBase, ext: ".tar.gz" })).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -56,6 +56,16 @@ const rA = register(nameA, "BootstrapPw123Aa!", undefined, "userA");
|
|
|
56
56
|
if (!rA.ok) throw new Error(`userA register failed: ${rA.error}`);
|
|
57
57
|
const userAToken = rA.token!;
|
|
58
58
|
const userAUserId = rA.user!.user_id;
|
|
59
|
+
const userANetworkId = rA.network_id!;
|
|
60
|
+
|
|
61
|
+
// #503 — userA joins userB's network below, which makes userA a
|
|
62
|
+
// SAME-network peer. Cross-network denial needs a principal who is in
|
|
63
|
+
// neither, so userC exists purely to be an outsider.
|
|
64
|
+
const nameC = `userccc_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
65
|
+
const rC = register(nameC, "BootstrapPw123Aa!", undefined, "userC");
|
|
66
|
+
if (!rC.ok) throw new Error(`userC register failed: ${rC.error}`);
|
|
67
|
+
const userCToken = rC.token!;
|
|
68
|
+
const userCUserId = rC.user!.user_id;
|
|
59
69
|
|
|
60
70
|
const nameB = `userbbb_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
61
71
|
const rB = register(nameB, "BootstrapPw123Aa!", undefined, "userB");
|
|
@@ -132,10 +142,16 @@ afterAll(() => {
|
|
|
132
142
|
delete process.env.COMMHUB_AUTH_TOKEN;
|
|
133
143
|
});
|
|
134
144
|
|
|
135
|
-
|
|
145
|
+
// #503 — a utok_ holder in more than one network must name the target
|
|
146
|
+
// network; the server refuses to guess ("first network" is not assumed).
|
|
147
|
+
// Callers using a single-network token can still omit it.
|
|
148
|
+
async function uploadAs(token: string, content: Uint8Array, filename: string, networkId?: string): Promise<string> {
|
|
136
149
|
const form = new FormData();
|
|
137
150
|
form.append("file", new Blob([content], { type: "application/octet-stream" }), filename);
|
|
138
|
-
const
|
|
151
|
+
const url = networkId
|
|
152
|
+
? `${BASE}/api/upload?network_id=${encodeURIComponent(networkId)}`
|
|
153
|
+
: `${BASE}/api/upload`;
|
|
154
|
+
const res = await fetch(url, {
|
|
139
155
|
method: "POST",
|
|
140
156
|
body: form,
|
|
141
157
|
headers: { Authorization: `Bearer ${token}` },
|
|
@@ -173,27 +189,36 @@ describe.skipIf(!isProdMode)("#495 — GET /api/files/:file_id authorization (ow
|
|
|
173
189
|
expect(await res.text()).toBe("agent-blob");
|
|
174
190
|
});
|
|
175
191
|
|
|
176
|
-
test("🔴 non-owner (
|
|
177
|
-
|
|
192
|
+
test("🔴 cross-network non-owner (userC) downloads userB's file → 404 (was 200 pre-#495)", async () => {
|
|
193
|
+
// Precondition — userC is in NEITHER of userB's networks, so the 404
|
|
194
|
+
// is cross-network denial. This row used to use userA, but #503 made
|
|
195
|
+
// userA a legitimate same-network reader; keeping userA here would
|
|
196
|
+
// have turned #495's guarantee into a test that asserts nothing.
|
|
197
|
+
const rows = db.all<{ network_id: string }>(
|
|
198
|
+
"SELECT network_id FROM network_members WHERE user_id = ?1 AND network_id = ?2",
|
|
199
|
+
userCUserId, userBNetworkId,
|
|
200
|
+
);
|
|
201
|
+
expect(rows.length).toBe(0);
|
|
202
|
+
const res = await downloadAs(userCToken, userBFileId);
|
|
178
203
|
expect(res.status).toBe(404);
|
|
179
204
|
const body: any = await res.json();
|
|
180
205
|
expect(body.error).toBe("not_found");
|
|
181
206
|
});
|
|
182
207
|
|
|
183
|
-
test("🔴 same-network non-owner (userA is a member of userB's network) →
|
|
184
|
-
// Precondition — userA really is a member of userB's default
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
// not "cross-network denied".
|
|
208
|
+
test("🔴 same-network non-owner (userA is a member of userB's network) → 200 (#503 lifted the #495 staged carve-out)", async () => {
|
|
209
|
+
// Precondition — userA really is a member of userB's default network
|
|
210
|
+
// (added in module setup), so the 200 represents "same-network peer
|
|
211
|
+
// allowed" rather than an owner match or an admin bypass.
|
|
188
212
|
const rows = db.all<{ network_id: string }>(
|
|
189
213
|
"SELECT network_id FROM network_members WHERE user_id = ?1 AND network_id = ?2",
|
|
190
214
|
userAUserId, userBNetworkId,
|
|
191
215
|
);
|
|
192
216
|
expect(rows.length).toBe(1);
|
|
217
|
+
expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", userAUserId)?.role)
|
|
218
|
+
.not.toBe("admin");
|
|
193
219
|
const res = await downloadAs(userAToken, userBFileId);
|
|
194
|
-
expect(res.status).toBe(
|
|
195
|
-
|
|
196
|
-
expect(body.error).toBe("not_found");
|
|
220
|
+
expect(res.status).toBe(200);
|
|
221
|
+
expect(await res.text()).toBe("secret-from-B");
|
|
197
222
|
});
|
|
198
223
|
|
|
199
224
|
test("admin caller downloads any file → 200 (operational access preserved)", async () => {
|
|
@@ -247,8 +272,10 @@ describe.skipIf(!isProdMode)("#500 CR2 — HEAD /api/files/:file_id authorizatio
|
|
|
247
272
|
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
248
273
|
});
|
|
249
274
|
|
|
250
|
-
test("🔴 cross-
|
|
251
|
-
|
|
275
|
+
test("🔴 cross-network HEAD (userC HEAD userB's file) → 404 via helper (was fallback 200 pre-CR2)", async () => {
|
|
276
|
+
// userC, not userA — #503 makes userA a same-network reader, so the
|
|
277
|
+
// GET/HEAD parity claim needs a principal who is genuinely denied.
|
|
278
|
+
const res = await headAs(userCToken, userBFileId);
|
|
252
279
|
expect(res.status).toBe(404);
|
|
253
280
|
// 404 must originate from authorizeFileDownload, not the fallback
|
|
254
281
|
// page. Body is application/json ({"ok":false,"error":"not_found"})
|
|
@@ -284,7 +311,9 @@ describe.skipIf(!isProdMode)("#500 CR2 — HEAD /api/files/:file_id authorizatio
|
|
|
284
311
|
// in the authorizeFileDownload comment and this test enforces it.
|
|
285
312
|
describe.skipIf(!isProdMode)("#500 CR2 — upload persists truthy owner_id (D1)", () => {
|
|
286
313
|
test("D1: userA authenticated upload → index entry owner_id === userA.userId (never null)", async () => {
|
|
287
|
-
|
|
314
|
+
// userA belongs to two networks (own default + userB's), so #503
|
|
315
|
+
// requires the target network to be named explicitly.
|
|
316
|
+
const fileId = await uploadAs(userAToken, new TextEncoder().encode("owner-persistence-check"), "d1.bin", userANetworkId);
|
|
288
317
|
const { readFileSync } = await import("fs");
|
|
289
318
|
const entryPath = join(UPLOADS_DIR, ".index", `${fileId}.json`);
|
|
290
319
|
expect(existsSync(entryPath)).toBe(true);
|
|
@@ -314,13 +343,18 @@ describe.skipIf(!isProdMode)("#495 — null-owner policy in production (DEV_OPEN
|
|
|
314
343
|
// reproduces the shape a DEV_OPEN or legacy master upload would
|
|
315
344
|
// create, without needing to spin up a second server in a
|
|
316
345
|
// different mode.
|
|
317
|
-
nullOwnerFileId = await uploadAs(userAToken, new TextEncoder().encode("legacy-blob"), "legacy.txt");
|
|
346
|
+
nullOwnerFileId = await uploadAs(userAToken, new TextEncoder().encode("legacy-blob"), "legacy.txt", userANetworkId);
|
|
318
347
|
const { readFileSync: rfs, writeFileSync: wfs } = await import("fs");
|
|
319
348
|
const entryFile = join(UPLOADS_DIR, ".index", `${nullOwnerFileId}.json`);
|
|
320
349
|
if (!existsSync(entryFile)) throw new Error(`index entry not found: ${entryFile}`);
|
|
321
350
|
const entry = JSON.parse(rfs(entryFile, "utf-8"));
|
|
322
351
|
entry.owner = null;
|
|
323
352
|
entry.owner_id = null;
|
|
353
|
+
// #503 — a true legacy entry predates network attribution entirely.
|
|
354
|
+
// Deleting the key (rather than nulling it) matches what the writer
|
|
355
|
+
// emits when there is no attribution, so this fixture exercises the
|
|
356
|
+
// legacy compatibility branch instead of the network branch.
|
|
357
|
+
delete entry.network_id;
|
|
324
358
|
wfs(entryFile, JSON.stringify(entry, null, 2));
|
|
325
359
|
});
|
|
326
360
|
|