ghostbus 0.5.3 → 0.5.4
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/CHANGELOG.md +13 -0
- package/README.md +1 -1
- package/docs/hosted.md +27 -0
- package/package.json +1 -1
- package/src/core.mjs +56 -11
- package/src/hosted-server.mjs +25 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.4 — 2026-10-11
|
|
4
|
+
|
|
5
|
+
Security hardening, round 3 — hostile input: the values a malicious (or merely buggy) client controls, followed to the filesystem, the object graph, and state validation. Audit-first as usual: two of the five surfaces turned out to be already safe, and the entry says which and why rather than dressing them up as fixes.
|
|
6
|
+
|
|
7
|
+
- **Shared-file name `__proto__` corrupted the file store (fixed)** — file names are keys in one plain object, and `_putFile` blocked `..` but nothing else. Writing a file named `__proto__` invoked the prototype setter on the files map: the record became the map's *prototype*, the "file" enumerated nowhere, and the store was silently corrupt (verified against 0.5.3). `constructor` had the mirror-image problem on reads (an inherited member instead of "not found"). Now: `__proto__` / `constructor` / `prototype` are refused at write ("path is a reserved name"), treated as absent at read/delete (own-property checks), and refused inside snapshots. Names that merely look like paths (`/abs/looking.txt`, backslashes, a NUL byte) remain what they always were — inert state keys, never filesystem paths; the suite proves nothing but the one store file appears on disk.
|
|
8
|
+
- **Snapshot validation deepened from shape to usability** (`validateSnapshot` in `src/core.mjs`) — a snapshot is untrusted input (hosted import, CLI import, restore), and the old check waved through states that then crashed the bus on first use: `agents: [null]` passed and bricked every subsequent operation with a TypeError, a file with non-string `text` passed and broke search/sizing, negative seq counters passed. Now entries must be objects, agents need string names, files need string text, counters must be finite and non-negative, context must be an object or null, and counts have sanity caps far above anything the bus itself can produce (stored messages trim at 10k, events at 5k; caps are 10k agents/files, 100k messages/tasks/events). Compatibility is a hard constraint and is tested: real exports produced by the current code import cleanly (hosted round trip + core restore), as they did from v0.5.0–v0.5.3 shapes. Unknown *extra* top-level keys stay allowed — rejecting them would break forward compatibility for no safety gain; every known key is typed.
|
|
9
|
+
- **Workspace ids validated at every boundary (defense in depth)** — create and import already enforced `^[a-z0-9][a-z0-9-]{1,62}$` and the routes are regex-bound, so remote traversal was already refused; the thin spot was the registry *file*: `getWorkspace` trusted its keys blindly, and a planted or hand-edited entry like `../escape` made the admin list load (and the bus write) a state file **outside** the data dir. The registry is now filtered through the id rule at load (invalid entries are ignored, with a one-time stderr warning per id), and workspace resolution re-checks the id before it can become a path.
|
|
10
|
+
- **Channel named `__proto__` was dropped from channel counts (fixed)** — `_listChannels` accumulated into a plain `{}`; reads of the `__proto__` key hit the prototype and the count never landed. The accumulator is a null-prototype object now; the channel counts like any other.
|
|
11
|
+
- **Prototype pollution: audited, not reachable — now pinned by canaries.** State enters only through `JSON.parse`, the core's JSON round-trip clone, and object spread — all own-property semantics — and a full-codebase hunt found no recursive merge and no `Object.assign` of caller data (the remaining `Object.assign` uses build Error objects from literals). Canary checks now attempt pollution through the context writer and through restore payloads (nested `__proto__` in workspace and context) and assert `Object.prototype` stays clean. The board page was audited in the same pass: every interpolated value already goes through its `esc()` helper, and the workspace name renders via `textContent`.
|
|
12
|
+
- **Audit-log injection: audited, safe.** The v0.5.3 admin audit line is a single `JSON.stringify` of a fixed-shape record (timestamp, action literal, validated workspace id, socket IP); JSON encoding escapes any control characters, and no other user-controlled string (workspace names included) reaches the line.
|
|
13
|
+
- **Tests** — five new checks (59 in the main suite, was 54): reserved file names + inert path-like names (with an on-disk assertion), deep snapshot validation + pollution canaries, `__proto__` channel counting, hosted traversal refusal at create and at import (explicit id, envelope id, and state id) with a hostile-envelope refusal and a clean legit import, and the planted-registry escape. All five fail against the 0.5.3 code (54 passed, 5 failed — each for its intended reason; in the hosted check, the id-traversal asserts already passed on 0.5.3 and the red assertion is the hostile envelope importing with 201) and pass with the change; serverless stays 6/6 and both demos green.
|
|
14
|
+
- **Scope boundary, honestly** — what did NOT change: no API changes; no behavior change for legitimate names, ids, or exports; the single relay's store path is still operator-supplied (argv/env) and out of the remote-input surface; the serverless pack's registry lives in a KV store where ids are opaque keys, not paths — its ids still enter only through the same validated creation, and the core fixes (reserved file names, deep snapshot validation where it applies, channel counts) flow to it through the shared core. The networked multi-writer store is still future work.
|
|
15
|
+
|
|
3
16
|
## 0.5.3 — 2026-10-11
|
|
4
17
|
|
|
5
18
|
Security hardening, round 2 — the layer under the v0.5.2 defaults. v0.5.2 made enforcement the default; this round removes the sharp edges an attacker could still lean on: non-timing-safe secret compares, unthrottled key guessing at the hosted layer, unbounded request bodies, framable HTML pages, world-readable data files, and internal error text leaking to clients.
|
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Built and in use: 29 MCP tools across presence, messages, tasks, a shared worksp
|
|
|
23
23
|
```bash
|
|
24
24
|
git clone https://github.com/littlestjames82-sys/ghostbus.git ghostbus && cd ghostbus
|
|
25
25
|
node examples/two-agent-demo.mjs # watch a planner and a builder cooperate
|
|
26
|
-
node test.mjs #
|
|
26
|
+
node test.mjs # 59 checks: core, tokens, capsules, dependencies, sync, persistence, concurrency, MCP stdio, HTTP, SSE, CLI, hosted security, hostile input
|
|
27
27
|
node examples/three-agent-team.mjs # planner + builder + reviewer, no human relaying
|
|
28
28
|
```
|
|
29
29
|
|
package/docs/hosted.md
CHANGED
|
@@ -138,6 +138,33 @@ Round 2 (v0.5.3) closed the next layer:
|
|
|
138
138
|
- **Reporting a vulnerability:** see `SECURITY.md` at the repo root
|
|
139
139
|
(private email reporting, one-business-day acknowledgement).
|
|
140
140
|
|
|
141
|
+
Round 3 (v0.5.4) hardened the input surface — values a hostile or buggy
|
|
142
|
+
client controls, followed to the filesystem, the object graph, and state
|
|
143
|
+
validation:
|
|
144
|
+
|
|
145
|
+
- **Workspace ids are validated at every boundary.** Create and import
|
|
146
|
+
require `^[a-z0-9][a-z0-9-]{1,62}$`, the workspace routes only match that
|
|
147
|
+
shape, workspace resolution re-checks it, and the registry file itself is
|
|
148
|
+
filtered through the same rule when loaded — a planted or hand-edited
|
|
149
|
+
registry entry can never turn an id into a path outside the data dir.
|
|
150
|
+
- **Shared-file names are state keys, never paths.** A name that merely
|
|
151
|
+
looks like a path is stored and returned as an inert key. The three names
|
|
152
|
+
that are special to a JavaScript object — `__proto__`, `constructor`,
|
|
153
|
+
`prototype` — are refused at write, treated as absent at read, and refused
|
|
154
|
+
inside snapshots (before v0.5.4, writing a file named `__proto__`
|
|
155
|
+
silently corrupted the workspace's file map).
|
|
156
|
+
- **Snapshots and imports are deeply validated.** Collection entries must
|
|
157
|
+
be usable objects (agents need string names, files need string text),
|
|
158
|
+
counters must be finite and non-negative, and counts carry sanity caps
|
|
159
|
+
far above anything the bus itself produces. Every export made by
|
|
160
|
+
v0.5.0–v0.5.3 still imports — compatibility is covered by tests, not
|
|
161
|
+
assumed.
|
|
162
|
+
- **Prototype pollution is not reachable.** State enters only through
|
|
163
|
+
`JSON.parse`, JSON round-trip cloning, and spread (all own-property
|
|
164
|
+
semantics); there is no recursive merge of caller data anywhere in the
|
|
165
|
+
codebase, and the suite carries canary checks that try anyway through
|
|
166
|
+
the context writer and restore payloads.
|
|
167
|
+
|
|
141
168
|
## Option B — serverless on Netlify (Blobs)
|
|
142
169
|
|
|
143
170
|
`deploy/netlify/` contains a Netlify Function implementing the same multi-workspace semantics over Netlify Blobs (strong consistency):
|
package/package.json
CHANGED
package/src/core.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import path from 'node:path';
|
|
|
14
14
|
import crypto from 'node:crypto';
|
|
15
15
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
16
16
|
|
|
17
|
-
export const GHOSTBUS_VERSION = '0.5.
|
|
17
|
+
export const GHOSTBUS_VERSION = '0.5.4';
|
|
18
18
|
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
|
19
19
|
// Timing-safe equality for hex digests (v0.5.3): secret comparisons must not
|
|
20
20
|
// short-circuit on the first differing character the way === does.
|
|
@@ -24,6 +24,15 @@ const hashEqHex = (aHex, bHex) => {
|
|
|
24
24
|
};
|
|
25
25
|
export const TASK_STATUSES = ['queued', 'claimed', 'needs-approval', 'done', 'cancelled'];
|
|
26
26
|
const CLAIM_LEASE_MS = 15 * 60 * 1000;
|
|
27
|
+
// Shared-file names are keys in one plain object, never filesystem paths —
|
|
28
|
+
// but three names are special to the object itself (v0.5.4): assigning
|
|
29
|
+
// files['__proto__'] invokes the prototype setter and silently replaces the
|
|
30
|
+
// map's prototype (the "file" then enumerates nowhere and the store is
|
|
31
|
+
// corrupted), while reading files['constructor'] / files['__proto__'] on a
|
|
32
|
+
// fresh map returns inherited members instead of "not found". Writes refuse
|
|
33
|
+
// the three outright; reads treat them as absent; snapshots may not carry
|
|
34
|
+
// them either (validateSnapshot below).
|
|
35
|
+
const RESERVED_FILE_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
|
|
27
36
|
|
|
28
37
|
const now = () => new Date().toISOString();
|
|
29
38
|
const clone = (v) => JSON.parse(JSON.stringify(v));
|
|
@@ -392,6 +401,7 @@ export class GhostBus {
|
|
|
392
401
|
this._requireAgent(by, token);
|
|
393
402
|
if (!p || typeof text !== 'string') throw new Error('path and text required');
|
|
394
403
|
if (String(p).includes('..')) throw new Error('path may not contain ..');
|
|
404
|
+
if (RESERVED_FILE_NAMES.has(p)) throw new Error('path is a reserved name');
|
|
395
405
|
const prev = this.state.files[p];
|
|
396
406
|
const history = prev ? [...(prev.history || []), { text: prev.text, updatedBy: prev.updatedBy, updatedAt: prev.updatedAt }].slice(-10) : [];
|
|
397
407
|
this.state.files[p] = { text, updatedBy: by, updatedAt: now(), history };
|
|
@@ -403,7 +413,7 @@ export class GhostBus {
|
|
|
403
413
|
async getFile(...args) { return this._serialized(() => this._getFile(...args)); }
|
|
404
414
|
async _getFile(p, { withHistory = false } = {}) {
|
|
405
415
|
await this.init();
|
|
406
|
-
const f = this.state.files[p];
|
|
416
|
+
const f = Object.prototype.hasOwnProperty.call(this.state.files, p) ? this.state.files[p] : undefined;
|
|
407
417
|
if (!f) throw new Error(`file not found: ${p}`);
|
|
408
418
|
const { history, ...rest } = f;
|
|
409
419
|
return { path: p, ...clone(rest), versions: (history || []).length + 1, ...(withHistory ? { history: clone(history || []) } : {}) };
|
|
@@ -412,7 +422,7 @@ export class GhostBus {
|
|
|
412
422
|
async fileHistory(...args) { return this._serialized(() => this._fileHistory(...args)); }
|
|
413
423
|
async _fileHistory(p) {
|
|
414
424
|
await this.init();
|
|
415
|
-
const f = this.state.files[p];
|
|
425
|
+
const f = Object.prototype.hasOwnProperty.call(this.state.files, p) ? this.state.files[p] : undefined;
|
|
416
426
|
if (!f) throw new Error(`file not found: ${p}`);
|
|
417
427
|
return { path: p, current: { text: f.text, updatedBy: f.updatedBy, updatedAt: f.updatedAt }, history: clone(f.history || []) };
|
|
418
428
|
}
|
|
@@ -461,7 +471,7 @@ export class GhostBus {
|
|
|
461
471
|
async _deleteFile({ by, path: p, token = null }) {
|
|
462
472
|
await this.init();
|
|
463
473
|
this._requireAgent(by, token);
|
|
464
|
-
if (!this.state.files
|
|
474
|
+
if (!Object.prototype.hasOwnProperty.call(this.state.files, p)) throw new Error(`file not found: ${p}`);
|
|
465
475
|
delete this.state.files[p];
|
|
466
476
|
this._event('file.delete', by, `${by} deleted ${p}`);
|
|
467
477
|
await this._save();
|
|
@@ -471,7 +481,9 @@ export class GhostBus {
|
|
|
471
481
|
async listChannels(...args) { return this._serialized(() => this._listChannels(...args)); }
|
|
472
482
|
async _listChannels() {
|
|
473
483
|
await this.init();
|
|
474
|
-
|
|
484
|
+
// Channel names are attacker-chosen strings; a null-prototype map counts
|
|
485
|
+
// '__proto__' like any other channel instead of dropping it (v0.5.4).
|
|
486
|
+
const counts = Object.create(null);
|
|
475
487
|
for (const m of this.state.messages) counts[m.channel] = (counts[m.channel] || 0) + 1;
|
|
476
488
|
return Object.entries(counts).map(([channel, messages]) => ({ channel, messages })).sort((a, b) => b.messages - a.messages);
|
|
477
489
|
}
|
|
@@ -638,13 +650,46 @@ export class GhostBus {
|
|
|
638
650
|
}
|
|
639
651
|
|
|
640
652
|
/** Shape-check a candidate workspace snapshot. Returns null when valid,
|
|
641
|
-
* otherwise a short description of the first problem found.
|
|
653
|
+
* otherwise a short description of the first problem found.
|
|
654
|
+
*
|
|
655
|
+
* v0.5.4 deepened this from "the collections exist" to "the collections are
|
|
656
|
+
* usable": a snapshot is untrusted input (hosted import, CLI import,
|
|
657
|
+
* restore), and the shallow check waved through states that then crashed
|
|
658
|
+
* the bus on first use — agents:[null] (agent lookups destructure entries),
|
|
659
|
+
* a file whose text is not a string (search/sizing throw), a negative seq
|
|
660
|
+
* counter, a '__proto__' file name (see RESERVED_FILE_NAMES). Entries must
|
|
661
|
+
* be objects, agents need string names (they are lookup keys), files need
|
|
662
|
+
* string text, counters must be finite and non-negative, and counts get
|
|
663
|
+
* sanity caps set far above anything the bus itself can produce (stored
|
|
664
|
+
* messages trim at 10k, events at 5k), so every export made by v0.5.0–0.5.3
|
|
665
|
+
* still validates. Unknown EXTRA top-level keys stay allowed on purpose:
|
|
666
|
+
* rejecting them would break forward compatibility with newer exports for
|
|
667
|
+
* no safety gain — the known keys are all typed here. */
|
|
668
|
+
const SNAPSHOT_CAPS = { agents: 10_000, messages: 100_000, tasks: 100_000, events: 100_000, files: 10_000 };
|
|
669
|
+
const isStateObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
642
670
|
export function validateSnapshot(s) {
|
|
643
|
-
if (!
|
|
644
|
-
if (!s.workspace
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
671
|
+
if (!isStateObject(s)) return 'not an object';
|
|
672
|
+
if (!isStateObject(s.workspace)) return 'missing workspace';
|
|
673
|
+
if (s.workspace.id !== undefined && typeof s.workspace.id !== 'string') return 'workspace.id must be a string';
|
|
674
|
+
if (s.workspace.name !== undefined && typeof s.workspace.name !== 'string') return 'workspace.name must be a string';
|
|
675
|
+
for (const k of ['seq', 'msgSeq', 'taskSeq']) {
|
|
676
|
+
if (typeof s[k] !== 'number' || !Number.isFinite(s[k]) || s[k] < 0) return `missing numeric ${k}`;
|
|
677
|
+
}
|
|
678
|
+
for (const k of ['agents', 'messages', 'tasks', 'events']) {
|
|
679
|
+
if (!Array.isArray(s[k])) return `missing array ${k}`;
|
|
680
|
+
if (s[k].length > SNAPSHOT_CAPS[k]) return `${k} exceeds sanity cap ${SNAPSHOT_CAPS[k]}`;
|
|
681
|
+
for (const el of s[k]) if (!isStateObject(el)) return `${k} entries must be objects`;
|
|
682
|
+
}
|
|
683
|
+
for (const a of s.agents) if (typeof a.name !== 'string' || !a.name) return 'agent entries need a string name';
|
|
684
|
+
if (!isStateObject(s.files)) return 'missing files object';
|
|
685
|
+
const fileNames = Object.keys(s.files);
|
|
686
|
+
if (fileNames.length > SNAPSHOT_CAPS.files) return `files exceeds sanity cap ${SNAPSHOT_CAPS.files}`;
|
|
687
|
+
for (const name of fileNames) {
|
|
688
|
+
if (RESERVED_FILE_NAMES.has(name)) return `files may not use reserved name: ${name}`;
|
|
689
|
+
const f = s.files[name];
|
|
690
|
+
if (!isStateObject(f) || typeof f.text !== 'string') return `file ${name} must be an object with string text`;
|
|
691
|
+
}
|
|
692
|
+
if (s.context !== undefined && s.context !== null && !isStateObject(s.context)) return 'context must be an object or null';
|
|
648
693
|
return null;
|
|
649
694
|
}
|
|
650
695
|
|
package/src/hosted-server.mjs
CHANGED
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
* per IP at this layer, JSON bodies are capped at 1 MB (413), registry +
|
|
28
28
|
* workspace files are written 0600, HTML pages carry CSP/frame headers,
|
|
29
29
|
* and every admin action is audit-logged to stderr (never keys/hashes).
|
|
30
|
+
* - v0.5.4: workspace ids are validated at EVERY boundary — create, import,
|
|
31
|
+
* route matching (regex), workspace resolution, and the registry itself
|
|
32
|
+
* when loaded: ids become file names inside the data dir, so a planted or
|
|
33
|
+
* hand-edited registry entry must never turn one into a path outside it.
|
|
30
34
|
*/
|
|
31
35
|
import http from 'node:http';
|
|
32
36
|
import fs from 'node:fs';
|
|
@@ -62,7 +66,26 @@ if (!REQUIRE_TOKENS) {
|
|
|
62
66
|
|
|
63
67
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
64
68
|
const registryPath = path.join(dataDir, 'registry.json');
|
|
65
|
-
const
|
|
69
|
+
const validId = (id) => /^[a-z0-9][a-z0-9-]{1,62}$/.test(String(id || ''));
|
|
70
|
+
const warnedRegistryIds = new Set();
|
|
71
|
+
const loadRegistry = () => {
|
|
72
|
+
let reg;
|
|
73
|
+
try { reg = JSON.parse(fs.readFileSync(registryPath, 'utf8')); } catch { return { workspaces: {} }; }
|
|
74
|
+
if (!reg || typeof reg !== 'object' || !reg.workspaces || typeof reg.workspaces !== 'object') return { workspaces: {} };
|
|
75
|
+
// Defense in depth (v0.5.4): create/import validate ids, but the registry
|
|
76
|
+
// is also a file on disk — never let a planted or hand-edited entry turn
|
|
77
|
+
// into a path outside the data dir via getWorkspace's `${id}.json`.
|
|
78
|
+
for (const id of Object.keys(reg.workspaces)) {
|
|
79
|
+
if (!validId(id)) {
|
|
80
|
+
if (!warnedRegistryIds.has(id)) {
|
|
81
|
+
warnedRegistryIds.add(id);
|
|
82
|
+
process.stderr.write(`ghostbus hosted: ignoring registry entry with invalid workspace id ${JSON.stringify(id)} (ids become file names inside the data dir)\n`);
|
|
83
|
+
}
|
|
84
|
+
delete reg.workspaces[id];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return reg;
|
|
88
|
+
};
|
|
66
89
|
const saveRegistry = (reg) => {
|
|
67
90
|
// Unique temp per save: two processes sharing this registry must never
|
|
68
91
|
// collide on one fixed '.tmp' name (first rename wins, second throws ENOENT).
|
|
@@ -77,10 +100,10 @@ const saveRegistry = (reg) => {
|
|
|
77
100
|
}
|
|
78
101
|
};
|
|
79
102
|
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
|
80
|
-
const validId = (id) => /^[a-z0-9][a-z0-9-]{1,62}$/.test(String(id || ''));
|
|
81
103
|
|
|
82
104
|
const buses = new Map(); // id -> {bus, handle}
|
|
83
105
|
async function getWorkspace(id) {
|
|
106
|
+
if (!validId(id)) return null; // an id is a file name inside dataDir — nothing else resolves (v0.5.4)
|
|
84
107
|
if (buses.has(id)) return buses.get(id);
|
|
85
108
|
const reg = loadRegistry();
|
|
86
109
|
const meta = reg.workspaces[id];
|