@mmmbuto/nexuscrew 0.9.66 → 0.9.67

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.
@@ -22,6 +22,7 @@
22
22
  // raw socket TCP (upgrade WS). resolveNode/httpRequest/connect iniettabili (test).
23
23
  const http = require('node:http');
24
24
  const net = require('node:net');
25
+ const { Transform } = require('node:stream');
25
26
  const { NODE_NAME_RE } = require('../nodes/store.js');
26
27
  const { bearerFrom } = require('../auth/middleware.js');
27
28
 
@@ -142,6 +143,77 @@ function sanitizeResponseHeaders(headers) {
142
143
  return out;
143
144
  }
144
145
 
146
+ // Answers of a host-only peer: the peer is published one way, so nothing it says
147
+ // may act on the origin of the PWA. Its cookies, its Clear-Site-Data and its CSP
148
+ // are dropped; the body is served sandboxed and without MIME sniffing.
149
+ const HOST_ONLY_STRIPPED_HEADERS = new Set([
150
+ 'set-cookie', 'set-cookie2', 'clear-site-data', 'content-security-policy', 'content-security-policy-report-only',
151
+ 'x-content-type-options', 'cross-origin-opener-policy', 'cross-origin-embedder-policy', 'cross-origin-resource-policy',
152
+ 'location', 'content-location', 'refresh',
153
+ ]);
154
+ function sanitizeHostOnlyResponseHeaders(headers) {
155
+ const out = {};
156
+ for (const [k, v] of Object.entries(sanitizeResponseHeaders(headers))) {
157
+ if (HOST_ONLY_STRIPPED_HEADERS.has(k.toLowerCase())) continue;
158
+ out[k] = v;
159
+ }
160
+ out['content-security-policy'] = "sandbox; default-src 'none'";
161
+ out['x-content-type-options'] = 'nosniff';
162
+ return out;
163
+ }
164
+
165
+ // The head of an upgrade answer from a host-only peer: the same headers dropped as on the HTTP
166
+ // answers (cookies, Clear-Site-Data, CSP). The head is parsed the way the most lenient consumer
167
+ // would read it, so what is dropped here cannot reappear there: the end of the head is the first
168
+ // empty line (CRLF or bare LF), lines end with CRLF or bare LF, folded (continuation) lines are
169
+ // unfolded, a header name is a strict token. Anything that does not parse that way — a bare CR, a
170
+ // NUL, a line without a colon or with a malformed name, a first line that is not a status line, a
171
+ // head over 16 KB — is refused (the stream errors, the connection is closed) instead of passed
172
+ // along. Bytes after the head pass as they are.
173
+ const HOST_ONLY_HEAD_DROPPED = new Set(['set-cookie', 'set-cookie2', 'clear-site-data', 'content-security-policy', 'content-security-policy-report-only']);
174
+ const HOST_ONLY_HEAD_MAX = 16384;
175
+ function sanitizeUpgradeHead(headText) {
176
+ if (/\0/.test(headText) || /\r(?!\n)/.test(headText)) throw new Error('upgrade head with a NUL or a bare CR');
177
+ const raw = headText.split(/\r?\n/);
178
+ // The ONLY answer to an upgrade that is passed along is a 101: a refusal, a 100 Continue (an interim answer
179
+ // that would let a second head slip through) or anything else is refused here.
180
+ if (!/^HTTP\/1\.[01] 101(?: [^\r\n]*)?$/.test(raw[0])) throw new Error('upgrade answer of a host-only peer is not a 101');
181
+ const logical = [];
182
+ for (const line of raw.slice(1)) {
183
+ if (/^[ \t]/.test(line)) { // obs-fold: continuation of the previous header
184
+ if (!logical.length) throw new Error('upgrade head with a continuation before any header');
185
+ logical[logical.length - 1] += ` ${line.trim()}`;
186
+ } else logical.push(line);
187
+ }
188
+ const kept = [raw[0]];
189
+ for (const line of logical) {
190
+ const i = line.indexOf(':');
191
+ if (i <= 0) throw new Error('upgrade head with a malformed header line');
192
+ const name = line.slice(0, i).trim();
193
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) throw new Error('upgrade head with a malformed header name');
194
+ if (HOST_ONLY_HEAD_DROPPED.has(name.toLowerCase())) continue;
195
+ kept.push(`${name}:${line.slice(i + 1)}`);
196
+ }
197
+ return `${kept.join('\r\n')}\r\n\r\n`;
198
+ }
199
+ function hostOnlyHeadFilter() {
200
+ let buf = Buffer.alloc(0); let done = false;
201
+ return new Transform({
202
+ transform(chunk, _enc, cb) {
203
+ if (done) return cb(null, chunk);
204
+ buf = Buffer.concat([buf, chunk]);
205
+ const window = buf.toString('latin1', 0, Math.min(buf.length, HOST_ONLY_HEAD_MAX + 4));
206
+ const m = /\r?\n\r?\n/.exec(window);
207
+ if (!m || m.index > HOST_ONLY_HEAD_MAX) return buf.length > HOST_ONLY_HEAD_MAX ? cb(new Error('upgrade head too large')) : cb();
208
+ let head;
209
+ try { head = sanitizeUpgradeHead(window.slice(0, m.index)); } catch (e) { return cb(e); }
210
+ const rest = buf.subarray(m.index + m[0].length);
211
+ done = true; buf = null;
212
+ return cb(null, Buffer.concat([Buffer.from(head, 'latin1'), rest]));
213
+ },
214
+ });
215
+ }
216
+
145
217
  function notFound(res) { res.status(404).json({ error: 'not found' }); }
146
218
 
147
219
  // --- HTTP proxy middleware --------------------------------------------------
@@ -186,7 +258,12 @@ function proxyHttp(req, res, node, rest, search, httpRequest, timeoutMs) {
186
258
  let upstream;
187
259
  try {
188
260
  upstream = httpRequest(options, (up) => {
189
- res.writeHead(up.statusCode, sanitizeResponseHeaders(up.headers));
261
+ if (node.hostOnly === true && up.statusCode >= 300 && up.statusCode < 400) {
262
+ // A redirect from a host-only peer would be followed by the browser WITH the owner's Authorization.
263
+ up.resume();
264
+ return res.status(502).json({ error: 'redirect refused', reason: 'host-only-redirect' });
265
+ }
266
+ res.writeHead(up.statusCode, (node.hostOnly === true ? sanitizeHostOnlyResponseHeaders : sanitizeResponseHeaders)(up.headers));
190
267
  up.pipe(res);
191
268
  });
192
269
  } catch (_) {
@@ -327,7 +404,13 @@ function handleNodeUpgrade(ctx) {
327
404
  socket.on('error', () => { try { upstream.destroy(); } catch (_) {} });
328
405
  upstream.on('error', () => { try { socket.destroy(); } catch (_) {} });
329
406
  socket.pipe(upstream);
330
- upstream.pipe(socket);
407
+ let down = upstream;
408
+ if (node.hostOnly === true) {
409
+ const filter = hostOnlyHeadFilter();
410
+ filter.on('error', () => { try { upstream.destroy(); } catch (_) {} abortUpgrade(socket, 502); });
411
+ down = upstream.pipe(filter);
412
+ }
413
+ down.pipe(socket);
331
414
  });
332
415
  upstream.on('error', () => { clearTimeout(timer); fail(502); });
333
416
  }
@@ -340,7 +423,7 @@ module.exports = {
340
423
  createNodeProxy,
341
424
  handleNodeUpgrade,
342
425
  // esposti per i test
343
- splitNodePath, isTransitiveRest, sanitizeRequestHeaders, sanitizeResponseHeaders,
426
+ splitNodePath, isTransitiveRest, sanitizeRequestHeaders, sanitizeResponseHeaders, sanitizeHostOnlyResponseHeaders, hostOnlyHeadFilter,
344
427
  buildUpgradeRequest, stripLocalTokenQuery, isStrippedRequestHeader, connectionOptions, bearerFromUpgrade,
345
428
  MUTATING, HOP_BY_HOP, PROXY_TIMEOUT_MS,
346
429
  };
package/lib/server.js CHANGED
@@ -501,7 +501,7 @@ function createServer(opts = {}) {
501
501
  if (!st) return null;
502
502
  const node = nodesStore.getNode(st, name);
503
503
  if (!node) return null;
504
- return { localPort: node.localPort, token: node.token || null };
504
+ return { localPort: node.localPort, token: node.token || null, hostOnly: node.publishRemote === true };
505
505
  }
506
506
 
507
507
  const runtimePort = () => (server && server.address() ? server.address().port : cfg.port);
@@ -994,6 +994,15 @@ function createServer(opts = {}) {
994
994
  const resource = req.url.slice(i + 2);
995
995
  if (!/^\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/.test(resource)) return next();
996
996
  if (req.method === 'POST' && /^\/panel\/[A-Za-z0-9._-]{1,32}\/ticket\/?$/.test(resource)) return next();
997
+ // The unauthenticated door exists only for a browser frame, which carries no
998
+ // header and arrives on this machine's own listener. It never opens for: a
999
+ // request with ANY Authorization (the local token goes through requireToken like
1000
+ // every other /api call; a peer's token is not the owner's); a request that came
1001
+ // in on a peer-bound reverse listener; a route that names a host-only peer at
1002
+ // any hop. Those fall through to requireToken, whatever the first hop is.
1003
+ const onPeerListener = reverseSlotListeners && reverseSlotListeners.isSlotPort(req.socket && req.socket.localPort);
1004
+ if (bearerFrom(req) || onPeerListener
1005
+ || federation.touchesHostOnly(nodesPath, { route: federation.routeNamesOf(req.url) })) return next();
997
1006
  panelFederatoRouter(req, res, next);
998
1007
  });
999
1008
  api.use(requireToken(tokenStore));
@@ -1161,12 +1170,28 @@ function createServer(opts = {}) {
1161
1170
  }
1162
1171
  return out;
1163
1172
  };
1164
- api.get('/config', (_req, res) => res.json({
1173
+ // A host-only peer is named only to the readers it is published to; a federated
1174
+ // reader is identified by the verified hop proof, never by the bare header.
1175
+ const hostOnlyNodesFor = (req) => {
1176
+ const st = nodesStore.loadStore(nodesPath);
1177
+ const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
1178
+ if (!st || vis.unreadable) return [];
1179
+ return st.nodes.filter((n) => n.publishRemote === true && !vis.hiddenNames.has(n.name)).map((n) => n.name);
1180
+ };
1181
+ const nodePanelPortsFor = (req) => {
1182
+ const out = nodePanelPorts();
1183
+ const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
1184
+ for (const name of vis.hiddenNames) delete out[name];
1185
+ if (vis.unreadable) return {};
1186
+ return out;
1187
+ };
1188
+ api.get('/config', (req, res) => res.json({
1165
1189
  readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
1166
1190
  bind: cfg.bind, port: cfg.port, panelPort: cfg.panelPort,
1167
1191
  protectSharedTmuxServer: cfg.protectSharedTmuxServer !== false,
1168
1192
  instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
1169
- nodePanelPorts: nodePanelPorts(),
1193
+ nodePanelPorts: nodePanelPortsFor(req),
1194
+ hostOnlyNodes: hostOnlyNodesFor(req),
1170
1195
  presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
1171
1196
  // i parametri del terminale sono configurabili dal server e leggibili
1172
1197
  // dal client (che ne ha comunque i default iniettabili).
@@ -1913,6 +1938,9 @@ function createServer(opts = {}) {
1913
1938
  },
1914
1939
  localPort: () => (server && server.address() ? server.address().port : cfg.port),
1915
1940
  localToken: () => tokenHolder.value,
1941
+ // A federated request that terminates here is never re-sent to a host-only
1942
+ // peer (checked on the target and route actually resolved, store read now).
1943
+ federatedGuard: ({ target, route }) => federation.touchesHostOnly(nodesPath, { target, route }),
1916
1944
  }),
1917
1945
  getGroup: (name) => audioGroups.getGroup(audioCfg, name, audioHome),
1918
1946
  }));
@@ -1971,7 +1999,15 @@ function createServer(opts = {}) {
1971
1999
  peers: federatedPeers,
1972
2000
  localPort: () => (server && server.address() ? server.address().port : cfg.port),
1973
2001
  localToken: () => tokenHolder.value,
2002
+ // Never designate, query or bridge to a host-only peer, whatever the route.
2003
+ ownerGuard: ({ target, route }) => federation.touchesHostOnly(nodesPath, { target, route }),
1974
2004
  }),
2005
+ // A designation that already points at a host-only peer is not shown to a
2006
+ // federated reader outside its audience (it would name the peer).
2007
+ ownerHiddenFor: (req, ownerId) => {
2008
+ const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
2009
+ return vis.unreadable || vis.hiddenIds.has(ownerId);
2010
+ },
1975
2011
  originResolver: createOriginResolver({ localNodeId: federatedNodeId, hopSecret: () => hopSecret }),
1976
2012
  federatedRate: createSpeakRateLimiter(),
1977
2013
  }));
@@ -1981,6 +2017,30 @@ function createServer(opts = {}) {
1981
2017
  api.use('/vl-nodes', vlNodeApiRoutes({
1982
2018
  storePath: vlNodesPath, broker: vlNodeBroker, ownerId: vlOwnerId, readonly: proxyReadonly,
1983
2019
  }));
2020
+ // A deck keeps references to nodes (tiles, floating windows): those that point at a host-only peer are not
2021
+ // served to a federated reader outside its audience, in any answer (the 409 carries the current deck too).
2022
+ api.use('/decks', (req, res, next) => {
2023
+ const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
2024
+ if (!vis.hiddenNames.size && !vis.hiddenIds.size && !vis.unreadable) return next();
2025
+ // A reader served a filtered deck must not be able to save it back and erase what it was not shown:
2026
+ // changing a deck that carries references it cannot see is refused (and says nothing about them).
2027
+ if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') {
2028
+ const first = String(req.url || '').split('?')[0].split('/').filter(Boolean)[0];
2029
+ let target = null; try { target = first ? decodeURIComponent(first) : null; } catch (_) { target = ''; }
2030
+ if (vis.unreadable) return res.status(503).json({ error: 'non disponibile' });
2031
+ let hiddenContent = false;
2032
+ try {
2033
+ // creating a deck (no name in the path) overwrites nothing: only the deck being changed is looked at
2034
+ const st = target === null ? null : decksStore.loadStore(decksPath);
2035
+ const touched = (st ? st.decks : []).filter((d) => d.name === target);
2036
+ hiddenContent = touched.some((d) => JSON.stringify(federation.redactHostOnlyReferences(d, vis)) !== JSON.stringify(d));
2037
+ } catch (_) { return res.status(503).json({ error: 'non disponibile' }); }
2038
+ if (hiddenContent) return res.status(403).json({ error: 'deck non modificabile da questo nodo', reason: 'deck-has-hidden-content' });
2039
+ }
2040
+ const json = res.json.bind(res);
2041
+ res.json = (body) => json(vis.unreadable ? { error: 'non disponibile' } : federation.redactHostOnlyReferences(body, vis));
2042
+ return next();
2043
+ });
1984
2044
  api.use('/decks', decksRoutes({ cfg, decksPath }));
1985
2045
  api.use('/prefs', prefsRoutes({ cfg, prefsPath }));
1986
2046
  api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
@@ -2067,9 +2127,37 @@ function createServer(opts = {}) {
2067
2127
  audio: { adapter: audioAdapter, queue: audioQueue },
2068
2128
  reverseSlots: { ensure: startRotatableReverse, close: stopRotatableReverse, verify: verifyRotatablePool },
2069
2129
  }));
2130
+ // Records that name a host-only peer are served only to the readers it is
2131
+ // published to. The WHOLE record is inspected (name or id anywhere in it).
2132
+ api.use('/diagnostics', (req, res, next) => {
2133
+ const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
2134
+ if (!vis.hiddenNames.size && !vis.hiddenIds.size && !vis.unreadable) return next();
2135
+ const esc = (v) => String(v).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2136
+ const names = [...vis.hiddenNames].map((n) => new RegExp(`(^|[^A-Za-z0-9_-])${esc(n)}([^A-Za-z0-9_-]|$)`));
2137
+ const ids = [...vis.hiddenIds];
2138
+ const mentions = (rec) => { const t = JSON.stringify(rec); return ids.some((id) => t.includes(id)) || names.some((re) => re.test(t)); };
2139
+ const json = res.json.bind(res);
2140
+ res.json = (body) => {
2141
+ if (vis.unreadable) return json({ error: 'non disponibile' });
2142
+ if (body && Array.isArray(body.records)) return json({ ...body, records: body.records.filter((r) => !mentions(r)) });
2143
+ return json(body);
2144
+ };
2145
+ return next();
2146
+ });
2070
2147
  api.use('/diagnostics', diagnosticsRoutes({ diagnostics, readonly: proxyReadonly }));
2071
- api.get('/topology', async (_req, res) => {
2072
- try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
2148
+ api.get('/topology', async (req, res) => {
2149
+ try {
2150
+ // What was hidden when the request began stays hidden: a publication that ends during the wait does not
2151
+ // hand its leaf to a reader that was not allowed to see it when it asked.
2152
+ const visBefore = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
2153
+ const topo = await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath });
2154
+ // Decided AFTER the fan-out wait and on the final result, cache and stale
2155
+ // entries included: the policy is read again now, not before the await.
2156
+ const vis = federation.hostOnlyVisibility(nodesPath, req, () => hopSecret);
2157
+ if (vis.unreadable || visBefore.unreadable) return res.json({ instanceId: topo && topo.instanceId, nodes: [] });
2158
+ const merged = { hiddenNames: new Set([...visBefore.hiddenNames, ...vis.hiddenNames]), hiddenIds: new Set([...visBefore.hiddenIds, ...vis.hiddenIds]) };
2159
+ res.json({ ...topo, nodes: federation.hideHostOnlyEntries(topo && topo.nodes, merged) });
2160
+ }
2073
2161
  catch (e) { res.status(500).json({ error: String(e.message || e) }); }
2074
2162
  });
2075
2163
  api.use('/route', federation.localRouter({
@@ -2208,7 +2296,10 @@ function createServer(opts = {}) {
2208
2296
  // emesso): transita e decide il proprietario, gate panelAccess compreso
2209
2297
  // — stessa forma del bypass HTTP sulle panel-resource federate.
2210
2298
  const panelFederato = /^\/api\/route\/[^?#]*\/_\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/.test(pathname)
2211
- && /(?:^|;\s*)npanel=/.test(String(req.headers.cookie || ''));
2299
+ && /(?:^|;\s*)npanel=/.test(String(req.headers.cookie || ''))
2300
+ && !bearerFrom(req) && !u.searchParams.get('token')
2301
+ && !(reverseSlotListeners && reverseSlotListeners.isSlotPort(req.socket && req.socket.localPort))
2302
+ && !federation.touchesHostOnly(nodesPath, { route: federation.routeNamesOf(pathname.slice('/api/route'.length)) });
2212
2303
  if (!panelFederato && !verify(tokenHolder.value, given)) return socket.destroy();
2213
2304
  federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret, hubAttaches });
2214
2305
  return;
@@ -2296,7 +2387,7 @@ function createServer(opts = {}) {
2296
2387
 
2297
2388
  return {
2298
2389
  app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater, diagnostics,
2299
- panelApp, panelServer, panelServerV6,
2390
+ panelApp, panelServer, panelServerV6, reverseSlotListeners,
2300
2391
  // Test/observability seam: publish a normalized state event through the
2301
2392
  // same per-peer projection the local producers use.
2302
2393
  notify: { publishState: notifier.publishState },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.66",
3
+ "version": "0.9.67",
4
4
  "description": "Faithful browser tmux client \u2014 attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {