ghostbus 0.5.2 → 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 CHANGED
@@ -1,5 +1,33 @@
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
+
16
+ ## 0.5.3 — 2026-10-11
17
+
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.
19
+
20
+ - **Timing-safe secret comparisons everywhere** — the hosted router compared workspace keys with `sha256(given) === meta.keyHash` (plain `===` on a digest), and the core compared agent-token hashes the same way. Both now use `crypto.timingSafeEqual` over the digests (new exported `hashMatches` in `src/handler.mjs`, shared by the router, the core token check, and the serverless pack, whose `wsKeyOk` had the same plain compare). A full-codebase audit found no other secret comparisons.
21
+ - **Failed-auth throttle at the hosted layer** (`src/hosted-server.mjs`) — the handler's 300 req/min limiter only throttles traffic already inside a workspace; nothing slowed down guessing admin or workspace keys. Now: 30 failed auth attempts per minute per IP → `429` on the admin surface and on workspace routes until the window rolls. Successful auth never counts; `/health` and `/probe` stay open so an operator can watch an attack happen.
22
+ - **Request bodies capped at 1 MB** (`readJsonBody` in `src/handler.mjs`) — both body readers accumulated without a cap (past a 5 MB socket-destroy with no useful response). One shared reader now enforces a commented `MAX_BODY_BYTES = 1_000_000` on hosted admin routes, in-workspace routes, and the single relay; over the cap the client gets a clean `413` JSON error and the socket is dropped after the response. File contents travel as JSON in GhostBus, so legitimate traffic fits comfortably; a workspace *import* over 1 MB must be restored from a state file / the CLI instead of over HTTP (stated in `docs/hosted.md`).
23
+ - **HTML pages carry frame/CSP protection** — the board (`src/handler.mjs`) and the hosted landing page are served with `X-Frame-Options: DENY` and a `Content-Security-Policy`: `default-src 'self'`, `frame-ancestors 'none'`, `object-src 'none'`. Both pages are single self-contained documents (inline script/style, same-origin fetches, zero external resources), so the CSP allows `'unsafe-inline'` for script/style specifically — no eval, no external origins.
24
+ - **Data files are written 0600** — the registry and workspace state files hold every key/token hash; `FileStore.save` (core, so the single relay benefits too) and `saveRegistry` plus the hosted import write now create their temp files with mode 0600 (plus a chmod before the atomic rename), and the mode rides through the rename. Existing files tighten on their next save.
25
+ - **Error hygiene** — the single relay and the hosted server returned raw `e.message` in 500s. Both now log the real error (with stack) to stderr and answer a generic `{ error: 'internal error' }`. The handler's 400s are unchanged on purpose: those are deliberate, client-facing validation/auth errors thrown by the core, not internal faults.
26
+ - **Admin audit log** (`src/hosted-server.mjs`) — every admin action (workspace create, delete, rotate-key, export, import) emits one structured stderr line: timestamp, action, workspace id, source IP. Never a key, never a hash. On a paid host, that stderr stream is the operator's audit trail.
27
+ - **SECURITY.md** — new at the repo root: latest-release-only support, private reporting to gdev6145@gmail.com, one-business-day acknowledgement (the studio's stated support posture), and scope notes.
28
+ - **Tests** — three new checks (54 in the main suite, was 51): HTML headers + 0600 file modes + audit lines (create/rotate/delete, asserting no key material appears in stderr); the 1 MB cap returning 413 on hosted-admin, in-workspace, and single-relay routes; and the failed-auth throttle (30 bad keys → 401 each, then even the correct key → 429, admin too, health still 200). All three fail against the 0.5.2 servers (51 passed, 3 failed, each for its intended reason) and pass with the change; serverless stays 6/6 and both demos green. The timing-safe change is covered functionally by the existing right-key/wrong-key checks at every layer — a timing side channel is not observable from a functional test, so that one is verified by the audit above rather than a red/green run.
29
+ - **Scope boundary, honestly** — what did NOT change: no API or behavior change for legitimate clients; storage format and locations; the local stdio/CLI trust model; the serverless pack's opt-in token posture (only its key compare was aligned); the deferred networked multi-writer store is still future work. One judgment call to know about: throttling is per-IP and in-memory — behind a shared NAT, one guesser can 429 a whole office's *unauthenticated* attempts for a minute; authenticated traffic with a valid key is only blocked while the throttle is tripped, and the window is short by design.
30
+
3
31
  ## 0.5.2 — 2026-10-11
4
32
 
5
33
  Security hardening — the pre-pilot pass. A cold source review scored GhostBus 7.4/10 with security maturity its lowest mark (5.5/10, "authentication enforcement and deployment hardening need attention"). That was the right call to answer before any hosted workspace carries a stranger's work — or their money:
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 # 51 checks: core, tokens, capsules, dependencies, sync, persistence, concurrency, MCP stdio, HTTP, SSE, CLI, hosted security
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
@@ -106,6 +106,65 @@ the operator, so its defaults are stricter than the local relay's (v0.5.2):
106
106
  Drill a restore (delete + import) before you need one; a restore lands in
107
107
  the workspace's provenance log as `workspace.restore`.
108
108
 
109
+ Round 2 (v0.5.3) closed the next layer:
110
+
111
+ - **Timing-safe secret comparisons everywhere.** Workspace keys (hosted
112
+ router + serverless pack) and per-agent tokens (core) are compared with
113
+ `crypto.timingSafeEqual` over SHA-256 digests — no `===` on secrets
114
+ anywhere in the codebase.
115
+ - **Failed-auth throttling at the hosted layer.** The in-workspace limiter
116
+ (300 req/min/IP) never covered *guessing*: 30 failed auth attempts per
117
+ minute from one IP now gets 429s on the admin surface and on workspace
118
+ routes until the window rolls. Successful auth never counts toward it;
119
+ `/health` and `/probe` stay open so operators can see an attack.
120
+ - **Request bodies are capped at 1 MB** on every JSON route (hosted admin,
121
+ in-workspace, single relay). Over the cap: a clean `413` JSON error.
122
+ File contents travel as JSON in GhostBus, so legitimate files fit
123
+ comfortably — but a workspace *import* larger than 1 MB must be restored
124
+ by placing the state file in the data dir (or via the CLI), not over HTTP.
125
+ - **HTML pages can't be framed and carry a CSP.** The board and the hosted
126
+ landing page answer with `X-Frame-Options: DENY` and a
127
+ `Content-Security-Policy` (`default-src 'self'`, `frame-ancestors 'none'`;
128
+ inline script/style are allowed specifically because both pages are
129
+ single self-contained documents with zero external resources).
130
+ - **Data files are written owner-only (0600).** The registry and every
131
+ workspace state file hold key/token hashes; they are created 0600 through
132
+ the same atomic temp+rename writes as before.
133
+ - **Admin actions are audit-logged.** Every workspace create, delete,
134
+ rotate-key, export, and import writes one structured stderr line
135
+ (timestamp, action, workspace id, source IP) — never a key or a hash.
136
+ Point the hosted process's stderr at your log collector and that *is*
137
+ the operator audit trail.
138
+ - **Reporting a vulnerability:** see `SECURITY.md` at the repo root
139
+ (private email reporting, one-business-day acknowledgement).
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
+
109
168
  ## Option B — serverless on Netlify (Blobs)
110
169
 
111
170
  `deploy/netlify/` contains a Netlify Function implementing the same multi-workspace semantics over Netlify Blobs (strong consistency):
package/package.json CHANGED
@@ -44,5 +44,5 @@
44
44
  "hosted": "node src/hosted-server.mjs"
45
45
  },
46
46
  "type": "module",
47
- "version": "0.5.2"
47
+ "version": "0.5.4"
48
48
  }
package/src/core.mjs CHANGED
@@ -14,10 +14,25 @@ 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.2';
17
+ export const GHOSTBUS_VERSION = '0.5.4';
18
18
  const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
19
+ // Timing-safe equality for hex digests (v0.5.3): secret comparisons must not
20
+ // short-circuit on the first differing character the way === does.
21
+ const hashEqHex = (aHex, bHex) => {
22
+ const a = Buffer.from(String(aHex)); const b = Buffer.from(String(bHex));
23
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
24
+ };
19
25
  export const TASK_STATUSES = ['queued', 'claimed', 'needs-approval', 'done', 'cancelled'];
20
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']);
21
36
 
22
37
  const now = () => new Date().toISOString();
23
38
  const clone = (v) => JSON.parse(JSON.stringify(v));
@@ -62,7 +77,11 @@ export class FileStore {
62
77
  await fs.promises.mkdir(path.dirname(this.file), { recursive: true });
63
78
  const tmp = this.file + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 10);
64
79
  try {
65
- await fs.promises.writeFile(tmp, JSON.stringify(state, null, 2));
80
+ // State files hold token hashes — owner-only (0600) from birth
81
+ // (v0.5.3). The mode rides through the atomic rename; the chmod
82
+ // covers filesystems/umasks that would otherwise widen it.
83
+ await fs.promises.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
84
+ await fs.promises.chmod(tmp, 0o600).catch(() => {});
66
85
  await fs.promises.rename(tmp, this.file); // atomic replace
67
86
  } finally {
68
87
  await fs.promises.unlink(tmp).catch(() => {});
@@ -160,7 +179,7 @@ export class GhostBus {
160
179
  const a = this._agent(name);
161
180
  if (!a) throw Object.assign(new Error(`agent not registered: ${name}`), { code: 'UNKNOWN_AGENT' });
162
181
  if (this.requireTokens && a.tokenHash) {
163
- if (!token || sha256(token) !== a.tokenHash) {
182
+ if (!token || !hashEqHex(sha256(token), a.tokenHash)) {
164
183
  throw Object.assign(new Error(`agent token required or wrong for: ${name}`), { code: 'AGENT_AUTH' });
165
184
  }
166
185
  }
@@ -382,6 +401,7 @@ export class GhostBus {
382
401
  this._requireAgent(by, token);
383
402
  if (!p || typeof text !== 'string') throw new Error('path and text required');
384
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');
385
405
  const prev = this.state.files[p];
386
406
  const history = prev ? [...(prev.history || []), { text: prev.text, updatedBy: prev.updatedBy, updatedAt: prev.updatedAt }].slice(-10) : [];
387
407
  this.state.files[p] = { text, updatedBy: by, updatedAt: now(), history };
@@ -393,7 +413,7 @@ export class GhostBus {
393
413
  async getFile(...args) { return this._serialized(() => this._getFile(...args)); }
394
414
  async _getFile(p, { withHistory = false } = {}) {
395
415
  await this.init();
396
- const f = this.state.files[p];
416
+ const f = Object.prototype.hasOwnProperty.call(this.state.files, p) ? this.state.files[p] : undefined;
397
417
  if (!f) throw new Error(`file not found: ${p}`);
398
418
  const { history, ...rest } = f;
399
419
  return { path: p, ...clone(rest), versions: (history || []).length + 1, ...(withHistory ? { history: clone(history || []) } : {}) };
@@ -402,7 +422,7 @@ export class GhostBus {
402
422
  async fileHistory(...args) { return this._serialized(() => this._fileHistory(...args)); }
403
423
  async _fileHistory(p) {
404
424
  await this.init();
405
- const f = this.state.files[p];
425
+ const f = Object.prototype.hasOwnProperty.call(this.state.files, p) ? this.state.files[p] : undefined;
406
426
  if (!f) throw new Error(`file not found: ${p}`);
407
427
  return { path: p, current: { text: f.text, updatedBy: f.updatedBy, updatedAt: f.updatedAt }, history: clone(f.history || []) };
408
428
  }
@@ -451,7 +471,7 @@ export class GhostBus {
451
471
  async _deleteFile({ by, path: p, token = null }) {
452
472
  await this.init();
453
473
  this._requireAgent(by, token);
454
- if (!this.state.files[p]) throw new Error(`file not found: ${p}`);
474
+ if (!Object.prototype.hasOwnProperty.call(this.state.files, p)) throw new Error(`file not found: ${p}`);
455
475
  delete this.state.files[p];
456
476
  this._event('file.delete', by, `${by} deleted ${p}`);
457
477
  await this._save();
@@ -461,7 +481,9 @@ export class GhostBus {
461
481
  async listChannels(...args) { return this._serialized(() => this._listChannels(...args)); }
462
482
  async _listChannels() {
463
483
  await this.init();
464
- const counts = {};
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);
465
487
  for (const m of this.state.messages) counts[m.channel] = (counts[m.channel] || 0) + 1;
466
488
  return Object.entries(counts).map(([channel, messages]) => ({ channel, messages })).sort((a, b) => b.messages - a.messages);
467
489
  }
@@ -628,13 +650,46 @@ export class GhostBus {
628
650
  }
629
651
 
630
652
  /** Shape-check a candidate workspace snapshot. Returns null when valid,
631
- * 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);
632
670
  export function validateSnapshot(s) {
633
- if (!s || typeof s !== 'object' || Array.isArray(s)) return 'not an object';
634
- if (!s.workspace || typeof s.workspace !== 'object') return 'missing workspace';
635
- for (const k of ['seq', 'msgSeq', 'taskSeq']) if (typeof s[k] !== 'number') return `missing numeric ${k}`;
636
- for (const k of ['agents', 'messages', 'tasks', 'events']) if (!Array.isArray(s[k])) return `missing array ${k}`;
637
- if (!s.files || typeof s.files !== 'object' || Array.isArray(s.files)) return 'missing files object';
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';
638
693
  return null;
639
694
  }
640
695
 
package/src/handler.mjs CHANGED
@@ -24,6 +24,56 @@ const hashEq = (given, expected) => {
24
24
  return a.length === b.length && crypto.timingSafeEqual(a, b);
25
25
  };
26
26
  export const keyMatches = (given, expected) => !!expected && !!given && hashEq(given, expected);
27
+ // Timing-safe check of a presented secret against a STORED sha256 hex digest
28
+ // (v0.5.3) — the hosted router and the core token check compare against
29
+ // hashes at rest, never against raw keys.
30
+ export const hashMatches = (given, storedHashHex) => {
31
+ if (!given || !storedHashHex) return false;
32
+ const a = Buffer.from(crypto.createHash('sha256').update(String(given)).digest('hex'));
33
+ const b = Buffer.from(String(storedHashHex));
34
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
35
+ };
36
+
37
+ // JSON request-body cap (v0.5.3): file contents travel inside JSON bodies in
38
+ // this codebase, so 1 MB is generous for messages/tasks/files while keeping
39
+ // a single request from exhausting memory. Over the cap the reader rejects
40
+ // with code BODY_TOO_LARGE and the route answers 413.
41
+ export const MAX_BODY_BYTES = 1_000_000;
42
+ export function readJsonBody(req, limit = MAX_BODY_BYTES) {
43
+ return new Promise((resolve, reject) => {
44
+ let size = 0; let done = false;
45
+ const chunks = [];
46
+ req.on('data', (c) => {
47
+ if (done) return;
48
+ size += c.length;
49
+ if (size > limit) {
50
+ done = true;
51
+ req.pause(); // stop reading; the route answers 413 and drops the socket
52
+ reject(Object.assign(new Error(`request body too large (limit ${limit} bytes)`), { code: 'BODY_TOO_LARGE' }));
53
+ return;
54
+ }
55
+ chunks.push(c);
56
+ });
57
+ req.on('end', () => {
58
+ if (done) return; done = true;
59
+ const data = Buffer.concat(chunks).toString('utf8');
60
+ try { resolve(data ? JSON.parse(data) : {}); } catch { resolve(null); }
61
+ });
62
+ req.on('error', () => { if (!done) { done = true; resolve(null); } });
63
+ });
64
+ }
65
+
66
+ // Headers for the HTML pages (board + hosted landing), v0.5.3. Both pages are
67
+ // single self-contained documents: inline <script>/<style> and same-origin
68
+ // fetches only, zero external resources — so the CSP allows 'unsafe-inline'
69
+ // for script/style specifically (no eval, no external origins, no plugins)
70
+ // and forbids framing outright.
71
+ export const HTML_SECURITY_HEADERS = {
72
+ 'x-frame-options': 'DENY',
73
+ 'content-security-policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
74
+ };
75
+
76
+ const TOO_LARGE = Symbol('body-too-large');
27
77
 
28
78
  export function createBusHandler({ id = 'default', bus, key = '' }) {
29
79
  const buckets = new Map();
@@ -51,13 +101,19 @@ export function createBusHandler({ id = 'default', bus, key = '' }) {
51
101
  if (!rateOk(req) && url.pathname !== '/health' && url.pathname !== '/probe') {
52
102
  return send(429, { error: 'rate limit: 300 requests/min per IP per workspace' });
53
103
  }
54
- const readBody = () => new Promise((resolve) => {
55
- let data = ''; req.on('data', c => { data += c; if (data.length > 5_000_000) req.destroy(); });
56
- req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { resolve(null); } });
57
- });
104
+ const send413 = () => {
105
+ try {
106
+ res.writeHead(413, { 'content-type': 'application/json', connection: 'close' });
107
+ res.end(JSON.stringify({ error: `request body too large (limit ${MAX_BODY_BYTES} bytes)` }), () => { try { req.destroy(); } catch {} });
108
+ } catch {}
109
+ };
110
+ const readBody = async () => {
111
+ try { return await readJsonBody(req); }
112
+ catch (e) { if (e && e.code === 'BODY_TOO_LARGE') { send413(); return TOO_LARGE; } throw e; }
113
+ };
58
114
 
59
115
  try {
60
- if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(BOARD_HTML); }
116
+ if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...HTML_SECURITY_HEADERS }); return res.end(BOARD_HTML); }
61
117
  if (req.method === 'GET' && url.pathname === '/health') return send(200, { ok: true, server: 'ghostbus', workspace: id, version: GHOSTBUS_VERSION });
62
118
  if (req.method === 'GET' && url.pathname === '/probe') {
63
119
  const s = await bus.status();
@@ -103,6 +159,7 @@ export function createBusHandler({ id = 'default', bus, key = '' }) {
103
159
 
104
160
  if (req.method === 'POST' && url.pathname === '/mcp') {
105
161
  const msg = await readBody();
162
+ if (msg === TOO_LARGE) return; // 413 already sent
106
163
  if (!msg) return send(400, { error: 'invalid JSON body' });
107
164
  if (msg.method === 'tools/list') return send(200, { jsonrpc: '2.0', id: msg.id, result: { tools: TOOLS } });
108
165
  if (msg.method === 'tools/call') {
@@ -118,6 +175,7 @@ export function createBusHandler({ id = 'default', bus, key = '' }) {
118
175
  }
119
176
 
120
177
  const body = ['POST', 'PUT', 'DELETE'].includes(req.method) ? await readBody().catch(() => ({})) : {};
178
+ if (body === TOO_LARGE) return; // 413 already sent
121
179
  const safeBody = body && typeof body === 'object' ? body : {};
122
180
  const q = Object.fromEntries(url.searchParams);
123
181
 
@@ -23,13 +23,21 @@
23
23
  * to act. GHOSTBUS_REQUIRE_TOKENS=0 is the only opt-out and starts with a
24
24
  * loud warning. GHOSTBUS_ADMIN_KEY is required and must be at least 16
25
25
  * characters — it is the operator root key (see docs/hosted.md).
26
+ * - v0.5.3: workspace-key checks are timing-safe, failed auth is throttled
27
+ * per IP at this layer, JSON bodies are capped at 1 MB (413), registry +
28
+ * workspace files are written 0600, HTML pages carry CSP/frame headers,
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.
26
34
  */
27
35
  import http from 'node:http';
28
36
  import fs from 'node:fs';
29
37
  import path from 'node:path';
30
38
  import crypto from 'node:crypto';
31
39
  import { createBus, FileStore, GHOSTBUS_VERSION, validateSnapshot } from './core.mjs';
32
- import { createBusHandler, keyMatches } from './handler.mjs';
40
+ import { createBusHandler, keyMatches, hashMatches, readJsonBody, MAX_BODY_BYTES, HTML_SECURITY_HEADERS } from './handler.mjs';
33
41
 
34
42
  const argv = process.argv.slice(2);
35
43
  const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
@@ -58,23 +66,44 @@ if (!REQUIRE_TOKENS) {
58
66
 
59
67
  fs.mkdirSync(dataDir, { recursive: true });
60
68
  const registryPath = path.join(dataDir, 'registry.json');
61
- const loadRegistry = () => { try { return JSON.parse(fs.readFileSync(registryPath, 'utf8')); } catch { return { workspaces: {} }; } };
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
+ };
62
89
  const saveRegistry = (reg) => {
63
90
  // Unique temp per save: two processes sharing this registry must never
64
91
  // collide on one fixed '.tmp' name (first rename wins, second throws ENOENT).
92
+ // The registry holds every workspace's key hash — owner-only (0600, v0.5.3).
65
93
  const tmp = registryPath + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 10);
66
94
  try {
67
- fs.writeFileSync(tmp, JSON.stringify(reg, null, 2));
95
+ fs.writeFileSync(tmp, JSON.stringify(reg, null, 2), { mode: 0o600 });
96
+ try { fs.chmodSync(tmp, 0o600); } catch { /* mode already applied on create */ }
68
97
  fs.renameSync(tmp, registryPath);
69
98
  } finally {
70
99
  try { fs.unlinkSync(tmp); } catch { /* already renamed away */ }
71
100
  }
72
101
  };
73
102
  const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
74
- const validId = (id) => /^[a-z0-9][a-z0-9-]{1,62}$/.test(String(id || ''));
75
103
 
76
104
  const buses = new Map(); // id -> {bus, handle}
77
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)
78
107
  if (buses.has(id)) return buses.get(id);
79
108
  const reg = loadRegistry();
80
109
  const meta = reg.workspaces[id];
@@ -98,9 +127,38 @@ const wsKeyOk = (req, meta) => {
98
127
  const given = req.headers['x-bus-key'] || String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
99
128
  if (!given) return false;
100
129
  if (keyMatches(given, ADMIN_KEY)) return true; // master key
101
- return sha256(given) === meta.keyHash;
130
+ return hashMatches(given, meta.keyHash); // timing-safe vs the stored hash (v0.5.3)
131
+ };
132
+
133
+ // Failed-auth throttle (v0.5.3): the in-workspace handler limiter (300/min)
134
+ // throttles traffic that is already inside a workspace, but nothing slowed
135
+ // down *guessing* at the hosted layer — admin keys and workspace keys were
136
+ // unthrottled. Track FAILED auth per IP; past the limit, admin + workspace
137
+ // routes answer 429 until the window rolls. Successful auth never counts.
138
+ const AUTH_FAIL_LIMIT = 30; // failed auth attempts per window per IP
139
+ const AUTH_FAIL_WINDOW_MS = 60_000; // one-minute window
140
+ const authFails = new Map(); // ip -> {start, count}
141
+ const clientIp = (req) => (req.socket && req.socket.remoteAddress) || 'unknown';
142
+ const authThrottled = (ip) => {
143
+ const b = authFails.get(ip);
144
+ return !!b && Date.now() - b.start <= AUTH_FAIL_WINDOW_MS && b.count >= AUTH_FAIL_LIMIT;
145
+ };
146
+ const noteAuthFail = (ip) => {
147
+ const t = Date.now();
148
+ let b = authFails.get(ip);
149
+ if (!b || t - b.start > AUTH_FAIL_WINDOW_MS) { b = { start: t, count: 0 }; authFails.set(ip, b); }
150
+ b.count += 1;
151
+ };
152
+
153
+ // Operator audit trail (v0.5.3): one structured stderr line per admin
154
+ // action, so a paid host has a record of who did what to which workspace.
155
+ // NEVER log keys or hashes — ids, actions, timestamps, source IPs only.
156
+ const audit = (action, id, req) => {
157
+ process.stderr.write(JSON.stringify({ ts: new Date().toISOString(), audit: 'admin', action, workspace: id, ip: clientIp(req) }) + '\n');
102
158
  };
103
159
 
160
+ const TOO_LARGE = Symbol('body-too-large');
161
+
104
162
  const LANDING = `<!doctype html><html><head><meta charset="utf-8"><title>GhostBus Hosted</title>
105
163
  <style>body{font-family:system-ui;background:#0e0c14;color:#ece8f5;margin:0;padding:40px}h1{font-size:22px}code{background:#17131f;padding:2px 7px;border-radius:6px;color:#c9b8ff}p{color:#9b8fb5;max-width:640px;line-height:1.55}</style></head>
106
164
  <body><h1>👻 GhostBus Hosted</h1>
@@ -116,19 +174,34 @@ const server = http.createServer(async (req, res) => {
116
174
  };
117
175
  res.setHeader('x-content-type-options', 'nosniff');
118
176
  res.setHeader('referrer-policy', 'no-referrer');
119
- const readBody = () => new Promise((resolve) => {
120
- let data = ''; req.on('data', c => { data += c; if (data.length > 5_000_000) req.destroy(); });
121
- req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { resolve(null); } });
122
- });
177
+ const ip = clientIp(req);
178
+ const adminDenied = () => { noteAuthFail(ip); return send(401, { error: 'admin key required' }); };
179
+ const readBody = async () => {
180
+ try { return await readJsonBody(req); }
181
+ catch (e) {
182
+ if (e && e.code === 'BODY_TOO_LARGE') {
183
+ try {
184
+ res.writeHead(413, { 'content-type': 'application/json', connection: 'close' });
185
+ res.end(JSON.stringify({ error: `request body too large (limit ${MAX_BODY_BYTES} bytes)` }), () => { try { req.destroy(); } catch {} });
186
+ } catch {}
187
+ return TOO_LARGE;
188
+ }
189
+ throw e;
190
+ }
191
+ };
123
192
 
124
193
  try {
125
- if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(LANDING); }
194
+ if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...HTML_SECURITY_HEADERS }); return res.end(LANDING); }
126
195
  if (req.method === 'GET' && url.pathname === '/health') return send(200, { ok: true, server: 'ghostbus-hosted', version: GHOSTBUS_VERSION, workspaces: Object.keys(loadRegistry().workspaces).length });
127
196
 
128
197
  // ---- admin ----
198
+ // Failed-auth throttle (v0.5.3): sustained key guessing gets 429s on the
199
+ // whole admin surface before any key is even compared.
200
+ if (url.pathname.startsWith('/api/workspaces') && authThrottled(ip)) return send(429, { error: 'too many failed auth attempts — try again shortly' });
129
201
  if (url.pathname === '/api/workspaces' && req.method === 'POST') {
130
- if (!isAdmin(req)) return send(401, { error: 'admin key required' });
202
+ if (!isAdmin(req)) return adminDenied();
131
203
  const body = await readBody();
204
+ if (body === TOO_LARGE) return; // 413 already sent
132
205
  if (!body) return send(400, { error: 'invalid JSON body' });
133
206
  const reg = loadRegistry();
134
207
  const id = body.id || `ws-${crypto.randomBytes(4).toString('hex')}`;
@@ -137,10 +210,11 @@ const server = http.createServer(async (req, res) => {
137
210
  const key = crypto.randomBytes(24).toString('hex');
138
211
  reg.workspaces[id] = { name: String(body.name || id).slice(0, 120), keyHash: sha256(key), createdAt: new Date().toISOString() };
139
212
  saveRegistry(reg);
213
+ audit('create', id, req);
140
214
  return send(201, { id, name: reg.workspaces[id].name, key, keyNote: 'Workspace key — shown ONCE, only its hash is stored. Agents use it as x-bus-key (or Bearer) under /w/' + id + '/.', board: `/w/${id}/` });
141
215
  }
142
216
  if (url.pathname === '/api/workspaces' && req.method === 'GET') {
143
- if (!isAdmin(req)) return send(401, { error: 'admin key required' });
217
+ if (!isAdmin(req)) return adminDenied();
144
218
  const reg = loadRegistry();
145
219
  const out = [];
146
220
  for (const [id, meta] of Object.entries(reg.workspaces)) {
@@ -152,7 +226,7 @@ const server = http.createServer(async (req, res) => {
152
226
  }
153
227
  const rotateMatch = url.pathname.match(/^\/api\/workspaces\/([a-z0-9-]+)\/rotate-key$/);
154
228
  if (req.method === 'POST' && rotateMatch) {
155
- if (!isAdmin(req)) return send(401, { error: 'admin key required' });
229
+ if (!isAdmin(req)) return adminDenied();
156
230
  const reg = loadRegistry();
157
231
  const id = rotateMatch[1];
158
232
  if (!reg.workspaces[id]) return send(404, { error: `no such workspace: ${id}` });
@@ -163,20 +237,23 @@ const server = http.createServer(async (req, res) => {
163
237
  // Refresh the cached meta so the old key dies immediately, not on restart.
164
238
  const cached = buses.get(id);
165
239
  if (cached) cached.meta = reg.workspaces[id];
240
+ audit('rotate-key', id, req);
166
241
  return send(200, { id, key, keyNote: 'New workspace key — shown ONCE. The previous key is invalid from this moment.', keyRotatedAt: reg.workspaces[id].keyRotatedAt });
167
242
  }
168
243
  const exportMatch = url.pathname.match(/^\/api\/workspaces\/([a-z0-9-]+)\/export$/);
169
244
  if (req.method === 'GET' && exportMatch) {
170
- if (!isAdmin(req)) return send(401, { error: 'admin key required' });
245
+ if (!isAdmin(req)) return adminDenied();
171
246
  const id = exportMatch[1];
172
247
  const w = await getWorkspace(id);
173
248
  if (!w) return send(404, { error: `no such workspace: ${id}` });
174
249
  const state = await w.bus.snapshot();
250
+ audit('export', id, req);
175
251
  return send(200, { ghostbusExport: 1, version: GHOSTBUS_VERSION, exportedAt: new Date().toISOString(), workspace: { id, name: w.meta.name }, state });
176
252
  }
177
253
  if (url.pathname === '/api/workspaces/import' && req.method === 'POST') {
178
- if (!isAdmin(req)) return send(401, { error: 'admin key required' });
254
+ if (!isAdmin(req)) return adminDenied();
179
255
  const body = await readBody();
256
+ if (body === TOO_LARGE) return; // 413 already sent
180
257
  if (!body) return send(400, { error: 'invalid JSON body' });
181
258
  // Accept either a full export envelope or a bare {state} / raw state.
182
259
  const env = body.ghostbusExport ? body : null;
@@ -195,15 +272,17 @@ const server = http.createServer(async (req, res) => {
195
272
  // the bus sees exactly what a restart would see.
196
273
  const file = path.join(dataDir, `${id}.json`);
197
274
  const tmp = file + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 10);
198
- fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
275
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 }); // state file: owner-only (v0.5.3)
276
+ try { fs.chmodSync(tmp, 0o600); } catch { /* mode already applied on create */ }
199
277
  fs.renameSync(tmp, file);
278
+ audit('import', id, req);
200
279
  const w = await getWorkspace(id);
201
280
  const status = await w.bus.status();
202
281
  return send(201, { id, name, key, keyNote: 'Workspace key — shown ONCE, only its hash is stored. Imported workspaces always get a FRESH key; the source key is not carried over.', agents: status.agents, messages: status.messages, tasks: status.tasks.total, board: `/w/${id}/` });
203
282
  }
204
283
  const delMatch = url.pathname.match(/^\/api\/workspaces\/([a-z0-9-]+)$/);
205
284
  if (req.method === 'DELETE' && delMatch) {
206
- if (!isAdmin(req)) return send(401, { error: 'admin key required' });
285
+ if (!isAdmin(req)) return adminDenied();
207
286
  const reg = loadRegistry();
208
287
  const id = delMatch[1];
209
288
  if (!reg.workspaces[id]) return send(404, { error: `no such workspace: ${id}` });
@@ -211,6 +290,7 @@ const server = http.createServer(async (req, res) => {
211
290
  saveRegistry(reg);
212
291
  buses.delete(id); handlers.delete(id);
213
292
  try { fs.unlinkSync(path.join(dataDir, `${id}.json`)); } catch {}
293
+ audit('delete', id, req);
214
294
  return send(200, { id, deleted: true });
215
295
  }
216
296
 
@@ -224,13 +304,15 @@ const server = http.createServer(async (req, res) => {
224
304
  sub.pathname = wMatch[2] || '/';
225
305
  // Open surfaces mirror the single relay: board page, health, probe.
226
306
  const open = sub.pathname === '/' || sub.pathname === '/health' || sub.pathname === '/probe';
227
- if (!open && !wsKeyOk(req, w.meta)) return send(401, { error: 'missing or wrong workspace key' });
307
+ if (!open && authThrottled(ip)) return send(429, { error: 'too many failed auth attempts — try again shortly' });
308
+ if (!open && !wsKeyOk(req, w.meta)) { noteAuthFail(ip); return send(401, { error: 'missing or wrong workspace key' }); }
228
309
  return handlerFor(id, w.bus)(req, res, sub);
229
310
  }
230
311
 
231
312
  return send(404, { error: 'not found' });
232
313
  } catch (e) {
233
- return send(500, { error: e.message });
314
+ process.stderr.write(`ghostbus hosted: internal error: ${(e && e.stack) || e}\n`);
315
+ return send(500, { error: 'internal error' });
234
316
  }
235
317
  });
236
318
 
@@ -30,7 +30,7 @@ const handle = createBusHandler({ id: 'default', bus, key: KEY });
30
30
 
31
31
  const server = http.createServer((req, res) => {
32
32
  const url = new URL(req.url, 'http://localhost');
33
- handle(req, res, url).catch((e) => { try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: e.message })); } catch {} });
33
+ handle(req, res, url).catch((e) => { process.stderr.write(`ghostbus relay: internal error: ${(e && e.stack) || e}\n`); try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal error' })); } catch {} });
34
34
  });
35
35
  server.listen(port, () => {
36
36
  process.stderr.write(`ghostbus http relay v${GHOSTBUS_VERSION} · http://127.0.0.1:${port} · store=${storePath} · auth=${KEY ? 'workspace key required' : 'open (set GHOSTBUS_KEY)'}\n`);