@sleep2agi/commhub-server 0.8.8 → 0.9.0-preview.1
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 +2 -2
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth.ts +92 -6
- package/src/config-apply-sec1.test.ts +561 -0
- package/src/config-apply-validate.test.ts +260 -0
- package/src/config-apply-validate.ts +171 -0
- package/src/cross-tenant-injection.test.ts +335 -0
- package/src/db-adapter.ts +2 -2
- package/src/db.ts +356 -7
- package/src/index.ts +61 -17
- package/src/push.test.ts +383 -2
- package/src/push.ts +291 -55
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +269 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/tools.ts +597 -64
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Round-5 F1+F2: cross-tenant write/inject guards.
|
|
2
|
+
//
|
|
3
|
+
// All three guards are exercised against a real in-process SQLite
|
|
4
|
+
// (COMMHUB_DB=/tmp/...) so we're not just unit-testing the SQL string,
|
|
5
|
+
// we're proving the rejection actually happens against persisted rows.
|
|
6
|
+
//
|
|
7
|
+
// Setup: two networks (A, B). Create a task in network A with a known
|
|
8
|
+
// task_id. Then drive the three attack vectors below from a session
|
|
9
|
+
// belonging to network B and assert each is blocked.
|
|
10
|
+
//
|
|
11
|
+
// Run with:
|
|
12
|
+
// COMMHUB_DB=/tmp/xtenant-test.db bun test src/cross-tenant-injection.test.ts
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test, beforeEach } from "bun:test";
|
|
15
|
+
import { db, uuidv4, chainReplyToParent } from "./db.js";
|
|
16
|
+
|
|
17
|
+
function insertTask(opts: {
|
|
18
|
+
task_id?: string;
|
|
19
|
+
from_name: string;
|
|
20
|
+
to_name: string;
|
|
21
|
+
network_id: string | null;
|
|
22
|
+
status?: string;
|
|
23
|
+
parent_task_id?: string | null;
|
|
24
|
+
result?: string | null;
|
|
25
|
+
}): string {
|
|
26
|
+
const id = opts.task_id ?? uuidv4();
|
|
27
|
+
db.run(
|
|
28
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id, parent_task_id, result)
|
|
29
|
+
VALUES (?1, ?2, ?3, 'normal', ?4, 'test content', 'reply', datetime('now'), ?5, ?6, ?7)`,
|
|
30
|
+
[id, opts.from_name, opts.to_name, opts.status ?? "delivered", opts.network_id, opts.parent_task_id ?? null, opts.result ?? null]
|
|
31
|
+
);
|
|
32
|
+
return id;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getTask(taskId: string) {
|
|
36
|
+
return db.get<{ task_id: string; status: string; result: string | null; network_id: string | null }>(
|
|
37
|
+
"SELECT task_id, status, result, network_id FROM tasks WHERE task_id = ?1",
|
|
38
|
+
taskId
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function countInbox(toSession: string): number {
|
|
43
|
+
const row = db.get<{ cnt: number }>(
|
|
44
|
+
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1",
|
|
45
|
+
toSession
|
|
46
|
+
);
|
|
47
|
+
return row?.cnt ?? 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
// Clean slate per test so prior fixtures don't leak across runs.
|
|
52
|
+
db.run("DELETE FROM tasks");
|
|
53
|
+
db.run("DELETE FROM inbox");
|
|
54
|
+
db.run("DELETE FROM task_events");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
58
|
+
// F2 #3 — chainReplyToParent cross-network rejection
|
|
59
|
+
//
|
|
60
|
+
// The unit-level test of the most direct vector: caller in network B
|
|
61
|
+
// holds a child task whose `parent_task_id` points at a parent in
|
|
62
|
+
// network A. Without the fix, chainReplyToParent walks the link and
|
|
63
|
+
// writes `result` + an inbox notification into network A. With the
|
|
64
|
+
// fix, the function refuses to traverse and writes nothing.
|
|
65
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
68
|
+
// Round-5 follow-up — chainReplyToParent return value gates SSE push
|
|
69
|
+
//
|
|
70
|
+
// 通信牛 catch on #275: the DB write is refused on cross-network, but
|
|
71
|
+
// the post-call code in tools.ts was still running:
|
|
72
|
+
//
|
|
73
|
+
// SELECT parent_task_id, from_name FROM tasks WHERE task_id = ?
|
|
74
|
+
// SELECT from_name, task_id ... WHERE task_id = ?
|
|
75
|
+
// pushEvent(parent.from_name, { parent_task_id: <netA-id>, ... },
|
|
76
|
+
// effectiveNetId)
|
|
77
|
+
//
|
|
78
|
+
// That pushEvent leaks the foreign parent's task_id into the caller's
|
|
79
|
+
// network via SSE payload. The fix: chainReplyToParent now returns
|
|
80
|
+
// `{ chained, stoppedReason? }` so callers can skip the follow-up
|
|
81
|
+
// push when the chain didn't actually write. These tests pin the
|
|
82
|
+
// return contract directly. The SSE-gate is exercised at the call
|
|
83
|
+
// sites in tools.ts; pinning the contract here ensures the call-site
|
|
84
|
+
// behaviour can't drift back if someone refactors db.ts.
|
|
85
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe("chainReplyToParent — return value gates SSE push (round5 follow-up)", () => {
|
|
88
|
+
test("cross-network refusal returns { chained: false, stoppedReason: 'cross_network' }", () => {
|
|
89
|
+
const parentA = insertTask({
|
|
90
|
+
from_name: "admin-a",
|
|
91
|
+
to_name: "worker-a",
|
|
92
|
+
network_id: "net-A",
|
|
93
|
+
status: "delivered",
|
|
94
|
+
});
|
|
95
|
+
const childB = insertTask({
|
|
96
|
+
from_name: "attacker-b",
|
|
97
|
+
to_name: "victim-b",
|
|
98
|
+
network_id: "net-B",
|
|
99
|
+
status: "replied",
|
|
100
|
+
parent_task_id: parentA,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const result = chainReplyToParent(childB, "payload", "replied", 5, "net-B");
|
|
104
|
+
|
|
105
|
+
expect(result.chained).toBe(false);
|
|
106
|
+
expect(result.stoppedReason).toBe("cross_network");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("legitimate same-network chain returns { chained: true } (no stoppedReason)", () => {
|
|
110
|
+
const parentB = insertTask({
|
|
111
|
+
from_name: "admin-b",
|
|
112
|
+
to_name: "worker-b",
|
|
113
|
+
network_id: "net-B",
|
|
114
|
+
status: "delivered",
|
|
115
|
+
});
|
|
116
|
+
const childB = insertTask({
|
|
117
|
+
from_name: "child-b",
|
|
118
|
+
to_name: "leaf-b",
|
|
119
|
+
network_id: "net-B",
|
|
120
|
+
status: "replied",
|
|
121
|
+
parent_task_id: parentB,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const result = chainReplyToParent(childB, "ok", "replied", 5, "net-B");
|
|
125
|
+
|
|
126
|
+
expect(result.chained).toBe(true);
|
|
127
|
+
expect(result.stoppedReason).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("child with no parent returns { chained: false } (no stoppedReason)", () => {
|
|
131
|
+
const orphan = insertTask({
|
|
132
|
+
from_name: "x",
|
|
133
|
+
to_name: "y",
|
|
134
|
+
network_id: "net-B",
|
|
135
|
+
status: "replied",
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const result = chainReplyToParent(orphan, "ok", "replied", 5, "net-B");
|
|
139
|
+
|
|
140
|
+
expect(result.chained).toBe(false);
|
|
141
|
+
expect(result.stoppedReason).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("3-level chain: same-net parent writes, foreign grandparent halts → chained=true, stoppedReason='cross_network'", () => {
|
|
145
|
+
const grandparent = insertTask({
|
|
146
|
+
from_name: "gp-admin",
|
|
147
|
+
to_name: "gp-worker",
|
|
148
|
+
network_id: "net-A",
|
|
149
|
+
status: "delivered",
|
|
150
|
+
});
|
|
151
|
+
const parent = insertTask({
|
|
152
|
+
from_name: "p-admin",
|
|
153
|
+
to_name: "p-worker",
|
|
154
|
+
network_id: "net-B",
|
|
155
|
+
status: "delivered",
|
|
156
|
+
parent_task_id: grandparent,
|
|
157
|
+
});
|
|
158
|
+
const child = insertTask({
|
|
159
|
+
from_name: "c",
|
|
160
|
+
to_name: "leaf",
|
|
161
|
+
network_id: "net-B",
|
|
162
|
+
status: "replied",
|
|
163
|
+
parent_task_id: parent,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const result = chainReplyToParent(child, "ok", "replied", 5, "net-B");
|
|
167
|
+
|
|
168
|
+
// We DID write into the same-net parent before the chain halted
|
|
169
|
+
// at the foreign grandparent. `chained: true` lets callers push
|
|
170
|
+
// SSE for the legitimate parent; `stoppedReason` makes it visible
|
|
171
|
+
// in logs/metrics that the chain didn't run to completion.
|
|
172
|
+
expect(result.chained).toBe(true);
|
|
173
|
+
expect(result.stoppedReason).toBe("cross_network");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("chainReplyToParent — cross-network rejection (round5 F2 #3)", () => {
|
|
178
|
+
test("blocks chain when parent.network_id != callerNetId", () => {
|
|
179
|
+
// Network A: a task that should NEVER receive a foreign reply.
|
|
180
|
+
const parentA = insertTask({
|
|
181
|
+
from_name: "admin-a",
|
|
182
|
+
to_name: "worker-a",
|
|
183
|
+
network_id: "net-A",
|
|
184
|
+
status: "delivered",
|
|
185
|
+
});
|
|
186
|
+
// Network B: a child task that maliciously names parentA as its
|
|
187
|
+
// parent (the attack — possible because send_task wrote a row,
|
|
188
|
+
// and B controls the parent_task_id field of its own dispatch
|
|
189
|
+
// when the F2 #2 guard isn't in place either).
|
|
190
|
+
const childB = insertTask({
|
|
191
|
+
from_name: "attacker-b",
|
|
192
|
+
to_name: "victim-b",
|
|
193
|
+
network_id: "net-B",
|
|
194
|
+
status: "replied",
|
|
195
|
+
parent_task_id: parentA,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Caller is in network B (effectiveNetId = "net-B"). The chain
|
|
199
|
+
// walks child → parentA, finds parent.network_id="net-A" !=
|
|
200
|
+
// caller="net-B", refuses to write.
|
|
201
|
+
chainReplyToParent(childB, "INJECTED PAYLOAD", "replied", 5, "net-B");
|
|
202
|
+
|
|
203
|
+
const after = getTask(parentA);
|
|
204
|
+
expect(after?.result).toBeNull(); // result NOT polluted
|
|
205
|
+
expect(after?.status).toBe("delivered"); // status NOT bumped
|
|
206
|
+
expect(countInbox("admin-a")).toBe(0); // no rogue inbox row
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("legitimate same-network chain still works", () => {
|
|
210
|
+
const parentB = insertTask({
|
|
211
|
+
from_name: "admin-b",
|
|
212
|
+
to_name: "worker-b",
|
|
213
|
+
network_id: "net-B",
|
|
214
|
+
status: "delivered",
|
|
215
|
+
});
|
|
216
|
+
const childB = insertTask({
|
|
217
|
+
from_name: "child-b",
|
|
218
|
+
to_name: "leaf-b",
|
|
219
|
+
network_id: "net-B",
|
|
220
|
+
status: "replied",
|
|
221
|
+
parent_task_id: parentB,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
chainReplyToParent(childB, "legitimate result", "replied", 5, "net-B");
|
|
225
|
+
|
|
226
|
+
const after = getTask(parentB);
|
|
227
|
+
expect(after?.result).toContain("legitimate result");
|
|
228
|
+
expect(after?.status).toBe("replied");
|
|
229
|
+
expect(countInbox("admin-b")).toBe(1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("default-network (null) caller cannot chain into named network", () => {
|
|
233
|
+
const parentNamed = insertTask({
|
|
234
|
+
from_name: "admin-x",
|
|
235
|
+
to_name: "worker-x",
|
|
236
|
+
network_id: "net-X",
|
|
237
|
+
status: "delivered",
|
|
238
|
+
});
|
|
239
|
+
const childDefault = insertTask({
|
|
240
|
+
from_name: "stray",
|
|
241
|
+
to_name: "leaf",
|
|
242
|
+
network_id: null,
|
|
243
|
+
status: "replied",
|
|
244
|
+
parent_task_id: parentNamed,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
chainReplyToParent(childDefault, "stray payload", "replied", 5, null);
|
|
248
|
+
|
|
249
|
+
expect(getTask(parentNamed)?.result).toBeNull();
|
|
250
|
+
expect(countInbox("admin-x")).toBe(0);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("named-network caller cannot chain into default-network parent", () => {
|
|
254
|
+
const parentDefault = insertTask({
|
|
255
|
+
from_name: "admin-default",
|
|
256
|
+
to_name: "worker-default",
|
|
257
|
+
network_id: null,
|
|
258
|
+
status: "delivered",
|
|
259
|
+
});
|
|
260
|
+
const childNamed = insertTask({
|
|
261
|
+
from_name: "named",
|
|
262
|
+
to_name: "leaf",
|
|
263
|
+
network_id: "net-Y",
|
|
264
|
+
status: "replied",
|
|
265
|
+
parent_task_id: parentDefault,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
chainReplyToParent(childNamed, "named payload", "replied", 5, "net-Y");
|
|
269
|
+
|
|
270
|
+
expect(getTask(parentDefault)?.result).toBeNull();
|
|
271
|
+
expect(countInbox("admin-default")).toBe(0);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("recursive chain stops at the first cross-network boundary", () => {
|
|
275
|
+
// 3-level chain: grandparent (net-A) ← parent (net-B) ← child (net-B)
|
|
276
|
+
// Caller B replies. F2 #3 should write into parent (same net) but
|
|
277
|
+
// refuse to recurse into grandparent (foreign net).
|
|
278
|
+
const grandparent = insertTask({
|
|
279
|
+
from_name: "gp-admin",
|
|
280
|
+
to_name: "gp-worker",
|
|
281
|
+
network_id: "net-A",
|
|
282
|
+
status: "delivered",
|
|
283
|
+
});
|
|
284
|
+
const parent = insertTask({
|
|
285
|
+
from_name: "p-admin",
|
|
286
|
+
to_name: "p-worker",
|
|
287
|
+
network_id: "net-B",
|
|
288
|
+
status: "delivered",
|
|
289
|
+
parent_task_id: grandparent,
|
|
290
|
+
});
|
|
291
|
+
const child = insertTask({
|
|
292
|
+
from_name: "c",
|
|
293
|
+
to_name: "leaf",
|
|
294
|
+
network_id: "net-B",
|
|
295
|
+
status: "replied",
|
|
296
|
+
parent_task_id: parent,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
chainReplyToParent(child, "result", "replied", 5, "net-B");
|
|
300
|
+
|
|
301
|
+
// Parent (same network) — chain wrote.
|
|
302
|
+
expect(getTask(parent)?.result).toContain("result");
|
|
303
|
+
expect(getTask(parent)?.status).toBe("replied");
|
|
304
|
+
// Grandparent (foreign network) — chain refused.
|
|
305
|
+
expect(getTask(grandparent)?.result).toBeNull();
|
|
306
|
+
expect(getTask(grandparent)?.status).toBe("delivered");
|
|
307
|
+
expect(countInbox("gp-admin")).toBe(0);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("undefined callerNetId preserves legacy network-blind behaviour", () => {
|
|
311
|
+
// Internal/server-only callers that have no tenant context can
|
|
312
|
+
// still call chainReplyToParent without the enforcement (they
|
|
313
|
+
// pass undefined). This is the back-compat escape hatch.
|
|
314
|
+
const parent = insertTask({
|
|
315
|
+
from_name: "internal-admin",
|
|
316
|
+
to_name: "internal-worker",
|
|
317
|
+
network_id: "net-A",
|
|
318
|
+
status: "delivered",
|
|
319
|
+
});
|
|
320
|
+
const child = insertTask({
|
|
321
|
+
from_name: "child",
|
|
322
|
+
to_name: "leaf",
|
|
323
|
+
network_id: "net-B",
|
|
324
|
+
status: "replied",
|
|
325
|
+
parent_task_id: parent,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
chainReplyToParent(child, "legacy result", "replied"); // no callerNetId
|
|
329
|
+
|
|
330
|
+
// Legacy behaviour: writes through regardless of network mismatch.
|
|
331
|
+
// Documents the back-compat surface so a future tightening is a
|
|
332
|
+
// deliberate decision.
|
|
333
|
+
expect(getTask(parent)?.result).toContain("legacy result");
|
|
334
|
+
});
|
|
335
|
+
});
|
package/src/db-adapter.ts
CHANGED
|
@@ -233,8 +233,8 @@ export class PgAdapter implements DbAdapter {
|
|
|
233
233
|
*
|
|
234
234
|
* Operators who run `bun -e` snippets that touch the DB are expected to
|
|
235
235
|
* `COMMHUB_DB=/tmp/...` themselves — `bun -e` doesn't set NODE_ENV, so the
|
|
236
|
-
* guard can't catch that case. The rule
|
|
237
|
-
*
|
|
236
|
+
* guard can't catch that case. The rule (never read or write the
|
|
237
|
+
* production hub database) is documented in CLAUDE.md.
|
|
238
238
|
*/
|
|
239
239
|
export function createAdapter(): DbAdapter {
|
|
240
240
|
const dbUrl = process.env.DATABASE_URL;
|