@mmmbuto/nexuscrew 0.9.65 → 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.
- package/CHANGELOG.md +24 -0
- package/docs/NODES.md +55 -0
- package/docs/SECURITY.md +40 -0
- package/frontend/dist/assets/{index-DkYRC1vV.js → index-D__76yWR.js} +42 -42
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/audio/dispatch.js +14 -1
- package/lib/audio/routes.js +2 -2
- package/lib/cli/commands.js +31 -0
- package/lib/live-host/federation.js +10 -0
- package/lib/live-host/routes.js +12 -2
- package/lib/nodes/access-presets.js +27 -7
- package/lib/nodes/commands.js +63 -4
- package/lib/nodes/reverse-slot-listeners.js +4 -1
- package/lib/nodes/store.js +205 -12
- package/lib/notify/ask-cell.js +53 -0
- package/lib/notify/event-feed-client.js +40 -2
- package/lib/notify/event-feed-routes.js +8 -2
- package/lib/proxy/federation.js +308 -17
- package/lib/proxy/node-proxy.js +86 -3
- package/lib/server.js +126 -12
- package/lib/settings/routes.js +6 -0
- package/package.json +1 -1
package/lib/proxy/federation.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
const { createAskSlotVerifier, askSlotVerifier, binding: askSlotBinding } = require('../nodes/ask-slot-verifier.js');
|
|
4
4
|
const http = require('node:http');
|
|
5
5
|
const net = require('node:net');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const path = require('node:path');
|
|
6
8
|
const crypto = require('node:crypto');
|
|
7
9
|
const express = require('express');
|
|
8
10
|
const { WebSocketServer } = require('ws');
|
|
@@ -15,9 +17,9 @@ const { bearerFrom } = require('../auth/middleware.js');
|
|
|
15
17
|
const { safeEqual } = require('../nodes/peering.js');
|
|
16
18
|
const { probeReverseSlot } = require('../nodes/reverse-slot-proof.js');
|
|
17
19
|
const {
|
|
18
|
-
sanitizeRequestHeaders, sanitizeResponseHeaders, stripLocalTokenQuery,
|
|
20
|
+
sanitizeRequestHeaders, sanitizeResponseHeaders, sanitizeHostOnlyResponseHeaders, hostOnlyHeadFilter, stripLocalTokenQuery,
|
|
19
21
|
} = require('./node-proxy.js');
|
|
20
|
-
const { signHop, HOP_HEADER } = require('./hop-proof.js');
|
|
22
|
+
const { signHop, verifyHop, HOP_HEADER } = require('./hop-proof.js');
|
|
21
23
|
const { COOKIE_NAME: PANEL_COOKIE_NAME } = require('./panel-auth.js');
|
|
22
24
|
|
|
23
25
|
const MAX_HOPS = 4;
|
|
@@ -113,14 +115,206 @@ function peerAllows(peer, otherId) {
|
|
|
113
115
|
return Array.isArray(peer.selected) && peer.selected.includes(otherId);
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
// A host-only peer (publishRemote, schema 4) is a REMOTE node this installation
|
|
119
|
+
// publishes to an explicit audience of its own admin nodes. The boundary is
|
|
120
|
+
// one-way and does not depend on the peer's own claims or grants:
|
|
121
|
+
// audience -> peer : yes, while the reader is in the audience AND still holds
|
|
122
|
+
// the operator grant (re-read on every call);
|
|
123
|
+
// peer -> anyone : never (not another peer, not a topology, not a route);
|
|
124
|
+
// everyone else : no, whatever `visibility` or `shared` say.
|
|
125
|
+
function isHostOnlyPeer(peer) {
|
|
126
|
+
return !!peer && peer.publishRemote === true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function audienceAllows(reader, hostOnly) {
|
|
130
|
+
return Array.isArray(hostOnly.publishRemoteAudience)
|
|
131
|
+
&& typeof reader.nodeId === 'string'
|
|
132
|
+
&& hostOnly.publishRemoteAudience.includes(reader.nodeId)
|
|
133
|
+
&& !isHostOnlyPeer(reader)
|
|
134
|
+
&& allowedByClass('operator', reader);
|
|
135
|
+
}
|
|
136
|
+
|
|
116
137
|
function canTransit(ingress, egress) {
|
|
117
138
|
if (!ingress || !egress || ingress.name === egress.name) return !ingress;
|
|
139
|
+
// The ingress is the AUTHENTICATED peer record (looked up by its token), never
|
|
140
|
+
// something read from the query, a header or the visited chain.
|
|
141
|
+
if (isHostOnlyPeer(ingress)) return false;
|
|
142
|
+
if (isHostOnlyPeer(egress)) return audienceAllows(ingress, egress);
|
|
118
143
|
// `shared` is the explicit publication gate. Visibility remains the hub ACL,
|
|
119
144
|
// but it cannot make a private peer routable on its own.
|
|
120
145
|
return egress.shared === true
|
|
121
146
|
&& peerAllows(ingress, egress.nodeId) && peerAllows(egress, ingress.nodeId);
|
|
122
147
|
}
|
|
123
148
|
|
|
149
|
+
// The audience decision for a CHAIN, not only for the last hop. A host-only peer
|
|
150
|
+
// is published to its audience: every node that took part in the request (the
|
|
151
|
+
// ones in `visited`, this node excluded) must be an audience member that still
|
|
152
|
+
// holds the operator grant. An id this node does not know is refused — the chain
|
|
153
|
+
// is what the peers declared, so only what the local store can vouch for counts.
|
|
154
|
+
function chainInAudience(st, chain, egress) {
|
|
155
|
+
if (!isHostOnlyPeer(egress)) return true;
|
|
156
|
+
if (!st || !Array.isArray(st.nodes) || !Array.isArray(chain)) return false;
|
|
157
|
+
for (const id of chain) {
|
|
158
|
+
if (id === st.nodeId) continue;
|
|
159
|
+
const reader = st.nodes.find((n) => n && n.nodeId === id);
|
|
160
|
+
if (!reader || !audienceAllows(reader, egress)) return false;
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Does this target, or any hop of the route to it, resolve to a host-only peer
|
|
166
|
+
// of THIS node? Read from the store at the time of the call, never from a cache.
|
|
167
|
+
function touchesHostOnly(nodesPath, { target = null, route = [] } = {}) {
|
|
168
|
+
const st = store.loadStore(nodesPath);
|
|
169
|
+
if (!st) return true; // fail closed: an unreadable store vouches for nothing
|
|
170
|
+
const names = Array.isArray(route) ? route : [];
|
|
171
|
+
return st.nodes.some((n) => isHostOnlyPeer(n) && ((target && n.nodeId === target) || names.includes(n.name)));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The first segments of `/<hop>/<hop>/_/...` (what is left after the /route mount).
|
|
175
|
+
function routeNamesOf(rest) {
|
|
176
|
+
const parts = String(rest || '').split('?')[0].split('/').filter(Boolean);
|
|
177
|
+
const idx = parts.indexOf(ROUTE_DELIMITER);
|
|
178
|
+
return idx < 0 ? [] : parts.slice(0, idx);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The identity of the caller of a LOCAL API handler that is the last hop of a
|
|
182
|
+
// federated route. It exists only when the hop header verifies (HMAC over method,
|
|
183
|
+
// path and chain, the same proof audio/origin.js reads); the bare presence of the
|
|
184
|
+
// header proves nothing. Returns the verified chain, or null.
|
|
185
|
+
function verifiedHopChain(req, st, hopSecret) {
|
|
186
|
+
const headers = (req && req.headers) || {};
|
|
187
|
+
const proof = headers[HOP_HEADER];
|
|
188
|
+
const secret = typeof hopSecret === 'function' ? hopSecret() : hopSecret;
|
|
189
|
+
const visited = String(headers['x-nexuscrew-visited'] || '').split(',').filter(Boolean);
|
|
190
|
+
if (!secret || !st || typeof proof !== 'string' || visited.length < 2 || visited.length > MAX_HOPS + 1) return null;
|
|
191
|
+
if (visited.some((id) => !store.NODE_ID_RE.test(id)) || new Set(visited).size !== visited.length) return null;
|
|
192
|
+
if (visited.at(-1) !== st.nodeId) return null;
|
|
193
|
+
if (!verifyHop(secret, { method: req.method || 'GET', path: req.originalUrl || req.url, visited }, proof)) return null;
|
|
194
|
+
return visited;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Which host-only peers a local API caller must NOT learn about. The owner (a
|
|
198
|
+
// request without the hop header) sees everything; a federated caller sees only
|
|
199
|
+
// the peers whose audience contains its whole verified chain; any header that
|
|
200
|
+
// does not verify, or an unknown id, hides all of them. Read fresh each time.
|
|
201
|
+
function hostOnlyVisibility(nodesPath, req, hopSecret) {
|
|
202
|
+
const hiddenNames = new Set(); const hiddenIds = new Set();
|
|
203
|
+
const federated = Boolean(req && req.headers && req.headers[HOP_HEADER] !== undefined);
|
|
204
|
+
// The owner (no hop header) is never filtered, whatever the state of the store.
|
|
205
|
+
if (!federated) return { hiddenNames, hiddenIds, unreadable: false };
|
|
206
|
+
const st = store.loadStore(nodesPath);
|
|
207
|
+
if (!st) return { hiddenNames, hiddenIds, unreadable: true };
|
|
208
|
+
const chain = verifiedHopChain(req, st, hopSecret);
|
|
209
|
+
for (const n of st.nodes) {
|
|
210
|
+
if (!isHostOnlyPeer(n)) continue;
|
|
211
|
+
const visible = chain !== null && chainInAudience(st, chain, n);
|
|
212
|
+
if (!visible) { hiddenNames.add(n.name); if (n.nodeId) hiddenIds.add(n.nodeId); }
|
|
213
|
+
}
|
|
214
|
+
return { hiddenNames, hiddenIds, unreadable: false };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Topology entries of hidden host-only peers, and anything announced behind one.
|
|
218
|
+
function hideHostOnlyEntries(nodes, vis) {
|
|
219
|
+
if (!Array.isArray(nodes) || (!vis.hiddenNames.size && !vis.hiddenIds.size)) return nodes;
|
|
220
|
+
return nodes.filter((e) => !vis.hiddenIds.has(e && e.instanceId)
|
|
221
|
+
&& !(e && Array.isArray(e.route) && e.route.some((name) => vis.hiddenNames.has(name))));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Removes, from any JSON value, the objects that point at a hidden host-only peer: by route name (`node`, a path of
|
|
225
|
+
// names, or `route`), or by instance id (`ownerId`, `nodeId`, `instanceId`). Used on the answers of handlers that
|
|
226
|
+
// store references to nodes (decks).
|
|
227
|
+
function redactHostOnlyReferences(value, vis) {
|
|
228
|
+
if (!vis || (!vis.hiddenNames.size && !vis.hiddenIds.size)) return value;
|
|
229
|
+
const points = (o) => o && typeof o === 'object' && !Array.isArray(o) && (
|
|
230
|
+
(typeof o.node === 'string' && o.node.split('/').some((n) => vis.hiddenNames.has(n)))
|
|
231
|
+
|| ['ownerId', 'nodeId', 'instanceId'].some((k) => typeof o[k] === 'string' && vis.hiddenIds.has(o[k]))
|
|
232
|
+
|| (Array.isArray(o.route) && o.route.some((n) => vis.hiddenNames.has(n))));
|
|
233
|
+
const walk = (v) => {
|
|
234
|
+
if (Array.isArray(v)) return v.filter((e) => !points(e)).map(walk);
|
|
235
|
+
if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
|
|
236
|
+
return v;
|
|
237
|
+
};
|
|
238
|
+
return walk(value);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Streams (SSE, WebSocket) opened towards a host-only peer for a peer caller:
|
|
242
|
+
// the authorization is re-read about every second and a stream that no longer
|
|
243
|
+
// passes (audience, operator grant, mode off, peer or reader removed, store
|
|
244
|
+
// unreadable) is closed. Owner streams are not tracked.
|
|
245
|
+
const HOST_ONLY_RECHECK_MS = 1000;
|
|
246
|
+
const hostOnlyStreams = new Set();
|
|
247
|
+
let hostOnlyTimer = null;
|
|
248
|
+
const credentialFingerprint = (v) => crypto.createHash('sha256').update(String(v || '')).digest('hex');
|
|
249
|
+
// The generation of the publication a stream was opened under. Every opt-in takes its
|
|
250
|
+
// own full backup next to the store (never overwritten), so the SET of those backups
|
|
251
|
+
// changes whenever the mode is turned on again — even with the same audience, even
|
|
252
|
+
// within the same second (names are not ordered in time, so the whole set is read, not
|
|
253
|
+
// the "newest"). A removed backup changes it too. Unreadable directory: a value that
|
|
254
|
+
// never matches (fail closed).
|
|
255
|
+
function publicationGeneration(nodesPath) {
|
|
256
|
+
try {
|
|
257
|
+
const base = `${path.basename(nodesPath)}.pre-publish-remote-`;
|
|
258
|
+
const names = fs.readdirSync(path.dirname(nodesPath)).filter((n) => n.startsWith(base) && n.endsWith('.bak')).sort();
|
|
259
|
+
return crypto.createHash('sha256').update(names.join('\n')).digest('hex');
|
|
260
|
+
} catch (_) { return `unreadable:${Date.now()}:${Math.random()}`; }
|
|
261
|
+
}
|
|
262
|
+
const audienceFingerprint = (egress) => crypto.createHash('sha256').update(JSON.stringify([...(egress.publishRemoteAudience || [])].sort())).digest('hex');
|
|
263
|
+
function hostOnlyGuardFor(nodesPath, ingress, egress, visited) {
|
|
264
|
+
return {
|
|
265
|
+
nodesPath, ingressNodeId: ingress.nodeId, ingressFp: credentialFingerprint(ingress.acceptToken),
|
|
266
|
+
egressName: egress.name, egressNodeId: egress.nodeId, egressPort: egress.localPort,
|
|
267
|
+
egressFp: credentialFingerprint(egress.token),
|
|
268
|
+
audienceFp: audienceFingerprint(egress), generation: publicationGeneration(nodesPath), visited,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
// A stream opened through a peer that was NOT host-only keeps the behaviour it always had, with one exception: when
|
|
272
|
+
// the publication is turned ON for that peer, every federated stream in flight through it ends.
|
|
273
|
+
function plainStreamGuardFor(nodesPath, egress) {
|
|
274
|
+
return { plain: true, nodesPath, egressName: egress.name, egressNodeId: egress.nodeId };
|
|
275
|
+
}
|
|
276
|
+
function streamGuardFor(nodesPath, ingress, egress, visited) {
|
|
277
|
+
return isHostOnlyPeer(egress) ? hostOnlyGuardFor(nodesPath, ingress, egress, visited) : plainStreamGuardFor(nodesPath, egress);
|
|
278
|
+
}
|
|
279
|
+
function hostOnlyStreamAllowed(guard, loaded) {
|
|
280
|
+
const st = loaded === undefined ? store.loadStore(guard.nodesPath) : loaded;
|
|
281
|
+
if (guard.plain) {
|
|
282
|
+
if (!st) return true; // a plain stream is not judged on an unreadable store
|
|
283
|
+
return !st.nodes.some((n) => n && n.nodeId === guard.egressNodeId && isHostOnlyPeer(n));
|
|
284
|
+
}
|
|
285
|
+
if (!st) return false;
|
|
286
|
+
const egress = store.getNode(st, guard.egressName);
|
|
287
|
+
const reader = st.nodes.find((n) => n && n.nodeId === guard.ingressNodeId);
|
|
288
|
+
if (!egress || !reader || !egress.token || !canTransit(reader, egress)) return false;
|
|
289
|
+
// The stream was opened under a host-only publication: it lives only while THAT publication
|
|
290
|
+
// is what authorizes it. Legacy sharing (shared=true after an off) is a different authorization.
|
|
291
|
+
if (!isHostOnlyPeer(egress)) return false;
|
|
292
|
+
if (audienceFingerprint(egress) !== guard.audienceFp || publicationGeneration(guard.nodesPath) !== guard.generation) return false;
|
|
293
|
+
// Same identities and credentials that were authenticated when the stream opened.
|
|
294
|
+
if (credentialFingerprint(reader.acceptToken) !== guard.ingressFp || credentialFingerprint(egress.token) !== guard.egressFp
|
|
295
|
+
|| egress.nodeId !== guard.egressNodeId || egress.localPort !== guard.egressPort) return false;
|
|
296
|
+
return chainInAudience(st, guard.visited, egress);
|
|
297
|
+
}
|
|
298
|
+
function trackHostOnlyStream(guard, close) {
|
|
299
|
+
if (!guard) return () => {};
|
|
300
|
+
const entry = { guard, close };
|
|
301
|
+
hostOnlyStreams.add(entry);
|
|
302
|
+
if (!hostOnlyTimer) {
|
|
303
|
+
hostOnlyTimer = setInterval(() => {
|
|
304
|
+
const loaded = new Map(); // the store is read once per file per tick
|
|
305
|
+
const load = (p) => { if (!loaded.has(p)) { let v = null; try { v = store.loadStore(p); } catch (_) { v = null; } loaded.set(p, v); } return loaded.get(p); };
|
|
306
|
+
for (const e of [...hostOnlyStreams]) {
|
|
307
|
+
let ok = false;
|
|
308
|
+
try { ok = hostOnlyStreamAllowed(e.guard, load(e.guard.nodesPath)); } catch (_) { ok = e.guard.plain === true; }
|
|
309
|
+
if (!ok) { hostOnlyStreams.delete(e); try { e.close(); } catch (_) { /* gone */ } }
|
|
310
|
+
}
|
|
311
|
+
if (!hostOnlyStreams.size) { clearInterval(hostOnlyTimer); hostOnlyTimer = null; }
|
|
312
|
+
}, HOST_ONLY_RECHECK_MS);
|
|
313
|
+
if (typeof hostOnlyTimer.unref === 'function') hostOnlyTimer.unref();
|
|
314
|
+
}
|
|
315
|
+
return () => { hostOnlyStreams.delete(entry); };
|
|
316
|
+
}
|
|
317
|
+
|
|
124
318
|
function parseRoute(raw) {
|
|
125
319
|
const parts = String(raw || '').split('?')[0].split('/').filter(Boolean);
|
|
126
320
|
const idx = parts.indexOf(ROUTE_DELIMITER);
|
|
@@ -274,6 +468,9 @@ function liveHostAllowedFor(ingress) {
|
|
|
274
468
|
// WebSocket upgrade. A resource whose class is unknown, or whose grants the
|
|
275
469
|
// owner has not conceded, is refused before anything is dispatched.
|
|
276
470
|
function classGateCheck(resource, method, ingress) {
|
|
471
|
+
// A host-only peer reaches nothing of ours, whatever grants its record carries
|
|
472
|
+
// or gains: the zeroed vector is the first lock, this is the second.
|
|
473
|
+
if (isHostOnlyPeer(ingress)) return { ok: false, status: 403, reason: 'host-only-peer' };
|
|
277
474
|
const cls = classifyResource(resource, method);
|
|
278
475
|
if (!cls) return { ok: false, status: 404, reason: 'resource-not-classified' };
|
|
279
476
|
if (!allowedByClass(cls, ingress)) return { ok: false, status: 403, reason: `grant-required:${cls}` };
|
|
@@ -489,7 +686,7 @@ function liveHostLedgerCapture({ ledger, ownerId, resource }) {
|
|
|
489
686
|
const SSE_IDLE_TIMEOUT_MS = 90000;
|
|
490
687
|
const SSE_CONTENT_TYPE_RE = /^text\/event-stream/i;
|
|
491
688
|
|
|
492
|
-
function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null, capture = null, getTimeoutMs = 10000, sseIdleMs = SSE_IDLE_TIMEOUT_MS, onForwardFailure = null, abortWithClient = false }) {
|
|
689
|
+
function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null, hostOnlyEgress = false, streamGuard = null, capture = null, getTimeoutMs = 10000, sseIdleMs = SSE_IDLE_TIMEOUT_MS, onForwardFailure = null, abortWithClient = false }) {
|
|
493
690
|
// A body a local middleware has already read is no longer in the stream: the
|
|
494
691
|
// headers would announce bytes that nothing sends, and the peer would wait for
|
|
495
692
|
// a body that never arrives. Say it at once, with a name, instead of letting
|
|
@@ -507,8 +704,23 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
|
|
|
507
704
|
if (r.statusCode < 200 || r.statusCode >= 300) failed();
|
|
508
705
|
r.once('aborted', failed); r.once('error', failed);
|
|
509
706
|
const headers = setCookiePrefix ? rewriteSetCookiePath(r.headers, setCookiePrefix) : r.headers;
|
|
707
|
+
const cleanResponse = hostOnlyEgress ? sanitizeHostOnlyResponseHeaders : sanitizeResponseHeaders;
|
|
708
|
+
if (hostOnlyEgress && r.statusCode >= 300 && r.statusCode < 400) {
|
|
709
|
+
// A redirect from a host-only peer would be followed by the browser WITH the owner's Authorization,
|
|
710
|
+
// towards any route of this node or of a trusted peer: it is never passed along.
|
|
711
|
+
r.resume();
|
|
712
|
+
return res.status(502).json({ error: 'redirect refused', reason: 'host-only-redirect' });
|
|
713
|
+
}
|
|
714
|
+
// host-only: every answer; a plain peer: only the long-lived (event streams)
|
|
715
|
+
if (streamGuard && (!streamGuard.plain || SSE_CONTENT_TYPE_RE.test(String(r.headers['content-type'] || '')))) {
|
|
716
|
+
const untrack = trackHostOnlyStream(streamGuard, () => {
|
|
717
|
+
try { up.destroy(); } catch (_) { /* gone */ }
|
|
718
|
+
try { res.destroy(); } catch (_) { /* gone */ }
|
|
719
|
+
});
|
|
720
|
+
res.once('close', untrack); up.once('close', untrack);
|
|
721
|
+
}
|
|
510
722
|
if (!capture) {
|
|
511
|
-
res.writeHead(r.statusCode,
|
|
723
|
+
res.writeHead(r.statusCode, cleanResponse(headers));
|
|
512
724
|
if (SSE_CONTENT_TYPE_RE.test(String(r.headers['content-type'] || ''))) {
|
|
513
725
|
// The response is open: no inactivity timeout any more (the 504 below
|
|
514
726
|
// still covers a peer that never answers), and the watchdog is rearmed
|
|
@@ -542,7 +754,7 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
|
|
|
542
754
|
const body = Buffer.concat(chunks);
|
|
543
755
|
try { capture(r.statusCode, body.toString('utf8')); } catch (_) { /* the ledger never breaks the forward */ }
|
|
544
756
|
if (res.writableEnded) return;
|
|
545
|
-
res.writeHead(r.statusCode,
|
|
757
|
+
res.writeHead(r.statusCode, cleanResponse(headers));
|
|
546
758
|
res.end(body);
|
|
547
759
|
});
|
|
548
760
|
});
|
|
@@ -642,7 +854,16 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
642
854
|
}
|
|
643
855
|
const next = st && store.getNode(st, parsed.route[0]);
|
|
644
856
|
const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
|
|
645
|
-
|
|
857
|
+
// A host-only peer has no panel and no Live host to offer: refused for the
|
|
858
|
+
// owner as well, and before any other decision.
|
|
859
|
+
if (next && isHostOnlyPeer(next) && (isPanelResource(parsed.resource) || isLiveHostResource(parsed.resource))) {
|
|
860
|
+
return res.status(403).json({
|
|
861
|
+
error: 'risorsa non disponibile verso un nodo solo-host',
|
|
862
|
+
reason: isPanelResource(parsed.resource) ? 'host-only-no-panel' : 'host-only-no-live-host',
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
if (!next || !next.token || privateInbound
|
|
866
|
+
|| (ingress && (!canTransit(ingress, next) || !chainInAudience(st, visited, next)))) return rejectAskRequest(403, { error: 'route non consentita' });
|
|
646
867
|
if (parsed.resource === '/asks' && req.method === 'POST') {
|
|
647
868
|
const view = accessPresets.grantsOf(next);
|
|
648
869
|
if (!view.configured || view.label !== 'admin') {
|
|
@@ -713,7 +934,7 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
713
934
|
if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) {
|
|
714
935
|
return rejectForward(403, { error: 'READONLY: federated mutation blocked' });
|
|
715
936
|
}
|
|
716
|
-
if (!currentNext.token || currentNext.shared !== true || (currentIngress && !canTransit(currentIngress, currentNext))) {
|
|
937
|
+
if (!currentNext.token || currentNext.shared !== true || (currentIngress && (!canTransit(currentIngress, currentNext) || !chainInAudience(current, visited, currentNext)))) {
|
|
717
938
|
return rejectForward(403, { error: 'route non consentita' });
|
|
718
939
|
}
|
|
719
940
|
if (!controlledVisited(req, currentIngress, current.nodeId)) return rejectForward(409, { error: 'federation cycle rejected' });
|
|
@@ -725,6 +946,8 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
725
946
|
try { JSON.parse(text); } catch (_) { invalidateProof(); }
|
|
726
947
|
} : capture;
|
|
727
948
|
proxyHttp(req, res, { port: forwardPort, path, credential: next.token, visited, setCookiePrefix, panelCookie, capture: actionCapture,
|
|
949
|
+
hostOnlyEgress: isHostOnlyPeer(next),
|
|
950
|
+
streamGuard: ingress ? streamGuardFor(nodesPath, ingress, next, visited) : null,
|
|
728
951
|
onForwardFailure: isAskAction ? invalidateProof : null, abortWithClient: isAskAction, getTimeoutMs: forwardGetTimeoutMs, sseIdleMs: forwardSseIdleMs });
|
|
729
952
|
};
|
|
730
953
|
}
|
|
@@ -1200,7 +1423,7 @@ async function collectTopologyDetailed({
|
|
|
1200
1423
|
// clients become part of Hydra only after their explicit Share toggle.
|
|
1201
1424
|
if (!n.nodeId || seen.has(n.nodeId)
|
|
1202
1425
|
|| (!ingress && n.direction === 'inbound' && n.shared !== true)
|
|
1203
|
-
|| (ingress && !canTransit(ingress, n))) continue;
|
|
1426
|
+
|| (ingress && (!canTransit(ingress, n) || !chainInAudience(st, [...seen, ingress.nodeId], n)))) continue;
|
|
1204
1427
|
// `name` e' lo slug locale con cui si indirizza; `label` e' il nome
|
|
1205
1428
|
// leggibile. Senza quest'ultimo un nodo raggiunto in transito arriva agli
|
|
1206
1429
|
// altri come uno slug scelto da qualcun altro, e cinque installazioni
|
|
@@ -1209,7 +1432,9 @@ async function collectTopologyDetailed({
|
|
|
1209
1432
|
instanceId: n.nodeId, name: n.name, route: [n.name], direct: true,
|
|
1210
1433
|
label: store.validLabel(n.label) ? n.label.trim() : '',
|
|
1211
1434
|
});
|
|
1212
|
-
|
|
1435
|
+
// A host-only peer is published as a LEAF: whatever it says about its own
|
|
1436
|
+
// children is never asked for, so it cannot announce nodes to our readers.
|
|
1437
|
+
if (ttl <= 1 || !n.token || isHostOnlyPeer(n)) continue;
|
|
1213
1438
|
probes.push({ n, pending: fetchPeerTopology({ node: n, ttl, seen, fetchImpl, timeoutMs: childBudget }) });
|
|
1214
1439
|
}
|
|
1215
1440
|
const results = await Promise.all(probes.map(async ({ n, pending }) => {
|
|
@@ -1261,12 +1486,38 @@ async function collectTopologyDetailed({
|
|
|
1261
1486
|
});
|
|
1262
1487
|
}
|
|
1263
1488
|
}
|
|
1264
|
-
const ids = new Set(); const routes = new Set();
|
|
1489
|
+
const ids = new Set(); const routes = new Set(); let unique = [];
|
|
1265
1490
|
for (const n of out.sort((a, b) => a.route.length - b.route.length)) {
|
|
1266
1491
|
const routeKey = n.route.join('/');
|
|
1267
1492
|
if (ids.has(n.instanceId) || routes.has(routeKey)) continue;
|
|
1268
1493
|
ids.add(n.instanceId); routes.add(routeKey); unique.push(n);
|
|
1269
1494
|
}
|
|
1495
|
+
if (ingress) {
|
|
1496
|
+
// The fan-out above waited: whatever authorized the reader at the start is read AGAIN now, before the
|
|
1497
|
+
// answer leaves. A host-only peer stays only if the SAME reader (same credential) still passes.
|
|
1498
|
+
const now = store.loadStore(nodesPath);
|
|
1499
|
+
const reader = now && now.nodes.find((n) => n && n.nodeId === ingress.nodeId
|
|
1500
|
+
&& credentialFingerprint(n.acceptToken) === credentialFingerprint(ingress.acceptToken));
|
|
1501
|
+
const chain = [...seen, ingress.nodeId];
|
|
1502
|
+
// A peer is judged by the FULL rule NOW if it was host-only when the walk started or is host-only now: one that
|
|
1503
|
+
// stopped being host-only meanwhile (the publication ended during the wait) does not keep its leaf, and neither
|
|
1504
|
+
// does one whose mode, audience or chain no longer passes. Unreadable store: nothing is vouched for.
|
|
1505
|
+
const wasHostOnly = new Map(st.nodes.filter((n) => isHostOnlyPeer(n)).map((n) => [n.nodeId, audienceFingerprint(n)]));
|
|
1506
|
+
const candidates = new Map();
|
|
1507
|
+
for (const n of st.nodes) if (isHostOnlyPeer(n)) candidates.set(n.nodeId, n);
|
|
1508
|
+
for (const n of (now ? now.nodes : [])) if (isHostOnlyPeer(n)) candidates.set(n.nodeId, n);
|
|
1509
|
+
const closed = [...candidates.values()].filter((n) => {
|
|
1510
|
+
if (!now || !reader) return true;
|
|
1511
|
+
const cur = now.nodes.find((x) => x.nodeId === n.nodeId);
|
|
1512
|
+
if (!cur || !isHostOnlyPeer(cur)) return true; // the publication ended (or the peer is gone)
|
|
1513
|
+
if (wasHostOnly.has(n.nodeId) && wasHostOnly.get(n.nodeId) !== audienceFingerprint(cur)) return true; // another audience
|
|
1514
|
+
return !(canTransit(reader, cur) && chainInAudience(now, chain, cur));
|
|
1515
|
+
});
|
|
1516
|
+
if (closed.length) {
|
|
1517
|
+
const closedIds = new Set(closed.map((n) => n.nodeId)); const closedNames = new Set(closed.map((n) => n.name));
|
|
1518
|
+
unique = unique.filter((e) => !closedIds.has(e.instanceId) && !(e.direct && closedNames.has(e.name)) && !e.route.some((name, i) => i === 0 && closedNames.has(name)));
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1270
1521
|
return { instanceId: st.nodeId, nodes: unique, authoritative };
|
|
1271
1522
|
}
|
|
1272
1523
|
|
|
@@ -1286,7 +1537,9 @@ async function collectLocalTopology({
|
|
|
1286
1537
|
const live = await collectTopologyDetailed({ nodesPath, fetchImpl, timeoutMs, childSnapshots: false });
|
|
1287
1538
|
const st = store.loadStore(nodesPath);
|
|
1288
1539
|
const directNames = new Set(((st && st.nodes) || [])
|
|
1289
|
-
|
|
1540
|
+
// A host-only peer is a leaf: nothing cached "behind" it survives (entries
|
|
1541
|
+
// written before the mode was turned on included).
|
|
1542
|
+
.filter((n) => (n.direction !== 'inbound' || n.shared === true) && !isHostOnlyPeer(n))
|
|
1290
1543
|
.map((n) => n.name));
|
|
1291
1544
|
const authoritative = new Set(live.authoritative);
|
|
1292
1545
|
const liveIds = new Set(live.nodes.map((n) => n.instanceId));
|
|
@@ -1362,6 +1615,15 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
1362
1615
|
if (!peer) return res.status(401).json({ error: 'unauthorized peer' });
|
|
1363
1616
|
req.peer = peer; next();
|
|
1364
1617
|
});
|
|
1618
|
+
// A host-only peer (publish-remote) reaches NOTHING of ours over the protocol:
|
|
1619
|
+
// the only thing it may do is the authenticated health probe. Share, topology,
|
|
1620
|
+
// reverse-pool and routes are all refused here, before their handlers run.
|
|
1621
|
+
r.use((req, res, next) => {
|
|
1622
|
+
if (isHostOnlyPeer(req.peer) && !(req.method === 'GET' && req.path === '/health')) {
|
|
1623
|
+
return res.status(403).json({ error: 'non concesso a questo nodo', reason: 'host-only-peer' });
|
|
1624
|
+
}
|
|
1625
|
+
return next();
|
|
1626
|
+
});
|
|
1365
1627
|
// Health federato: il peer autenticato (acceptToken matchato sopra) ottiene un
|
|
1366
1628
|
// 200 esplicito con instanceId/version. Serve da target di probeHealth() lato
|
|
1367
1629
|
// Initiator: distingue transport (porta aperta) da auth (200 vs 401) da
|
|
@@ -1683,10 +1945,15 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1683
1945
|
let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential();
|
|
1684
1946
|
// Ultimo hop: `/ws` entra com'e', il pannello rientra nell'API locale.
|
|
1685
1947
|
let path = parsed.resource === '/ws' ? '/ws' : `/api${parsed.resource}`;
|
|
1948
|
+
let hostOnlyEgress = false; let hostOnlyGuard = null;
|
|
1686
1949
|
if (parsed.route.length) {
|
|
1687
1950
|
const next = store.getNode(st, parsed.route[0]);
|
|
1688
1951
|
const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
|
|
1689
|
-
if (
|
|
1952
|
+
if (next && isHostOnlyPeer(next) && isPanelResource(parsed.resource)) return reject(socket, 403);
|
|
1953
|
+
if (!next || !next.token || privateInbound
|
|
1954
|
+
|| (ingress && (!canTransit(ingress, next) || !chainInAudience(st, visited, next)))) return reject(socket, 403);
|
|
1955
|
+
hostOnlyEgress = isHostOnlyPeer(next);
|
|
1956
|
+
if (ingress) hostOnlyGuard = streamGuardFor(nodesPath, ingress, next, visited);
|
|
1690
1957
|
port = next.localPort; credential = next.token;
|
|
1691
1958
|
const rest = parsed.route.slice(1);
|
|
1692
1959
|
path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}`;
|
|
@@ -1707,6 +1974,13 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1707
1974
|
// cambiare), l'output va scritto grezzo (i frame li ha gia' incorniciati
|
|
1708
1975
|
// l'upstream).
|
|
1709
1976
|
try { socket.write(handshake); } catch (_) { /* morto */ }
|
|
1977
|
+
if (hostOnlyGuard) {
|
|
1978
|
+
const untrackTaken = trackHostOnlyStream(hostOnlyGuard, () => {
|
|
1979
|
+
try { socket.destroy(); } catch (_) { /* gone */ }
|
|
1980
|
+
try { record.up.destroy(); } catch (_) { /* gone */ }
|
|
1981
|
+
});
|
|
1982
|
+
record.up.once('close', untrackTaken);
|
|
1983
|
+
}
|
|
1710
1984
|
// Gli eventuali byte gia' arrivati dopo la richiesta di upgrade vanno
|
|
1711
1985
|
// inoltrati come nel ramo normale, non persi.
|
|
1712
1986
|
if (head && head.length) { try { record.up.write(head); } catch (_) { /* morto */ } }
|
|
@@ -1729,6 +2003,14 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1729
2003
|
}
|
|
1730
2004
|
}
|
|
1731
2005
|
const up = net.connect({ host: '127.0.0.1', port });
|
|
2006
|
+
// What we read from the peer: for a host-only peer the head of its answer is
|
|
2007
|
+
// filtered like its HTTP answers.
|
|
2008
|
+
let upRead = up;
|
|
2009
|
+
if (hostOnlyEgress) {
|
|
2010
|
+
const headFilter = hostOnlyHeadFilter();
|
|
2011
|
+
headFilter.on('error', () => { try { up.destroy(); } catch (_) { /* gone */ } }); // refused head: upstreamGone() answers the client with our own bytes
|
|
2012
|
+
upRead = up.pipe(headFilter);
|
|
2013
|
+
}
|
|
1732
2014
|
// ECONNREFUSED sull'hop locale (tunnel che si riavvia) arriva PRIMA del
|
|
1733
2015
|
// 'connect': nel ramo hubMode il listener 'error' era registrato solo dentro
|
|
1734
2016
|
// up.once('connect', …) e l'evento restava senza gestore → Unhandled 'error'
|
|
@@ -1752,7 +2034,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1752
2034
|
let saw101 = false;
|
|
1753
2035
|
let headBuf = Buffer.alloc(0);
|
|
1754
2036
|
let gone = false;
|
|
1755
|
-
|
|
2037
|
+
upRead.on('data', (chunk) => {
|
|
1756
2038
|
if (saw101 || headBuf.length >= 1024) return;
|
|
1757
2039
|
headBuf = Buffer.concat([headBuf, chunk.subarray(0, Math.max(0, 1024 - headBuf.length))]);
|
|
1758
2040
|
if (headBuf.includes(' 101')) saw101 = true;
|
|
@@ -1798,6 +2080,15 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1798
2080
|
const remove = () => { activeSockets.delete(socket); activeSockets.delete(up); };
|
|
1799
2081
|
socket.once('close', remove); up.once('close', remove);
|
|
1800
2082
|
}
|
|
2083
|
+
if (hostOnlyGuard) {
|
|
2084
|
+
// Released when the UPSTREAM goes: a held attach keeps `up` after the
|
|
2085
|
+
// client has gone, and it must still be closed on revocation.
|
|
2086
|
+
const untrack = trackHostOnlyStream(hostOnlyGuard, () => {
|
|
2087
|
+
try { socket.destroy(); } catch (_) { /* gone */ }
|
|
2088
|
+
try { up.destroy(); } catch (_) { /* gone */ }
|
|
2089
|
+
});
|
|
2090
|
+
up.once('close', untrack);
|
|
2091
|
+
}
|
|
1801
2092
|
if (hubMode) {
|
|
1802
2093
|
// Attach trattenuta: l'output passa dal ring quando il client non c'è,
|
|
1803
2094
|
// e il record entra nello store sotto la sua identità.
|
|
@@ -1805,7 +2096,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1805
2096
|
attachId, tokenHash: tokenFingerprint(attachToken), route: attachRoute, session: attachSession, up,
|
|
1806
2097
|
});
|
|
1807
2098
|
heldRecord = record;
|
|
1808
|
-
|
|
2099
|
+
upRead.on('data', (chunk) => hubAttaches.feed(record, chunk));
|
|
1809
2100
|
up.on('close', () => hubAttaches.destroy(record));
|
|
1810
2101
|
up.on('error', () => hubAttaches.destroy(record));
|
|
1811
2102
|
// end:false: la caduta del CLIENT non deve chiudere l'upstream — è
|
|
@@ -1831,7 +2122,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1831
2122
|
socket.pipe(up);
|
|
1832
2123
|
// end:false: la chiusura verso il browser la governiamo noi, o la pipe
|
|
1833
2124
|
// chiuderebbe il socket PRIMA che il frame di close 4402 possa partire.
|
|
1834
|
-
|
|
2125
|
+
upRead.pipe(socket, { end: false });
|
|
1835
2126
|
});
|
|
1836
2127
|
if (!hubMode) {
|
|
1837
2128
|
// Nel ramo trattenuto la morte dell'upstream la governa lo store (destroy
|
|
@@ -2072,9 +2363,9 @@ function acceptWsThenClose(socket, req, code, reason) {
|
|
|
2072
2363
|
|
|
2073
2364
|
|
|
2074
2365
|
module.exports = {
|
|
2075
|
-
isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, routeHandler,
|
|
2366
|
+
isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, classGateCheck, routeHandler,
|
|
2076
2367
|
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
|
|
2077
|
-
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
|
|
2368
|
+
peerFromToken, peerAllows, canTransit, isHostOnlyPeer, chainInAudience, touchesHostOnly, routeNamesOf, hostOnlyVisibility, hideHostOnlyEntries, redactHostOnlyReferences, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
|
|
2078
2369
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade, createHubAttachStore,
|
|
2079
2370
|
probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
|
|
2080
2371
|
hubPoolRequest, verifyHubPoolSlot, reserveHubPoolSlot, commitHubPoolSlot, settleHubPoolSlot, abortHubPoolSlot, getHubPoolStatus,
|
package/lib/proxy/node-proxy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
};
|