claude-code-hub 0.22.0 → 0.24.0

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.
@@ -0,0 +1,267 @@
1
+ 'use strict';
2
+
3
+ // Network boundary for a local-only dev server.
4
+ //
5
+ // Binding loopback is necessary but not sufficient. Three distinct attackers:
6
+ //
7
+ // * LAN peer — http://192.168.1.42:3541/api/... The loopback bind stops this
8
+ // outright; the handshake never completes off-box.
9
+ // * DNS rebinding — the victim loads evil.com, the attacker re-resolves that
10
+ // name to 127.0.0.1, and the page fetches http://evil.com:3541/api/...
11
+ // The page's origin *is* evil.com:3541, so the request is same-origin and
12
+ // CORS never fires; the connection is to loopback, so the bind is satisfied.
13
+ // The only thing that differs from a legitimate request is the Host header,
14
+ // which script cannot forge. A Host allowlist is the entire defense.
15
+ // * Plain CSRF — evil.com POSTs straight at 127.0.0.1:3541. Host is a real
16
+ // allowlisted value, so hostGuard passes it; this needs a separate Origin /
17
+ // Sec-Fetch-Site check on state-changing methods.
18
+ //
19
+ // There is no authentication. Exposing this to a network means trusting everyone
20
+ // on it with the user's Claude Code history.
21
+ //
22
+ // CANONICAL COPY: scripts/security-lib/net-guard.js in the claude-code-hub repo.
23
+ // Run scripts/sync-security-lib.sh after editing.
24
+
25
+ const LOOPBACK_HOSTS = new Set([
26
+ 'localhost',
27
+ '127.0.0.1',
28
+ '::1',
29
+ '0:0:0:0:0:0:0:1',
30
+ '::ffff:127.0.0.1',
31
+ ]);
32
+
33
+ // Hoisted: parseHostHeader runs on every request, and neither the regex nor the
34
+ // predicate depends on the argument.
35
+ const PORT_RE = /^[0-9]{1,5}$/;
36
+ // The port is validated but not compared — the listen(0) fallbacks make the real
37
+ // port dynamic, so only the host is allowlisted.
38
+ const validPort = (p) => PORT_RE.test(p) && Number(p) >= 1 && Number(p) <= 65535;
39
+
40
+ function isLoopbackAddress(host) {
41
+ if (!host) return false;
42
+ const h = String(host).toLowerCase();
43
+ return LOOPBACK_HOSTS.has(h) || h.startsWith('127.');
44
+ }
45
+
46
+ // Hand-parsed rather than via new URL(), which is far more permissive than we
47
+ // want here and happily accepts things no real client sends. Returns the bare
48
+ // hostname, lowercased, or null if the header is malformed.
49
+ //
50
+ // The port is deliberately ignored: ports are dynamic in these apps because of
51
+ // the listen(0) fallback when the default is busy, so pinning one would break
52
+ // the fallback path.
53
+ function parseHostHeader(value) {
54
+ if (typeof value !== 'string') return null;
55
+ const raw = value.trim();
56
+ if (!raw || raw.length > 259) return null;
57
+
58
+ // Reject whitespace and control characters. Written as a code-point scan rather
59
+ // than a character class so it cannot be misread — a stray range in a regex
60
+ // here would silently reject legitimate hostnames containing a dash.
61
+ for (let i = 0; i < raw.length; i++) {
62
+ const code = raw.charCodeAt(i);
63
+ if (code <= 0x20 || code === 0x7f) return null;
64
+ }
65
+ if (raw.includes('@')) return null; // userinfo has no business in a Host header
66
+
67
+ let host;
68
+ if (raw.startsWith('[')) {
69
+ const end = raw.indexOf(']');
70
+ if (end === -1) return null;
71
+ host = raw.slice(1, end);
72
+ const rest = raw.slice(end + 1);
73
+ if (rest && !(rest.startsWith(':') && validPort(rest.slice(1)))) return null;
74
+ } else {
75
+ const parts = raw.split(':');
76
+ if (parts.length > 2) return null; // bare IPv6 without brackets, or garbage
77
+ host = parts[0];
78
+ if (parts.length === 2 && !validPort(parts[1])) return null;
79
+ }
80
+
81
+ if (!host) return null;
82
+ if (host.endsWith('.')) host = host.slice(0, -1); // one trailing root dot is legal
83
+ if (!host) return null;
84
+ return host.toLowerCase();
85
+ }
86
+
87
+ function parseList(value) {
88
+ return String(value || '')
89
+ .split(',')
90
+ .map((s) => s.trim().toLowerCase())
91
+ .filter(Boolean);
92
+ }
93
+
94
+ function getFlag(name) {
95
+ const idx = process.argv.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
96
+ if (idx === -1) return null;
97
+ const arg = process.argv[idx];
98
+ if (arg.includes('=')) return arg.slice(arg.indexOf('=') + 1);
99
+ const next = process.argv[idx + 1];
100
+ return next && !next.startsWith('--') ? next : null;
101
+ }
102
+
103
+ /**
104
+ * @param {{appName?: string}} [config]
105
+ */
106
+ function createNetGuard(config = {}) {
107
+ const appName = config.appName || 'This app';
108
+
109
+ const BIND_HOST = getFlag('host') || process.env.HOST || '127.0.0.1';
110
+ const BIND_HOST_LC = BIND_HOST.toLowerCase();
111
+ const EXPOSED = !isLoopbackAddress(BIND_HOST);
112
+ // Resolved here and re-exported so the hub can forward the same value to its
113
+ // children rather than re-parsing the flag with its own argv scanner — a parent
114
+ // and child that disagree means the shell serves while every iframe 403s.
115
+ const ALLOWED_HOSTS = getFlag('allowed-hosts') || process.env.ALLOWED_HOSTS || '';
116
+ const extraHosts = new Set(parseList(ALLOWED_HOSTS));
117
+
118
+ // The hub frames each sub-app, so its origin must be accepted as an Origin and
119
+ // permitted as a framing ancestor.
120
+ let hubOrigin = null;
121
+ if (process.env.HUB_URL) {
122
+ try { hubOrigin = new URL(process.env.HUB_URL).origin; } catch { /* ignore a malformed HUB_URL */ }
123
+ }
124
+
125
+ // Resolved only once listen() succeeds — the requested port may have been busy.
126
+ let actualPort = null;
127
+
128
+ function hostAllowed(host) {
129
+ if (!host) return false;
130
+ if (isLoopbackAddress(host)) return true;
131
+ if (extraHosts.has(host)) return true;
132
+ // Binding a specific non-loopback address implies that address is a valid name for us.
133
+ if (EXPOSED && host === BIND_HOST_LC) return true;
134
+ return false;
135
+ }
136
+
137
+ function hostGuard(req, res, next) {
138
+ const host = parseHostHeader(req.headers.host);
139
+ if (hostAllowed(host)) return next();
140
+ res.status(403).type('text/plain').send(
141
+ `403 Forbidden - unrecognized Host header: ${req.headers.host || '(none)'}\n\n` +
142
+ `${appName} only answers requests addressed to localhost. This protects you\n` +
143
+ `from DNS rebinding, where a website you visit re-points its own hostname at\n` +
144
+ `127.0.0.1 in order to read your local data.\n\n` +
145
+ `If you meant to reach this from another machine, restart with:\n` +
146
+ ` --host 0.0.0.0 --allowed-hosts=${host || '<your-hostname>'}\n\n` +
147
+ `Only do that on a network you trust - there is no authentication.\n`
148
+ );
149
+ }
150
+
151
+ // true = allow, false = block, null = no Origin header, caller decides.
152
+ function originVerdict(origin) {
153
+ if (!origin || origin === 'null') return null;
154
+ let parsed;
155
+ try { parsed = new URL(origin); } catch { return false; }
156
+ if (hubOrigin && parsed.origin === hubOrigin) return true;
157
+ if (!isLoopbackAddress(parsed.hostname)) return false;
158
+ // Same port, any loopback spelling. A different local port is a different app
159
+ // and has no business driving this one.
160
+ if (actualPort && parsed.port && Number(parsed.port) !== Number(actualPort)) return false;
161
+ return true;
162
+ }
163
+
164
+ function originGuard(req, res, next) {
165
+ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
166
+
167
+ const verdict = originVerdict(req.headers.origin);
168
+ if (verdict === true) return next();
169
+ if (verdict === false) {
170
+ return res.status(403).type('text/plain')
171
+ .send(`403 Forbidden - cross-origin ${req.method} from ${req.headers.origin} is not allowed.\n`);
172
+ }
173
+
174
+ // No Origin header: a non-browser client (curl, the CLI, the hub's own fetch).
175
+ // Trust it unless the browser explicitly tells us otherwise.
176
+ const site = req.headers['sec-fetch-site'];
177
+ if (site && site !== 'same-origin' && site !== 'none') {
178
+ return res.status(403).type('text/plain')
179
+ .send(`403 Forbidden - cross-site ${req.method} (Sec-Fetch-Site: ${site}) is not allowed.\n`);
180
+ }
181
+ return next();
182
+ }
183
+
184
+ // Constant for the life of the process, and frameGuard runs ahead of
185
+ // express.static — so this is built once rather than per asset fetch.
186
+ //
187
+ // Under the hub, X-Frame-Options is deliberately omitted: it has no multi-origin
188
+ // form, and sending SAMEORIGIN would break the hub's iframes.
189
+ //
190
+ // The port is wildcarded rather than pinned to HUB_URL's. The hub spawns its
191
+ // children before it binds, so if its own preferred port is busy it hands them a
192
+ // HUB_URL with the wrong port and every iframe would break. Any loopback port may
193
+ // frame us; that is not the threat being addressed here — a remote page still
194
+ // cannot, which is what matters.
195
+ //
196
+ // No IPv6 literal: Chrome rejects `http://[::1]:*` as a source expression and
197
+ // logs it on every framed load. The hub's own origin is added verbatim instead,
198
+ // which covers a hub reached over [::1].
199
+ let CSP;
200
+ if (hubOrigin) {
201
+ const ancestors = ["'self'", 'http://localhost:*', 'http://127.0.0.1:*'];
202
+ if (!/^http:\/\/(localhost|127\.0\.0\.1)(:|$)/.test(hubOrigin)) ancestors.push(hubOrigin);
203
+ CSP = `frame-ancestors ${ancestors.join(' ')}`;
204
+ } else {
205
+ CSP = "frame-ancestors 'none'";
206
+ }
207
+
208
+ function frameGuard(_req, res, next) {
209
+ res.setHeader('X-Content-Type-Options', 'nosniff');
210
+ res.setHeader('Referrer-Policy', 'no-referrer');
211
+ res.setHeader('Content-Security-Policy', CSP);
212
+ if (!hubOrigin) res.setHeader('X-Frame-Options', 'DENY');
213
+ next();
214
+ }
215
+
216
+ /**
217
+ * Bind BIND_HOST, then opportunistically serve the other loopback family on the
218
+ * same resolved port via a second server sharing the same request handler.
219
+ *
220
+ * That second bind is what keeps `http://localhost:<port>` working no matter how
221
+ * the OS resolver orders A/AAAA records - binding only 127.0.0.1 breaks hosts
222
+ * where `localhost` resolves to ::1, and vice versa. Doing it on the *resolved*
223
+ * port means every existing URL, startup banner and postMessage origin is
224
+ * unchanged. Failures are swallowed: it is an optimization, not a requirement.
225
+ *
226
+ * Returns the primary server, so callers keep attaching their own 'error'
227
+ * handler for the EADDRINUSE fallback.
228
+ *
229
+ * @param {any} app express app (or any http request handler)
230
+ * @param {number} port 0 selects a free port
231
+ * @param {(port: number) => void} [onReady]
232
+ * @param {{maxHeaderSize?: number}} [opts]
233
+ */
234
+ function listenLoopback(app, port, onReady, opts = {}) {
235
+ const http = require('http');
236
+ const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
237
+ const makeServer = (handler) => http.createServer(serverOpts, handler);
238
+ const server = makeServer(app);
239
+ let secondary = null;
240
+
241
+ server.on('listening', () => {
242
+ actualPort = server.address().port;
243
+ if (!EXPOSED) {
244
+ const otherFamily = BIND_HOST.includes(':') ? '127.0.0.1' : '::1';
245
+ try {
246
+ secondary = makeServer(app);
247
+ secondary.on('error', () => {}); // that family may not exist on this host
248
+ secondary.listen({ host: otherFamily, port: actualPort, ipv6Only: otherFamily === '::1' });
249
+ } catch { /* best effort */ }
250
+ }
251
+ if (onReady) onReady(actualPort);
252
+ });
253
+ server.on('close', () => { try { if (secondary) secondary.close(); } catch { /* already gone */ } });
254
+
255
+ server.listen(port, BIND_HOST);
256
+ return server;
257
+ }
258
+
259
+ function exposureWarning() {
260
+ if (!EXPOSED) return null;
261
+ return `WARNING: listening on ${BIND_HOST} - reachable from your network, with no authentication.`;
262
+ }
263
+
264
+ return { BIND_HOST, ALLOWED_HOSTS, hostGuard, originGuard, frameGuard, listenLoopback, exposureWarning };
265
+ }
266
+
267
+ module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress };
package/package.json CHANGED
@@ -1,17 +1,19 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
7
- "claude-code-hub": "./server.js"
7
+ "claude-code-hub": "server.js"
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node server.js",
11
11
  "dev": "node server.js --open",
12
- "lint": "npx @biomejs/biome check public/app.js server.js",
12
+ "lint": "npx @biomejs/biome check public/app.js server.js && npm run check:escaping && npm run check:security-lib",
13
13
  "lint:fix": "npx @biomejs/biome check --fix public/app.js server.js",
14
- "prepare": "husky"
14
+ "prepare": "husky",
15
+ "check:escaping": "node scripts/check-escaping.mjs",
16
+ "check:security-lib": "bash scripts/sync-security-lib.sh --check"
15
17
  },
16
18
  "repository": {
17
19
  "type": "git",
@@ -35,10 +37,10 @@
35
37
  },
36
38
  "homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
37
39
  "dependencies": {
38
- "claude-code-cost": "^0.12.1",
39
- "claude-code-kanban": "^4.17.0",
40
- "claude-code-marketplace": "^0.12.1",
41
- "claude-code-memory-explorer": "^0.9.1",
40
+ "claude-code-cost": "^0.14.0",
41
+ "claude-code-kanban": "^4.19.0",
42
+ "claude-code-marketplace": "^0.13.0",
43
+ "claude-code-memory-explorer": "^0.10.0",
42
44
  "express": "^4.21.0",
43
45
  "open": "^10.1.0"
44
46
  },
@@ -47,7 +49,8 @@
47
49
  },
48
50
  "files": [
49
51
  "server.js",
50
- "public/**/*"
52
+ "public/**/*",
53
+ "lib/**/*"
51
54
  ],
52
55
  "devDependencies": {
53
56
  "@biomejs/biome": "^2.4.10",
package/public/app.js CHANGED
@@ -68,6 +68,10 @@ function originOf(appId) {
68
68
  }
69
69
 
70
70
  function postTo(appId, message) {
71
+ // Before the iframe commits its src its contentWindow is still on the hub's own
72
+ // origin, so a send addressed to the sub-app origin is refused and logged. The
73
+ // load handler posts the current state anyway, so skipping here loses nothing.
74
+ if (!loadedApps.has(appId)) return;
71
75
  iframes[appId]?.contentWindow?.postMessage(message, originOf(appId));
72
76
  }
73
77
 
package/server.js CHANGED
@@ -3,8 +3,8 @@
3
3
  const { spawn } = require('child_process');
4
4
  const express = require('express');
5
5
  const fs = require('fs');
6
- const http = require('http');
7
6
  const path = require('path');
7
+ const { createNetGuard } = require('./lib/net-guard');
8
8
 
9
9
  function getArg(name) {
10
10
  const idx = process.argv.findIndex((a) => a.startsWith(`--${name}`));
@@ -23,6 +23,11 @@ const MEMORY_PORT = parseInt(getArg('memory-port') || '3544', 10);
23
23
  const children = [];
24
24
  const actualPorts = { marketplace: MARKETPLACE_PORT, kanban: KANBAN_PORT, cost: COST_PORT, memory: MEMORY_PORT };
25
25
 
26
+ // Created before spawnApp so the same host/allowed-hosts decision reaches the
27
+ // children. Without that propagation, `--host 0.0.0.0` would expose the hub shell
28
+ // while every iframe 403'd on its own Host check.
29
+ const net = createNetGuard({ appName: 'Claude Code Hub' });
30
+
26
31
  function spawnApp(name, cmd, args, envPort) {
27
32
  const child = spawn(cmd, args, {
28
33
  cwd: __dirname,
@@ -31,6 +36,8 @@ function spawnApp(name, cmd, args, envPort) {
31
36
  PORT: String(envPort),
32
37
  CLAUDE_HUB: '1',
33
38
  HUB_URL: `http://localhost:${HUB_PORT}`,
39
+ HOST: net.BIND_HOST,
40
+ ALLOWED_HOSTS: net.ALLOWED_HOSTS,
34
41
  },
35
42
  stdio: ['ignore', 'pipe', 'pipe'],
36
43
  });
@@ -105,7 +112,8 @@ const memoryPath = resolveApp('memory', 'claude-code-memory-explorer');
105
112
 
106
113
  // Raise header size limit to 64KB — localhost cookies from sibling apps can pile up and
107
114
  // trip Node's default 16KB limit, breaking iframes with HTTP 431.
108
- const NODE_HDR = '--max-http-header-size=65536';
115
+ const HDR_BYTES = 65536;
116
+ const NODE_HDR = `--max-http-header-size=${HDR_BYTES}`;
109
117
 
110
118
  spawnApp('marketplace', process.execPath, [NODE_HDR, marketplacePath, `--port=${MARKETPLACE_PORT}`], MARKETPLACE_PORT);
111
119
  spawnApp('kanban', process.execPath, [NODE_HDR, kanbanPath], KANBAN_PORT);
@@ -114,6 +122,12 @@ spawnApp('memory', process.execPath, [NODE_HDR, memoryPath, `--port=${MEMORY_POR
114
122
 
115
123
  const app = express();
116
124
 
125
+ // Mounted before the routes below, which are registered ahead of the first
126
+ // app.use() and would otherwise bypass the guards entirely.
127
+ app.use(net.hostGuard);
128
+ app.use(net.frameGuard);
129
+ app.use(net.originGuard);
130
+
117
131
  // The accent hex per color theme, read from the same registry generate-themes.mjs compiles into each
118
132
  // sub-app's themes.css. Served rather than hand-copied into public/app.js so there is one source of
119
133
  // truth; the palette is the hub's only themed surface, so only ember (the accent role) is needed.
@@ -187,31 +201,26 @@ app.get('/api/resolve-path', (req, res) => {
187
201
 
188
202
  app.use(express.static(path.join(__dirname, 'public')));
189
203
 
190
- const server = http.createServer({ maxHeaderSize: 65536 }, app);
191
- server.listen(HUB_PORT, () => {
192
- const actual = server.address().port;
204
+ const onReady = (actual) => {
193
205
  printBanner(actual);
194
206
  if (process.argv.includes('--open')) {
195
207
  import('open').then((m) => m.default(`http://localhost:${actual}`));
196
208
  }
197
- });
209
+ };
210
+
211
+ const server = net.listenLoopback(app, HUB_PORT, onReady, { maxHeaderSize: HDR_BYTES });
198
212
 
199
213
  function printBanner(port) {
200
214
  console.log(`Claude Code Hub running at http://localhost:${port}`);
215
+ const warning = net.exposureWarning();
216
+ if (warning) console.log(warning);
201
217
  console.log('Type "q" or "exit" to stop the server');
202
218
  }
203
219
 
204
220
  server.on('error', (err) => {
205
221
  if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
206
222
  console.log(`Port ${HUB_PORT} in use, trying random port...`);
207
- const fallback = http.createServer({ maxHeaderSize: 65536 }, app);
208
- fallback.listen(0, () => {
209
- const actual = fallback.address().port;
210
- printBanner(actual);
211
- if (process.argv.includes('--open')) {
212
- import('open').then((m) => m.default(`http://localhost:${actual}`));
213
- }
214
- });
223
+ net.listenLoopback(app, 0, onReady, { maxHeaderSize: HDR_BYTES });
215
224
  } else {
216
225
  throw err;
217
226
  }