ghostbus 0.5.2 → 0.5.3
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 +15 -0
- package/README.md +1 -1
- package/docs/hosted.md +32 -0
- package/package.json +1 -1
- package/src/core.mjs +13 -3
- package/src/handler.mjs +63 -5
- package/src/hosted-server.mjs +76 -17
- package/src/http-server.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.3 — 2026-10-11
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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`).
|
|
10
|
+
- **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.
|
|
11
|
+
- **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.
|
|
12
|
+
- **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.
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
16
|
+
- **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.
|
|
17
|
+
|
|
3
18
|
## 0.5.2 — 2026-10-11
|
|
4
19
|
|
|
5
20
|
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 #
|
|
26
|
+
node test.mjs # 54 checks: core, tokens, capsules, dependencies, sync, persistence, concurrency, MCP stdio, HTTP, SSE, CLI, hosted security
|
|
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,38 @@ 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
|
+
|
|
109
141
|
## Option B — serverless on Netlify (Blobs)
|
|
110
142
|
|
|
111
143
|
`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,8 +14,14 @@ 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.3';
|
|
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;
|
|
21
27
|
|
|
@@ -62,7 +68,11 @@ export class FileStore {
|
|
|
62
68
|
await fs.promises.mkdir(path.dirname(this.file), { recursive: true });
|
|
63
69
|
const tmp = this.file + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 10);
|
|
64
70
|
try {
|
|
65
|
-
|
|
71
|
+
// State files hold token hashes — owner-only (0600) from birth
|
|
72
|
+
// (v0.5.3). The mode rides through the atomic rename; the chmod
|
|
73
|
+
// covers filesystems/umasks that would otherwise widen it.
|
|
74
|
+
await fs.promises.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
75
|
+
await fs.promises.chmod(tmp, 0o600).catch(() => {});
|
|
66
76
|
await fs.promises.rename(tmp, this.file); // atomic replace
|
|
67
77
|
} finally {
|
|
68
78
|
await fs.promises.unlink(tmp).catch(() => {});
|
|
@@ -160,7 +170,7 @@ export class GhostBus {
|
|
|
160
170
|
const a = this._agent(name);
|
|
161
171
|
if (!a) throw Object.assign(new Error(`agent not registered: ${name}`), { code: 'UNKNOWN_AGENT' });
|
|
162
172
|
if (this.requireTokens && a.tokenHash) {
|
|
163
|
-
if (!token || sha256(token)
|
|
173
|
+
if (!token || !hashEqHex(sha256(token), a.tokenHash)) {
|
|
164
174
|
throw Object.assign(new Error(`agent token required or wrong for: ${name}`), { code: 'AGENT_AUTH' });
|
|
165
175
|
}
|
|
166
176
|
}
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
package/src/hosted-server.mjs
CHANGED
|
@@ -23,13 +23,17 @@
|
|
|
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).
|
|
26
30
|
*/
|
|
27
31
|
import http from 'node:http';
|
|
28
32
|
import fs from 'node:fs';
|
|
29
33
|
import path from 'node:path';
|
|
30
34
|
import crypto from 'node:crypto';
|
|
31
35
|
import { createBus, FileStore, GHOSTBUS_VERSION, validateSnapshot } from './core.mjs';
|
|
32
|
-
import { createBusHandler, keyMatches } from './handler.mjs';
|
|
36
|
+
import { createBusHandler, keyMatches, hashMatches, readJsonBody, MAX_BODY_BYTES, HTML_SECURITY_HEADERS } from './handler.mjs';
|
|
33
37
|
|
|
34
38
|
const argv = process.argv.slice(2);
|
|
35
39
|
const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
|
|
@@ -62,9 +66,11 @@ const loadRegistry = () => { try { return JSON.parse(fs.readFileSync(registryPat
|
|
|
62
66
|
const saveRegistry = (reg) => {
|
|
63
67
|
// Unique temp per save: two processes sharing this registry must never
|
|
64
68
|
// collide on one fixed '.tmp' name (first rename wins, second throws ENOENT).
|
|
69
|
+
// The registry holds every workspace's key hash — owner-only (0600, v0.5.3).
|
|
65
70
|
const tmp = registryPath + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 10);
|
|
66
71
|
try {
|
|
67
|
-
fs.writeFileSync(tmp, JSON.stringify(reg, null, 2));
|
|
72
|
+
fs.writeFileSync(tmp, JSON.stringify(reg, null, 2), { mode: 0o600 });
|
|
73
|
+
try { fs.chmodSync(tmp, 0o600); } catch { /* mode already applied on create */ }
|
|
68
74
|
fs.renameSync(tmp, registryPath);
|
|
69
75
|
} finally {
|
|
70
76
|
try { fs.unlinkSync(tmp); } catch { /* already renamed away */ }
|
|
@@ -98,9 +104,38 @@ const wsKeyOk = (req, meta) => {
|
|
|
98
104
|
const given = req.headers['x-bus-key'] || String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
|
|
99
105
|
if (!given) return false;
|
|
100
106
|
if (keyMatches(given, ADMIN_KEY)) return true; // master key
|
|
101
|
-
return
|
|
107
|
+
return hashMatches(given, meta.keyHash); // timing-safe vs the stored hash (v0.5.3)
|
|
102
108
|
};
|
|
103
109
|
|
|
110
|
+
// Failed-auth throttle (v0.5.3): the in-workspace handler limiter (300/min)
|
|
111
|
+
// throttles traffic that is already inside a workspace, but nothing slowed
|
|
112
|
+
// down *guessing* at the hosted layer — admin keys and workspace keys were
|
|
113
|
+
// unthrottled. Track FAILED auth per IP; past the limit, admin + workspace
|
|
114
|
+
// routes answer 429 until the window rolls. Successful auth never counts.
|
|
115
|
+
const AUTH_FAIL_LIMIT = 30; // failed auth attempts per window per IP
|
|
116
|
+
const AUTH_FAIL_WINDOW_MS = 60_000; // one-minute window
|
|
117
|
+
const authFails = new Map(); // ip -> {start, count}
|
|
118
|
+
const clientIp = (req) => (req.socket && req.socket.remoteAddress) || 'unknown';
|
|
119
|
+
const authThrottled = (ip) => {
|
|
120
|
+
const b = authFails.get(ip);
|
|
121
|
+
return !!b && Date.now() - b.start <= AUTH_FAIL_WINDOW_MS && b.count >= AUTH_FAIL_LIMIT;
|
|
122
|
+
};
|
|
123
|
+
const noteAuthFail = (ip) => {
|
|
124
|
+
const t = Date.now();
|
|
125
|
+
let b = authFails.get(ip);
|
|
126
|
+
if (!b || t - b.start > AUTH_FAIL_WINDOW_MS) { b = { start: t, count: 0 }; authFails.set(ip, b); }
|
|
127
|
+
b.count += 1;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Operator audit trail (v0.5.3): one structured stderr line per admin
|
|
131
|
+
// action, so a paid host has a record of who did what to which workspace.
|
|
132
|
+
// NEVER log keys or hashes — ids, actions, timestamps, source IPs only.
|
|
133
|
+
const audit = (action, id, req) => {
|
|
134
|
+
process.stderr.write(JSON.stringify({ ts: new Date().toISOString(), audit: 'admin', action, workspace: id, ip: clientIp(req) }) + '\n');
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const TOO_LARGE = Symbol('body-too-large');
|
|
138
|
+
|
|
104
139
|
const LANDING = `<!doctype html><html><head><meta charset="utf-8"><title>GhostBus Hosted</title>
|
|
105
140
|
<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
141
|
<body><h1>👻 GhostBus Hosted</h1>
|
|
@@ -116,19 +151,34 @@ const server = http.createServer(async (req, res) => {
|
|
|
116
151
|
};
|
|
117
152
|
res.setHeader('x-content-type-options', 'nosniff');
|
|
118
153
|
res.setHeader('referrer-policy', 'no-referrer');
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
154
|
+
const ip = clientIp(req);
|
|
155
|
+
const adminDenied = () => { noteAuthFail(ip); return send(401, { error: 'admin key required' }); };
|
|
156
|
+
const readBody = async () => {
|
|
157
|
+
try { return await readJsonBody(req); }
|
|
158
|
+
catch (e) {
|
|
159
|
+
if (e && e.code === 'BODY_TOO_LARGE') {
|
|
160
|
+
try {
|
|
161
|
+
res.writeHead(413, { 'content-type': 'application/json', connection: 'close' });
|
|
162
|
+
res.end(JSON.stringify({ error: `request body too large (limit ${MAX_BODY_BYTES} bytes)` }), () => { try { req.destroy(); } catch {} });
|
|
163
|
+
} catch {}
|
|
164
|
+
return TOO_LARGE;
|
|
165
|
+
}
|
|
166
|
+
throw e;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
123
169
|
|
|
124
170
|
try {
|
|
125
|
-
if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(LANDING); }
|
|
171
|
+
if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...HTML_SECURITY_HEADERS }); return res.end(LANDING); }
|
|
126
172
|
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
173
|
|
|
128
174
|
// ---- admin ----
|
|
175
|
+
// Failed-auth throttle (v0.5.3): sustained key guessing gets 429s on the
|
|
176
|
+
// whole admin surface before any key is even compared.
|
|
177
|
+
if (url.pathname.startsWith('/api/workspaces') && authThrottled(ip)) return send(429, { error: 'too many failed auth attempts — try again shortly' });
|
|
129
178
|
if (url.pathname === '/api/workspaces' && req.method === 'POST') {
|
|
130
|
-
if (!isAdmin(req)) return
|
|
179
|
+
if (!isAdmin(req)) return adminDenied();
|
|
131
180
|
const body = await readBody();
|
|
181
|
+
if (body === TOO_LARGE) return; // 413 already sent
|
|
132
182
|
if (!body) return send(400, { error: 'invalid JSON body' });
|
|
133
183
|
const reg = loadRegistry();
|
|
134
184
|
const id = body.id || `ws-${crypto.randomBytes(4).toString('hex')}`;
|
|
@@ -137,10 +187,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
137
187
|
const key = crypto.randomBytes(24).toString('hex');
|
|
138
188
|
reg.workspaces[id] = { name: String(body.name || id).slice(0, 120), keyHash: sha256(key), createdAt: new Date().toISOString() };
|
|
139
189
|
saveRegistry(reg);
|
|
190
|
+
audit('create', id, req);
|
|
140
191
|
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
192
|
}
|
|
142
193
|
if (url.pathname === '/api/workspaces' && req.method === 'GET') {
|
|
143
|
-
if (!isAdmin(req)) return
|
|
194
|
+
if (!isAdmin(req)) return adminDenied();
|
|
144
195
|
const reg = loadRegistry();
|
|
145
196
|
const out = [];
|
|
146
197
|
for (const [id, meta] of Object.entries(reg.workspaces)) {
|
|
@@ -152,7 +203,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
152
203
|
}
|
|
153
204
|
const rotateMatch = url.pathname.match(/^\/api\/workspaces\/([a-z0-9-]+)\/rotate-key$/);
|
|
154
205
|
if (req.method === 'POST' && rotateMatch) {
|
|
155
|
-
if (!isAdmin(req)) return
|
|
206
|
+
if (!isAdmin(req)) return adminDenied();
|
|
156
207
|
const reg = loadRegistry();
|
|
157
208
|
const id = rotateMatch[1];
|
|
158
209
|
if (!reg.workspaces[id]) return send(404, { error: `no such workspace: ${id}` });
|
|
@@ -163,20 +214,23 @@ const server = http.createServer(async (req, res) => {
|
|
|
163
214
|
// Refresh the cached meta so the old key dies immediately, not on restart.
|
|
164
215
|
const cached = buses.get(id);
|
|
165
216
|
if (cached) cached.meta = reg.workspaces[id];
|
|
217
|
+
audit('rotate-key', id, req);
|
|
166
218
|
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
219
|
}
|
|
168
220
|
const exportMatch = url.pathname.match(/^\/api\/workspaces\/([a-z0-9-]+)\/export$/);
|
|
169
221
|
if (req.method === 'GET' && exportMatch) {
|
|
170
|
-
if (!isAdmin(req)) return
|
|
222
|
+
if (!isAdmin(req)) return adminDenied();
|
|
171
223
|
const id = exportMatch[1];
|
|
172
224
|
const w = await getWorkspace(id);
|
|
173
225
|
if (!w) return send(404, { error: `no such workspace: ${id}` });
|
|
174
226
|
const state = await w.bus.snapshot();
|
|
227
|
+
audit('export', id, req);
|
|
175
228
|
return send(200, { ghostbusExport: 1, version: GHOSTBUS_VERSION, exportedAt: new Date().toISOString(), workspace: { id, name: w.meta.name }, state });
|
|
176
229
|
}
|
|
177
230
|
if (url.pathname === '/api/workspaces/import' && req.method === 'POST') {
|
|
178
|
-
if (!isAdmin(req)) return
|
|
231
|
+
if (!isAdmin(req)) return adminDenied();
|
|
179
232
|
const body = await readBody();
|
|
233
|
+
if (body === TOO_LARGE) return; // 413 already sent
|
|
180
234
|
if (!body) return send(400, { error: 'invalid JSON body' });
|
|
181
235
|
// Accept either a full export envelope or a bare {state} / raw state.
|
|
182
236
|
const env = body.ghostbusExport ? body : null;
|
|
@@ -195,15 +249,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
195
249
|
// the bus sees exactly what a restart would see.
|
|
196
250
|
const file = path.join(dataDir, `${id}.json`);
|
|
197
251
|
const tmp = file + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 10);
|
|
198
|
-
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
252
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 }); // state file: owner-only (v0.5.3)
|
|
253
|
+
try { fs.chmodSync(tmp, 0o600); } catch { /* mode already applied on create */ }
|
|
199
254
|
fs.renameSync(tmp, file);
|
|
255
|
+
audit('import', id, req);
|
|
200
256
|
const w = await getWorkspace(id);
|
|
201
257
|
const status = await w.bus.status();
|
|
202
258
|
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
259
|
}
|
|
204
260
|
const delMatch = url.pathname.match(/^\/api\/workspaces\/([a-z0-9-]+)$/);
|
|
205
261
|
if (req.method === 'DELETE' && delMatch) {
|
|
206
|
-
if (!isAdmin(req)) return
|
|
262
|
+
if (!isAdmin(req)) return adminDenied();
|
|
207
263
|
const reg = loadRegistry();
|
|
208
264
|
const id = delMatch[1];
|
|
209
265
|
if (!reg.workspaces[id]) return send(404, { error: `no such workspace: ${id}` });
|
|
@@ -211,6 +267,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
211
267
|
saveRegistry(reg);
|
|
212
268
|
buses.delete(id); handlers.delete(id);
|
|
213
269
|
try { fs.unlinkSync(path.join(dataDir, `${id}.json`)); } catch {}
|
|
270
|
+
audit('delete', id, req);
|
|
214
271
|
return send(200, { id, deleted: true });
|
|
215
272
|
}
|
|
216
273
|
|
|
@@ -224,13 +281,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
224
281
|
sub.pathname = wMatch[2] || '/';
|
|
225
282
|
// Open surfaces mirror the single relay: board page, health, probe.
|
|
226
283
|
const open = sub.pathname === '/' || sub.pathname === '/health' || sub.pathname === '/probe';
|
|
227
|
-
if (!open &&
|
|
284
|
+
if (!open && authThrottled(ip)) return send(429, { error: 'too many failed auth attempts — try again shortly' });
|
|
285
|
+
if (!open && !wsKeyOk(req, w.meta)) { noteAuthFail(ip); return send(401, { error: 'missing or wrong workspace key' }); }
|
|
228
286
|
return handlerFor(id, w.bus)(req, res, sub);
|
|
229
287
|
}
|
|
230
288
|
|
|
231
289
|
return send(404, { error: 'not found' });
|
|
232
290
|
} catch (e) {
|
|
233
|
-
|
|
291
|
+
process.stderr.write(`ghostbus hosted: internal error: ${(e && e.stack) || e}\n`);
|
|
292
|
+
return send(500, { error: 'internal error' });
|
|
234
293
|
}
|
|
235
294
|
});
|
|
236
295
|
|
package/src/http-server.mjs
CHANGED
|
@@ -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:
|
|
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`);
|