claude-code-hub 0.21.0 → 0.23.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.
- package/lib/net-guard.js +267 -0
- package/package.json +12 -9
- package/public/app.js +22 -2
- package/server.js +23 -14
package/lib/net-guard.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.23.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": "
|
|
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.
|
|
39
|
-
"claude-code-kanban": "^4.
|
|
40
|
-
"claude-code-marketplace": "^0.
|
|
41
|
-
"claude-code-memory-explorer": "^0.
|
|
40
|
+
"claude-code-cost": "^0.13.0",
|
|
41
|
+
"claude-code-kanban": "^4.18.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
|
|
|
@@ -227,8 +231,15 @@ function listenKeys() {
|
|
|
227
231
|
return;
|
|
228
232
|
}
|
|
229
233
|
// While the palette is open the input owns the keyboard — don't let tab shortcuts fire
|
|
230
|
-
// mid-path (Alt+digit especially, since Windows paths contain digits).
|
|
231
|
-
|
|
234
|
+
// mid-path (Alt+digit especially, since Windows paths contain digits). Escape still closes:
|
|
235
|
+
// bound here as well as on the input so it works wherever focus landed in the hub document.
|
|
236
|
+
if (palette.open) {
|
|
237
|
+
if (e.key === 'Escape') {
|
|
238
|
+
e.preventDefault();
|
|
239
|
+
closePalette();
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
232
243
|
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
|
|
233
244
|
const digit = parseInt(e.key, 10);
|
|
234
245
|
if (digit >= 1 && digit <= 9) {
|
|
@@ -417,6 +428,10 @@ function openPalette() {
|
|
|
417
428
|
palette.sel = 0;
|
|
418
429
|
input.value = '';
|
|
419
430
|
document.getElementById('palette').hidden = false;
|
|
431
|
+
// Ctrl+Alt+P usually arrives forwarded from a focused iframe. Without inert the sub-app can keep
|
|
432
|
+
// or take focus back, and then Escape is handled inside it — the palette stays open and only the
|
|
433
|
+
// sub-app's own focus visibly changes.
|
|
434
|
+
setIframesInert(true);
|
|
420
435
|
renderPalette();
|
|
421
436
|
input.focus();
|
|
422
437
|
// Stale-while-revalidate: the cached list renders instantly, recency refreshes when this lands.
|
|
@@ -430,9 +445,14 @@ function openPalette() {
|
|
|
430
445
|
function closePalette() {
|
|
431
446
|
palette.open = false;
|
|
432
447
|
document.getElementById('palette').hidden = true;
|
|
448
|
+
setIframesInert(false);
|
|
433
449
|
iframes[activeApp]?.focus();
|
|
434
450
|
}
|
|
435
451
|
|
|
452
|
+
function setIframesInert(on) {
|
|
453
|
+
for (const iframe of Object.values(iframes)) iframe.inert = on;
|
|
454
|
+
}
|
|
455
|
+
|
|
436
456
|
function movePaletteSel(delta) {
|
|
437
457
|
const n = palette.rows.length;
|
|
438
458
|
if (n === 0) return;
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|