@volter/world-core 3.0.64 → 3.0.65
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/capability-decision.cjs +238 -0
- package/capability-decision.d.cts +56 -0
- package/dist/capability-decision.cjs +238 -0
- package/dist/capability-decision.d.cts +56 -0
- package/dist/inject.cjs +130 -98
- package/dist/network-policy.cjs +19 -15
- package/dist/network-policy.d.cts +1 -1
- package/inject.cjs +130 -98
- package/network-policy.cjs +19 -15
- package/network-policy.d.cts +1 -1
- package/package.json +7 -1
package/inject.cjs
CHANGED
|
@@ -45,7 +45,8 @@ const { Readable } = require('stream');
|
|
|
45
45
|
const Module = require('module');
|
|
46
46
|
const { urlToHttpOptions } = require('node:url');
|
|
47
47
|
const claimsRefreshChannel = require('node:diagnostics_channel').channel('volter.inject.claims-refresh');
|
|
48
|
-
const {
|
|
48
|
+
const { worldNetworkPolicyFromEnv, isWorldInternalHost } = require('./network-policy.cjs');
|
|
49
|
+
const { OPEN_DECLARATION, decideCapability, refusalRecord, refusalSide } = require('./capability-decision.cjs');
|
|
49
50
|
const { createPublicKey, createVerify } = require('crypto');
|
|
50
51
|
|
|
51
52
|
// the vendor host table (VENDOR_HOSTS: a hostname, and a path where two vendors share a host, to the vendor whose twin
|
|
@@ -325,17 +326,43 @@ function strictEgressEnabled() {
|
|
|
325
326
|
return process.env.VOLTER_TWIN_STRICT_EGRESS === '1' || process.env.VOLTER_TWIN_STRICT_EGRESS === 'true';
|
|
326
327
|
}
|
|
327
328
|
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
329
|
+
// THE WORLD'S DECISION (docs/contributing/architecture.md, "Capability decisions"): every seam below asks
|
|
330
|
+
// capability-decision.cjs and enforces its answer. This process is a host process, whose declaration is the open one, or a
|
|
331
|
+
// local light guest, whose HTTP leaves through its guest listener; what the World grants is its published policy. The
|
|
332
|
+
// seams supply what they observe: the twin route, the vendors sharing a host, the World's doors.
|
|
333
|
+
function worldGrant() {
|
|
334
|
+
let policy; let unreadable;
|
|
335
|
+
try { policy = worldNetworkPolicyFromEnv(process.env); } catch (error) { unreadable = error.message; }
|
|
336
|
+
return { policy, strict: strictEgressEnabled(), unreadable };
|
|
337
|
+
}
|
|
338
|
+
function decide(url, transport, facts = {}, port) {
|
|
339
|
+
const guestProxy = localGuestProxy();
|
|
340
|
+
return decideCapability({ kind: 'egress', url, transport, port },
|
|
341
|
+
{ run: { guest: !!guestProxy }, declaration: OPEN_DECLARATION, grant: worldGrant(), facts: { guestProxy, ...facts } });
|
|
342
|
+
}
|
|
343
|
+
/** What an HTTP seam observes about `url`. A local guest's request to one of the World's doors, or not over HTTP(S), is
|
|
344
|
+
* not mediated by its guest listener. */
|
|
345
|
+
function httpFacts(url, pathname) {
|
|
346
|
+
if (!url) return {};
|
|
347
|
+
const guestProxy = localGuestProxy();
|
|
348
|
+
return {
|
|
349
|
+
twin: resolveTwin(url.hostname, MAP, pathname === undefined ? url.pathname : pathname),
|
|
350
|
+
twinnedHostVendors: twinnedHostVendors(url.hostname, MAP),
|
|
351
|
+
...(guestProxy && !/^https?:$/.test(url.protocol) ? { guestProxy: null } : {}),
|
|
352
|
+
endpoint: guestProxy && (addressedToWorld(url) || explicitTwinEndpoint(url) || url.origin === guestProxy.origin) ? { kind: 'door' } : null,
|
|
353
|
+
};
|
|
333
354
|
}
|
|
334
|
-
|
|
335
|
-
function
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
355
|
+
/** A refusal as an Error: the seam's own message, the side that refused, and the World's refusal record. */
|
|
356
|
+
function refusalError(answer, message) {
|
|
357
|
+
return Object.assign(new Error(`${message} (${refusalSide(answer)})`), { worldRefusal: refusalRecord(answer) });
|
|
358
|
+
}
|
|
359
|
+
/** The World's listing refused it (sealed, unlisted, an unreadable policy). */
|
|
360
|
+
const LISTING_RULES = new Set(['sealed', 'not-granted', 'policy-unreadable']);
|
|
361
|
+
/** The message an HTTP seam (`request`, `fetch`) gives a refusal. */
|
|
362
|
+
function httpRefusalMessage(answer, url, seam) {
|
|
363
|
+
if (answer.rule === 'unclaimed-path') return unclaimedTwinnedHostPathMessage(url.hostname, url.pathname, MAP);
|
|
364
|
+
if (LISTING_RULES.has(answer.rule)) return `[twin-inject] blocked untwinned external ${seam} to ${url.hostname}`;
|
|
365
|
+
return `[twin-inject] ${answer.reason}`;
|
|
339
366
|
}
|
|
340
367
|
|
|
341
368
|
// --- untwinned-host TLS failure naming (local mode) -----------------------------------------
|
|
@@ -475,10 +502,6 @@ function explicitTwinEndpoint(url) {
|
|
|
475
502
|
} catch { return false; }
|
|
476
503
|
});
|
|
477
504
|
}
|
|
478
|
-
function mediateLocalGuest(url) {
|
|
479
|
-
const proxy = localGuestProxy();
|
|
480
|
-
return proxy && url && /^https?:$/.test(url.protocol) && !addressedToWorld(url) && !explicitTwinEndpoint(url) && url.origin !== proxy.origin ? proxy : null;
|
|
481
|
-
}
|
|
482
505
|
function addressedToWorld(url) {
|
|
483
506
|
const base = worldBase();
|
|
484
507
|
if (!base || !url || !base.path || url.origin.replace(/^ws/, 'http') !== base.origin) return false;
|
|
@@ -545,30 +568,30 @@ function keyedView(dispatcher) {
|
|
|
545
568
|
if (name === 'dispatch') {
|
|
546
569
|
return function keyedDispatch(opts, handler) {
|
|
547
570
|
const key = ORIGINAL_DEFINE_PROPERTY ? twinsKey() : null;
|
|
571
|
+
let answer = null;
|
|
548
572
|
if (ORIGINAL_DEFINE_PROPERTY && opts?.origin != null) {
|
|
549
|
-
|
|
550
|
-
let at;
|
|
573
|
+
let at = null; let invalid = false;
|
|
551
574
|
try {
|
|
552
575
|
const origin = new URL(String(opts.origin));
|
|
553
576
|
const path = String(opts.path ?? '/');
|
|
554
577
|
if (!path.startsWith('/') || /\\/.test(path)) throw new Error('invalid dispatch path');
|
|
555
578
|
at = new URL(origin.origin + path);
|
|
556
|
-
} catch {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
579
|
+
} catch { invalid = true; }
|
|
580
|
+
answer = decide(at, 'dispatch', at ? { twin: resolveTwin(at.hostname, MAP, at.pathname), twinnedHostVendors: twinnedHostVendors(at.hostname, MAP), twinOrigin: isTwinOriginHost(at.hostname) } : { invalid });
|
|
581
|
+
if (answer.outcome === 'refuse') {
|
|
582
|
+
const message = answer.rule === 'invalid' ? '[twin-inject] invalid effective undici request'
|
|
583
|
+
: answer.rule === 'unclaimed-path' ? unclaimedTwinnedHostPathMessage(at.hostname, at.pathname, MAP)
|
|
584
|
+
: '[twin-inject] unmediated undici hop cannot use inspected egress';
|
|
585
|
+
process.nextTick(() => handler.onError(refusalError(answer, message)));
|
|
562
586
|
return false;
|
|
563
587
|
}
|
|
564
588
|
}
|
|
565
589
|
// Undici's shared dispatcher is an HTTP seam even when its caller bypasses global fetch.
|
|
566
590
|
// Rewrite the hop, not its socket: the original host remains available to the twin.
|
|
567
|
-
if (
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if (twin && twin.vendor !== 'app') {
|
|
591
|
+
if (answer && answer.outcome === 'twin') {
|
|
592
|
+
const at = new URL(answer.target);
|
|
593
|
+
const twin = answer.twin;
|
|
594
|
+
if (twin.vendor !== 'app') {
|
|
572
595
|
const endpoint = new URL(twin.origin);
|
|
573
596
|
const headers = undiciHeaderObject(opts.headers);
|
|
574
597
|
headers['host'] = at.host;
|
|
@@ -605,6 +628,12 @@ function keyUndiciDispatches() {
|
|
|
605
628
|
};
|
|
606
629
|
for (const name of UNDICI_DISPATCHERS) if (globalThis[name]) globalThis[name] = keyedView(globalThis[name]);
|
|
607
630
|
}
|
|
631
|
+
/** The twin the World routes `url` to, or null: its other answers are the dispatcher's and the sockets' to enforce. */
|
|
632
|
+
function routedTwin(url) {
|
|
633
|
+
if (!url) return null;
|
|
634
|
+
const answer = decide(url, 'dispatch', { twin: resolveTwin(url.hostname, MAP, url.pathname) });
|
|
635
|
+
return answer.outcome === 'twin' ? answer.twin : null;
|
|
636
|
+
}
|
|
608
637
|
// Imported Undici APIs may hold a dispatcher obtained before the global view was installed.
|
|
609
638
|
// Rewrite their public HTTP doors as well; an independent connector still faces the socket guards.
|
|
610
639
|
let UNDICI_LOAD = null;
|
|
@@ -620,7 +649,7 @@ function routeImportedUndici() {
|
|
|
620
649
|
view.fetch = function routedUndiciFetch(input, init) {
|
|
621
650
|
if (!installed) return exports.fetch(input, init);
|
|
622
651
|
let url; try { url = new URL(typeof input === 'object' && input?.url ? input.url : String(input)); } catch {}
|
|
623
|
-
const twin =
|
|
652
|
+
const twin = routedTwin(url);
|
|
624
653
|
// Local control planes may use their own Request class and dispatcher. Preserve both;
|
|
625
654
|
// connections still pass the socket guards, and claimed vendor hops still enter routing.
|
|
626
655
|
if (url && isLocalOrPrivateHost(url.hostname) && (!twin || twin.vendor === 'app')) return exports.fetch(input, init);
|
|
@@ -639,7 +668,7 @@ function routeImportedUndici() {
|
|
|
639
668
|
if (!installed) return exports.request.apply(exports, arguments);
|
|
640
669
|
const opts = typeof options === 'function' ? {} : options ?? {};
|
|
641
670
|
let url; try { url = new URL(String(input)); if (opts.path !== undefined) url = new URL(url.origin + String(opts.path)); } catch {}
|
|
642
|
-
const twin =
|
|
671
|
+
const twin = routedTwin(url);
|
|
643
672
|
if (!twin || twin.vendor === 'app') return exports.request.apply(exports, arguments);
|
|
644
673
|
const headers = undiciHeaderObject(opts.headers);
|
|
645
674
|
headers.host = url.host; headers['x-volter-twin-original-host'] = url.host;
|
|
@@ -962,7 +991,7 @@ function unclaimedTwinnedHostPathMessage(hostname, pathname, map) {
|
|
|
962
991
|
|
|
963
992
|
function blockedClientRequest(message) {
|
|
964
993
|
const request = new EventEmitter();
|
|
965
|
-
const error = new Error(message);
|
|
994
|
+
const error = message instanceof Error ? message : new Error(message);
|
|
966
995
|
const socket = new EventEmitter();
|
|
967
996
|
socket.connecting = false;
|
|
968
997
|
socket.setKeepAlive = () => socket;
|
|
@@ -1154,46 +1183,54 @@ function socketTargetPort(args) {
|
|
|
1154
1183
|
return Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined;
|
|
1155
1184
|
}
|
|
1156
1185
|
|
|
1157
|
-
|
|
1186
|
+
/** Why the World refuses a socket to `hostname`:`port` (an Error carrying the refusal record), or null. */
|
|
1187
|
+
function socketRefusal(hostname, port, isTls, datagram = false) {
|
|
1158
1188
|
const host = String(hostname || '').toLowerCase();
|
|
1189
|
+
// an addressing mistake, not a capability: the World's DNS answer for the names it routes leads nowhere
|
|
1159
1190
|
if (host === WORLD_ADDRESS[4] || host === WORLD_ADDRESS[6]) {
|
|
1160
|
-
return `[twin-inject] refused a socket to ${host}, the address this World's DNS gives the names it routes: `
|
|
1161
|
-
+ 'nothing listens there; connect by the name, which the World routes to its twin or its application.';
|
|
1162
|
-
}
|
|
1163
|
-
if (localGuestProxy() && isLocalOrPrivateHost(host)) {
|
|
1164
|
-
const endpoints = [localGuestProxy(), worldBase() && new URL(worldBase().origin), ...Object.values(MAP).map(origin => { try { return new URL(origin); } catch { return null; } })].filter(Boolean);
|
|
1165
|
-
if (!endpoints.some(endpoint => endpoint.hostname.replace(/^\[|\]$/g, '') === host.replace(/^\[|\]$/g, '') && Number(endpoint.port || (endpoint.protocol === 'https:' ? 443 : 80)) === port)) {
|
|
1166
|
-
return `[twin-inject] local guest private socket to ${host}:${port} requires its guest proxy`;
|
|
1167
|
-
}
|
|
1191
|
+
return new Error(`[twin-inject] refused a socket to ${host}, the address this World's DNS gives the names it routes: `
|
|
1192
|
+
+ 'nothing listens there; connect by the name, which the World routes to its twin or its application.');
|
|
1168
1193
|
}
|
|
1169
|
-
if (!host
|
|
1194
|
+
if (!host) return null;
|
|
1170
1195
|
// the destination as an origin, its port and its protocol included: a policy that lists https://host allows TLS to
|
|
1171
1196
|
// host:443, never another port or a plaintext connection to it
|
|
1172
1197
|
const authority = `${isIP(host) === 6 ? `[${host}]` : host}${port !== undefined && !(isTls && port === 443) ? `:${port}` : ''}`;
|
|
1173
1198
|
const url = (() => { try { return new URL(`${isTls ? 'https' : 'http'}://${authority}/`); } catch { return null; } })();
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
const
|
|
1177
|
-
|
|
1199
|
+
if (!url) return null;
|
|
1200
|
+
const guestProxy = localGuestProxy();
|
|
1201
|
+
const internal = isLocalOrPrivateHost(host);
|
|
1202
|
+
const facts = internal
|
|
1203
|
+
? { endpoint: guestProxy && guestDoorAt(host, port) ? { kind: 'door' } : null }
|
|
1204
|
+
: { twin: resolveTwin(host, MAP), twinnedHostVendors: twinnedHostVendors(host, MAP), twinOrigin: isTwinOriginHost(host) };
|
|
1205
|
+
const answer = decide(url, datagram ? 'datagram' : 'socket', facts, port);
|
|
1206
|
+
if (answer.outcome !== 'refuse') return null;
|
|
1207
|
+
let message = `[twin-inject] ${answer.reason}`;
|
|
1208
|
+
if (answer.rule === 'guest-private') message = `[twin-inject] local guest private socket to ${host}:${port} requires its guest proxy`;
|
|
1209
|
+
else if (answer.rule === 'raw-socket-twinned') {
|
|
1210
|
+
const vendors = facts.twinnedHostVendors;
|
|
1178
1211
|
const doors = vendors.map(endpointEnvFor).filter(Boolean);
|
|
1179
|
-
|
|
1212
|
+
message = `[twin-inject] refused a raw socket to ${host}, which is twinned in this world (${vendors.join(', ')}): `
|
|
1180
1213
|
+ `the client opened its own connection instead of going through http/https or fetch, so the twin never saw it. `
|
|
1181
1214
|
+ (doors.length > 0
|
|
1182
1215
|
? `The SDK reaches the twin through ${doors.join('; ')}, which the world sets when it runs the app.`
|
|
1183
1216
|
: `This client needs an endpoint door on the pack before the world can serve it.`);
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
return `[twin-inject] refused a raw socket to ${host}, which this world routes to the application (volter-world app-url --host): `
|
|
1217
|
+
} else if (answer.rule === 'raw-socket-app') {
|
|
1218
|
+
message = `[twin-inject] refused a raw socket to ${host}, which this world routes to the application (volter-world app-url --host): `
|
|
1187
1219
|
+ 'the client opened its own connection instead of going through http/https or fetch, so it could not be routed.';
|
|
1188
|
-
}
|
|
1189
|
-
if (
|
|
1190
|
-
|
|
1191
|
-
|
|
1220
|
+
} else if (answer.rule === 'inspected-transport') message = `[twin-inject] inspected egress requires a mediated HTTP request to ${authority}`;
|
|
1221
|
+
else if (LISTING_RULES.has(answer.rule)) message = `[twin-inject] blocked untwinned external socket to ${authority}`;
|
|
1222
|
+
return refusalError(answer, message);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/** One of the World's doors a local guest connects to directly: its guest listener, the World's origin, a twin. */
|
|
1226
|
+
function guestDoorAt(host, port) {
|
|
1227
|
+
const doors = [localGuestProxy(), worldBase() && new URL(worldBase().origin), ...Object.values(MAP).map(origin => { try { return new URL(origin); } catch { return null; } })].filter(Boolean);
|
|
1228
|
+
return doors.some(door => door.hostname.replace(/^\[|\]$/g, '') === host.replace(/^\[|\]$/g, '') && Number(door.port || (door.protocol === 'https:' ? 443 : 80)) === port);
|
|
1192
1229
|
}
|
|
1193
1230
|
|
|
1194
|
-
function refusedSocket(
|
|
1231
|
+
function refusedSocket(refusal) {
|
|
1195
1232
|
const socket = new net.Socket();
|
|
1196
|
-
process.nextTick(() => socket.destroy(Object.assign(
|
|
1233
|
+
process.nextTick(() => socket.destroy(Object.assign(refusal, { code: 'ECONNREFUSED' })));
|
|
1197
1234
|
return socket;
|
|
1198
1235
|
}
|
|
1199
1236
|
|
|
@@ -1230,7 +1267,7 @@ function patchedInstanceConnect(originalConnect) {
|
|
|
1230
1267
|
const refusal = socketRefusal(host, socketTargetPort(target), isTls);
|
|
1231
1268
|
if (refusal) {
|
|
1232
1269
|
// Preserve the instance and its error listeners; never return a different socket.
|
|
1233
|
-
process.nextTick(() => this.destroy(Object.assign(
|
|
1270
|
+
process.nextTick(() => this.destroy(Object.assign(refusal, { code: 'ECONNREFUSED' })));
|
|
1234
1271
|
return this;
|
|
1235
1272
|
}
|
|
1236
1273
|
}
|
|
@@ -1245,9 +1282,9 @@ function patchedDatagram(original, connecting) {
|
|
|
1245
1282
|
const tail = connecting ? args : args.slice(1);
|
|
1246
1283
|
const host = peer?.address ?? tail.findLast(arg => typeof arg === 'string') ?? 'localhost';
|
|
1247
1284
|
const port = peer?.port ?? tail.findLast(arg => typeof arg === 'number');
|
|
1248
|
-
const refusal = socketRefusal(host, port, false);
|
|
1285
|
+
const refusal = socketRefusal(host, port, false, true);
|
|
1249
1286
|
if (refusal) {
|
|
1250
|
-
const error = Object.assign(
|
|
1287
|
+
const error = Object.assign(refusal, { code: 'ECONNREFUSED' });
|
|
1251
1288
|
const callback = !connecting && typeof args.at(-1) === 'function' ? args.at(-1) : null;
|
|
1252
1289
|
process.nextTick(() => callback ? callback(error) : this.emit('error', error));
|
|
1253
1290
|
return;
|
|
@@ -1289,33 +1326,27 @@ function upgradeToTwin(origin, url, method, headers, args, callback) {
|
|
|
1289
1326
|
function patchedFactory(originalRequest, defaultProtocol) {
|
|
1290
1327
|
return function patchedRequest(...args) {
|
|
1291
1328
|
const { url, method, headers, callback, options: given, invalid } = describe(args, defaultProtocol);
|
|
1292
|
-
|
|
1293
|
-
const
|
|
1294
|
-
if (
|
|
1295
|
-
|
|
1296
|
-
|
|
1329
|
+
const upgrade = isUpgrade(headers) || method === 'CONNECT';
|
|
1330
|
+
const answer = decide(url, upgrade ? 'upgrade' : 'http', { ...httpFacts(url), invalid });
|
|
1331
|
+
if (answer.outcome === 'refuse') {
|
|
1332
|
+
const message = answer.rule === 'invalid' ? '[twin-inject] request has an invalid effective authority or path'
|
|
1333
|
+
: answer.rule === 'guest-upgrade' ? '[twin-inject] local guest upgrade requires a mediated transport'
|
|
1334
|
+
: answer.rule === 'inspected-transport' ? '[twin-inject] inspected egress cannot upgrade its transport'
|
|
1335
|
+
: httpRefusalMessage(answer, url, 'request');
|
|
1336
|
+
return blockedClientRequest(refusalError(answer, message));
|
|
1297
1337
|
}
|
|
1298
|
-
|
|
1299
|
-
if (twin && twin.vendor === 'app') {
|
|
1338
|
+
if (answer.via === 'guest-proxy') return requestApp({ proxy: localGuestProxy() }, url, method, headers, { signal: given.signal, timeout: given.timeout }, callback);
|
|
1339
|
+
if (answer.outcome === 'twin' && answer.twin.vendor === 'app') {
|
|
1300
1340
|
const given = args.find((a) => a && typeof a === 'object' && !(a instanceof URL)) || {};
|
|
1301
|
-
return requestApp(twin, url, method, headers, { signal: given.signal, timeout: given.timeout }, callback);
|
|
1341
|
+
return requestApp(answer.twin, url, method, headers, { signal: given.signal, timeout: given.timeout }, callback);
|
|
1302
1342
|
}
|
|
1303
|
-
if (twin) {
|
|
1343
|
+
if (answer.outcome === 'twin') {
|
|
1304
1344
|
const fwdHeaders = Object.assign({}, headers);
|
|
1305
1345
|
if (!fwdHeaders.host && !fwdHeaders.Host) fwdHeaders.host = url.host;
|
|
1306
|
-
if (isUpgrade(headers)) return upgradeToTwin(twin.origin, url, method, fwdHeaders, args, callback);
|
|
1307
|
-
return fakeClientRequest(twin.origin, method, url.pathname + url.search, fwdHeaders, callback);
|
|
1308
|
-
}
|
|
1309
|
-
// No twin claims this path — but if configured twins claim the HOST, the path is an unclaimed
|
|
1310
|
-
// slice of a shared, virtualized host (the Cal.com /calendar/v3 case): refuse loudly, in every
|
|
1311
|
-
// mode, before the strict-egress check gets a chance to phrase it as an ordinary untwinned host.
|
|
1312
|
-
if (url && !publicBuildRequest(url) && twinnedHostVendors(url.hostname, MAP).length > 0) {
|
|
1313
|
-
return blockedClientRequest(unclaimedTwinnedHostPathMessage(url.hostname, url.pathname, MAP));
|
|
1346
|
+
if (isUpgrade(headers)) return upgradeToTwin(answer.twin.origin, url, method, fwdHeaders, args, callback);
|
|
1347
|
+
return fakeClientRequest(answer.twin.origin, method, url.pathname + url.search, fwdHeaders, callback);
|
|
1314
1348
|
}
|
|
1315
|
-
if (
|
|
1316
|
-
const policy = worldNetworkPolicyFromEnv(process.env);
|
|
1317
|
-
if (url && policy && worldNetworkNeedsPathInspection(policy, url)) {
|
|
1318
|
-
if (isUpgrade(headers) || method === 'CONNECT') return blockedClientRequest('[twin-inject] inspected egress cannot upgrade its transport');
|
|
1349
|
+
if (answer.via === 'inspected') {
|
|
1319
1350
|
try {
|
|
1320
1351
|
return requestApp({ proxy: pathGrantProxy() }, url, method, headers, { signal: given.signal, timeout: given.timeout }, callback);
|
|
1321
1352
|
} catch (error) { return blockedClientRequest(`[twin-inject] ${error.message}`); }
|
|
@@ -1384,13 +1415,17 @@ function install(mapOverride) {
|
|
|
1384
1415
|
http2.connect = function patchedHttp2Connect(authority, options, listener) {
|
|
1385
1416
|
let url = null;
|
|
1386
1417
|
try { url = new URL(typeof authority === 'string' ? authority : authority && authority.href ? authority.href : `https://${authority.hostname || authority.host}`); } catch { url = null; }
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1418
|
+
// a session names no path at connect, so a twinned host's unclaimed paths cannot be told apart here: host-level only
|
|
1419
|
+
const { twinnedHostVendors: _paths, ...facts } = httpFacts(url, '/');
|
|
1420
|
+
const answer = decide(url, 'http2', facts);
|
|
1421
|
+
if (answer.outcome !== 'twin') {
|
|
1422
|
+
// the World's listing is enforced on the session's own TLS connect, by the socket guard, with this same decision
|
|
1423
|
+
if (answer.outcome === 'refuse' && !LISTING_RULES.has(answer.rule)) {
|
|
1424
|
+
throw refusalError(answer, answer.rule === 'guest-http2' ? '[twin-inject] local guest HTTP/2 requires a mediated transport' : '[twin-inject] HTTP/2 cannot use inspected egress');
|
|
1425
|
+
}
|
|
1392
1426
|
return ORIGINALS.http2Connect.apply(this, arguments);
|
|
1393
1427
|
}
|
|
1428
|
+
const twin = answer.twin;
|
|
1394
1429
|
const cb = typeof options === 'function' ? options : listener;
|
|
1395
1430
|
const opts = typeof options === 'function' || !options ? {} : Object.fromEntries(Object.entries(options).filter(([k]) => !['secureContext', 'servername', 'ca', 'cert', 'key', 'checkServerIdentity', 'ALPNProtocols', 'createConnection', 'rejectUnauthorized'].includes(k)));
|
|
1396
1431
|
return ORIGINALS.http2Connect.call(this, new URL(twin.origin).origin, opts, cb);
|
|
@@ -1403,26 +1438,23 @@ function install(mapOverride) {
|
|
|
1403
1438
|
let urlStr;
|
|
1404
1439
|
try { urlStr = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input && input.url; } catch { urlStr = undefined; }
|
|
1405
1440
|
let url = null;
|
|
1441
|
+
let invalid = false;
|
|
1406
1442
|
if (urlStr) {
|
|
1407
|
-
|
|
1443
|
+
invalid = /\\/.test(String(urlStr).split(/[?#]/, 1)[0]);
|
|
1408
1444
|
try { url = new URL(urlStr); } catch { url = null; }
|
|
1409
1445
|
}
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
const twin =
|
|
1446
|
+
// the claims are read as they stand; a miss refreshes them once below and asks again
|
|
1447
|
+
const answer = decide(url, 'http', { ...httpFacts(url), invalid });
|
|
1448
|
+
if (answer.rule === 'invalid') return Promise.reject(refusalError(answer, '[twin-inject] backslash in fetch target'));
|
|
1449
|
+
if (answer.via === 'guest-proxy') return fetchApp({ proxy: localGuestProxy() }, url, input, init, chain);
|
|
1450
|
+
const twin = answer.outcome === 'twin' ? answer.twin : null;
|
|
1415
1451
|
if (!twin) {
|
|
1416
1452
|
// A host may have been claimed since startup (including by a child process).
|
|
1417
1453
|
// Refresh once at most: a retry goes on with the claims actually returned.
|
|
1418
1454
|
const pending = !claimsWaited && url && !isLocalOrPrivateHost(url.hostname) && !isTwinOriginHost(url.hostname) ? claimsRefreshBriefly() : null;
|
|
1419
1455
|
if (pending) return pending.then(() => patchedFetch(input, init, true, chain));
|
|
1420
|
-
//
|
|
1421
|
-
|
|
1422
|
-
if (url && !publicBuildRequest(url) && twinnedHostVendors(url.hostname, MAP).length > 0) {
|
|
1423
|
-
return Promise.reject(new Error(unclaimedTwinnedHostPathMessage(url.hostname, url.pathname, MAP)));
|
|
1424
|
-
}
|
|
1425
|
-
if (shouldBlockUntwinned(url)) return Promise.reject(new Error(`[twin-inject] blocked untwinned external fetch to ${url.hostname}`));
|
|
1456
|
+
// an unclaimed path on a twinned host refuses loudly in every mode: never mis-routed, never leaked to the vendor
|
|
1457
|
+
if (answer.outcome === 'refuse') return Promise.reject(refusalError(answer, httpRefusalMessage(answer, url, 'fetch')));
|
|
1426
1458
|
// Each followed redirect re-enters authorization; native fetch only receives manual hops under a policy.
|
|
1427
1459
|
if (addressedToWorld(url) && twinsKey()) {
|
|
1428
1460
|
const request = typeof input === 'object' && input !== null ? input : undefined;
|
|
@@ -1437,9 +1469,9 @@ function install(mapOverride) {
|
|
|
1437
1469
|
}
|
|
1438
1470
|
return originalFetch(input, keyed);
|
|
1439
1471
|
}
|
|
1440
|
-
const policy =
|
|
1472
|
+
const policy = worldGrant().policy;
|
|
1441
1473
|
const redirect = init?.redirect ?? (typeof input === 'object' && input?.redirect) ?? 'follow';
|
|
1442
|
-
if (
|
|
1474
|
+
if (answer.via === 'inspected') {
|
|
1443
1475
|
try { return fetchApp({ proxy: pathGrantProxy() }, url, input, init, chain); }
|
|
1444
1476
|
catch (error) { return Promise.reject(error); }
|
|
1445
1477
|
}
|
package/network-policy.cjs
CHANGED
|
@@ -55,12 +55,13 @@ function origins(value) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function normalizeWorldNetwork(value) {
|
|
58
|
-
const network = object(value, ['egress', 'writes', 'phase', 'passthrough', 'publicReads'], 'World network');
|
|
58
|
+
const network = object(value, ['egress', 'writes', 'phase', 'passthrough', 'publicReads', 'ownLoopback'], 'World network');
|
|
59
59
|
const egress = origins(network.egress);
|
|
60
60
|
const writes = origins(network.writes ?? []);
|
|
61
61
|
const phase = network.phase ?? 'runtime';
|
|
62
62
|
if (phase !== 'build' && phase !== 'runtime') throw new Error('World network phase must be build or runtime');
|
|
63
63
|
if (network.publicReads !== undefined && typeof network.publicReads !== 'boolean') throw new Error('World network publicReads must be a boolean');
|
|
64
|
+
if (network.ownLoopback !== undefined && typeof network.ownLoopback !== 'boolean') throw new Error('World network ownLoopback must be a boolean');
|
|
64
65
|
const publicReads = phase === 'build' && network.publicReads !== false;
|
|
65
66
|
const passthrough = origins(network.passthrough ?? []);
|
|
66
67
|
if (passthrough.length && phase !== 'build') throw new Error('World TLS passthrough requires phase: build');
|
|
@@ -69,7 +70,9 @@ function normalizeWorldNetwork(value) {
|
|
|
69
70
|
// an opaque tunnel cannot show its method, so it cannot carry a write
|
|
70
71
|
if (writes.some(grant => new URL(grant).origin === origin)) throw new Error(`TLS passthrough cannot carry a write grant: ${origin}`);
|
|
71
72
|
}
|
|
72
|
-
|
|
73
|
+
// a run reaches its own listeners unless the World says otherwise (architecture, "Capability decisions"); only the
|
|
74
|
+
// refusal is written, so a policy that grants it reads as it did before the field existed
|
|
75
|
+
return Object.freeze({ egress, writes, phase, passthrough, publicReads, ...(network.ownLoopback === false ? { ownLoopback: false } : {}) });
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
function createWorldNetworkPolicy(world, network) {
|
|
@@ -78,9 +81,9 @@ function createWorldNetworkPolicy(world, network) {
|
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
function parseWorldNetworkPolicy(value) {
|
|
81
|
-
const policy = object(value, ['version', 'world', 'egress', 'writes', 'phase', 'passthrough', 'publicReads'], 'World network policy');
|
|
84
|
+
const policy = object(value, ['version', 'world', 'egress', 'writes', 'phase', 'passthrough', 'publicReads', 'ownLoopback'], 'World network policy');
|
|
82
85
|
if (policy.version !== 1) throw new Error('Unsupported World network policy version');
|
|
83
|
-
return createWorldNetworkPolicy(policy.world, { egress: policy.egress, writes: policy.writes, phase: policy.phase, passthrough: policy.passthrough, publicReads: policy.publicReads });
|
|
86
|
+
return createWorldNetworkPolicy(policy.world, { egress: policy.egress, writes: policy.writes, phase: policy.phase, passthrough: policy.passthrough, publicReads: policy.publicReads, ownLoopback: policy.ownLoopback });
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
function worldNetworkPolicyFromEnv(env) {
|
|
@@ -152,7 +155,8 @@ function intersectWorldNetwork(own, ceiling) {
|
|
|
152
155
|
? left.passthrough.filter(origin => right.passthrough.includes(origin) && egress.includes(origin)) : [];
|
|
153
156
|
// public reads are permission too: only a build ceiling that reads them passes them on
|
|
154
157
|
const publicReads = left.publicReads && right.publicReads;
|
|
155
|
-
|
|
158
|
+
const ownLoopback = left.ownLoopback !== false && right.ownLoopback !== false;
|
|
159
|
+
return normalizeWorldNetwork({ egress, writes, phase: left.phase, passthrough, publicReads, ownLoopback });
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
/** Loopback and private addresses stay inside the machine a World runs on: never refused. */
|
|
@@ -171,21 +175,21 @@ function isWorldInternalHost(hostname) {
|
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
/**
|
|
174
|
-
* Why this World refuses an outbound request to `value`, or null when it allows it
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* injector does not attach to. Internal addresses pass; strict egress refuses everything else; a
|
|
178
|
+
* Why this World refuses an outbound request to `value`, or null when it allows it: the World's capability decision
|
|
179
|
+
* (capability-decision.cjs) for a twin's own World-internal outbound calls (a queue delivering to its destination), which
|
|
180
|
+
* run in a host the injector does not attach to. Internal addresses pass; strict egress refuses everything else; a
|
|
178
181
|
* published network policy refuses what it does not list. An unreadable policy refuses.
|
|
179
182
|
*/
|
|
180
183
|
function worldEgressRefusal(value, env = process.env, method = 'GET') {
|
|
181
184
|
let url;
|
|
182
185
|
try { url = new URL(String(value)); } catch { return `unparseable destination ${String(value)}`; }
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
let policy; let unreadable;
|
|
187
|
+
try { policy = worldNetworkPolicyFromEnv(env); } catch (error) { unreadable = error.message; }
|
|
188
|
+
const strict = env.VOLTER_TWIN_STRICT_EGRESS === '1' || env.VOLTER_TWIN_STRICT_EGRESS === 'true';
|
|
189
|
+
const { OPEN_DECLARATION, decideCapability, refusalSide } = require('./capability-decision.cjs');
|
|
190
|
+
const answer = decideCapability({ kind: 'egress', url, method, transport: 'http' },
|
|
191
|
+
{ run: { guest: false }, declaration: OPEN_DECLARATION, grant: { policy, strict, unreadable }, facts: {} });
|
|
192
|
+
return answer.outcome === 'refuse' ? `${answer.reason} (${refusalSide(answer)})` : null;
|
|
189
193
|
}
|
|
190
194
|
|
|
191
195
|
module.exports = { PUBLIC_BUILD_READS, READ_METHODS, intersectWorldNetwork, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough, isWorldInternalHost, worldEgressRefusal, WORLD_NETWORK_POLICY_ENV, normalizeWorldNetwork, createWorldNetworkPolicy, parseWorldNetworkPolicy, worldNetworkPolicyFromEnv, allowsWorldNetworkEgress };
|
package/network-policy.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export interface WorldNetwork { readonly egress: readonly string[]; readonly writes?: readonly string[]; readonly phase?: 'build' | 'runtime'; readonly passthrough?: readonly string[]; readonly publicReads?: boolean }
|
|
1
|
+
export interface WorldNetwork { readonly egress: readonly string[]; readonly writes?: readonly string[]; readonly phase?: 'build' | 'runtime'; readonly passthrough?: readonly string[]; readonly publicReads?: boolean; readonly ownLoopback?: boolean }
|
|
2
2
|
export interface WorldNetworkPolicy extends WorldNetwork { readonly version: 1; readonly world: string }
|
|
3
3
|
export const WORLD_NETWORK_POLICY_ENV: 'VOLTER_WORLD_NETWORK_POLICY';
|
|
4
4
|
export function normalizeWorldNetwork(value: unknown): WorldNetwork;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.65",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
"http-bridge.cjs",
|
|
28
28
|
"network-policy.cjs",
|
|
29
29
|
"network-policy.d.cts",
|
|
30
|
+
"capability-decision.cjs",
|
|
31
|
+
"capability-decision.d.cts",
|
|
30
32
|
"app-route.cjs",
|
|
31
33
|
"vendor-hosts.cjs",
|
|
32
34
|
"host-routing.cjs",
|
|
@@ -91,6 +93,10 @@
|
|
|
91
93
|
"types": "./network-policy.d.cts",
|
|
92
94
|
"default": "./network-policy.cjs"
|
|
93
95
|
},
|
|
96
|
+
"./capability-decision": {
|
|
97
|
+
"types": "./capability-decision.d.cts",
|
|
98
|
+
"default": "./capability-decision.cjs"
|
|
99
|
+
},
|
|
94
100
|
"./app-route": {
|
|
95
101
|
"types": "./app-route.d.cts",
|
|
96
102
|
"default": "./app-route.cjs"
|