@volter/world-runtime 3.0.67 → 3.0.68
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/dist/src/inject-map.d.ts +2 -0
- package/dist/src/redirect-proxy.d.ts +2 -0
- package/dist/src/redirect-proxy.js +77 -65
- package/package.json +4 -4
- package/src/inject-map.ts +2 -0
- package/src/redirect-proxy.ts +86 -57
package/dist/src/inject-map.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export type InjectModule = {
|
|
|
10
10
|
twinUrlVendor(name: string): string | null;
|
|
11
11
|
/** the headers a request routed to the application carries: the host and scheme it was made with */
|
|
12
12
|
appForwardHeaders(url: URL): Record<string, string>;
|
|
13
|
+
/** the vendors whose twins this map runs and whose host rules name `host`, whatever the path */
|
|
14
|
+
twinnedHostVendors(host: string, map: Record<string, string>): string[];
|
|
13
15
|
VENDOR_HOSTS: Record<string, (host: string, pathname?: string) => boolean>;
|
|
14
16
|
restore(): void;
|
|
15
17
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { errorMessage } from "./error-message.js";
|
|
2
2
|
import { serveHttp } from '@volter/world-core/runtime';
|
|
3
|
-
import { worldNetworkPolicyFromEnv,
|
|
3
|
+
import { worldNetworkPolicyFromEnv, worldGrantFromEnv, worldStrictEgress, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough } from '@volter/world-core/network-policy';
|
|
4
|
+
import { OPEN_DECLARATION, decideCapability, publicBuildGrant, refusalRecord, refusalSide } from '@volter/world-core/capability-decision';
|
|
4
5
|
import { websocketRelay } from "./websocket-relay.js";
|
|
5
6
|
// The ambient TLS redirect proxy — the substance of "any CLI, zero config" (WORLD_ACTIVATE.md §"How
|
|
6
7
|
// redirection works", tier 1). A local TLS-terminating forward proxy that maps vendor API hosts
|
|
@@ -57,10 +58,10 @@ import { sessionTrustEnv } from "./ca-trust.js";
|
|
|
57
58
|
*/
|
|
58
59
|
export function noTwinMessage(host, path) {
|
|
59
60
|
return `host ${host} is twinned in this world, but path ${JSON.stringify(path)} belongs to no twin configured here.\n`
|
|
60
|
-
+ 'This host is shared between vendors, and no
|
|
61
|
+
+ 'This host is shared between vendors, and no twin in this world serves this path. Either the\n'
|
|
61
62
|
+ 'vendor that owns this path has a twin that is not configured — set its *_TWIN_URL (for\n'
|
|
62
63
|
+ 'www.googleapis.com: YOUTUBE_TWIN_URL serves /youtube/v3/*, GOOGLEAUTH_TWIN_URL serves the\n'
|
|
63
|
-
+ 'OAuth2 token paths) — or this API has no twin
|
|
64
|
+
+ 'OAuth2 token paths) — or this API has no twin yet (e.g. Google Drive /drive/v3/*),\n'
|
|
64
65
|
+ 'in which case this loud refusal is the honest outcome: the request is neither answered by the\n'
|
|
65
66
|
+ 'wrong twin nor leaked to the real vendor.\n';
|
|
66
67
|
}
|
|
@@ -80,33 +81,6 @@ export function proxyTargetFor(hostname, env, pathname) {
|
|
|
80
81
|
export function activeVendorMap(env) {
|
|
81
82
|
return loadInject().readMap(env);
|
|
82
83
|
}
|
|
83
|
-
function strictEgressEnabled(env) {
|
|
84
|
-
return env.VOLTER_TWIN_STRICT_EGRESS === '1' || env.VOLTER_TWIN_STRICT_EGRESS === 'true';
|
|
85
|
-
}
|
|
86
|
-
function isLocalOrPrivateHost(hostname) {
|
|
87
|
-
const h = String(hostname || '').toLowerCase();
|
|
88
|
-
if (!h)
|
|
89
|
-
return true;
|
|
90
|
-
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.test'))
|
|
91
|
-
return true;
|
|
92
|
-
if (h === '0.0.0.0' || h === '127.0.0.1' || h === '::1' || h === '[::1]')
|
|
93
|
-
return true;
|
|
94
|
-
if (isIP(h) !== 4)
|
|
95
|
-
return false;
|
|
96
|
-
if (/^127\./.test(h))
|
|
97
|
-
return true;
|
|
98
|
-
if (/^10\./.test(h))
|
|
99
|
-
return true;
|
|
100
|
-
if (/^192\.168\./.test(h))
|
|
101
|
-
return true;
|
|
102
|
-
const match = h.match(/^172\.(\d+)\./);
|
|
103
|
-
if (match) {
|
|
104
|
-
const n = Number(match[1]);
|
|
105
|
-
if (n >= 16 && n <= 31)
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
return false;
|
|
109
|
-
}
|
|
110
84
|
/** A connection from anything but the proxy host's own loopback is a guest's (a World Machine: its forwarder connects
|
|
111
85
|
* from 127.0.0.2 on this host, or from its VM's address): architecture, "The scoped proxy's local pass-through". */
|
|
112
86
|
export function isGuestSource(address) {
|
|
@@ -177,21 +151,51 @@ export async function guestDestination(host) {
|
|
|
177
151
|
}
|
|
178
152
|
const bad = addresses.find(isGuestForbiddenAddress);
|
|
179
153
|
if (bad || !addresses.length)
|
|
180
|
-
return { refused: `${host} is on the machine's host network (${bad ?? 'no address'}), which a World Machine cannot reach
|
|
154
|
+
return { refused: `${host} is on the machine's host network (${bad ?? 'no address'}), which a World Machine cannot reach`, hostNetwork: true, address: bad ?? 'no address' };
|
|
181
155
|
return { address: addresses[0] };
|
|
182
156
|
}
|
|
183
|
-
|
|
157
|
+
/** The World's refusal of `value` (sealed, unlisted, an unreadable policy), or null: the World's capability decision
|
|
158
|
+
* (docs/contributing/architecture.md, "Capability decisions") for a host process, with the twin of its host as the fact.
|
|
159
|
+
* `method` is given where the proxy sees it, on the effective request it sends to the real origin itself: no twin
|
|
160
|
+
* answers that request, so its host's twin is no fact of it. */
|
|
161
|
+
function shouldBlockUntwinned(value, env, method) {
|
|
184
162
|
let url;
|
|
185
163
|
try {
|
|
186
164
|
url = value instanceof URL ? value : new URL(value);
|
|
187
165
|
}
|
|
188
166
|
catch {
|
|
189
|
-
return
|
|
167
|
+
return { outcome: 'refuse', capability: 'network.egress', target: String(value), missing: 'grant', rule: 'invalid', reason: 'the request has an invalid effective authority or path' };
|
|
190
168
|
}
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
169
|
+
const answer = decideCapability({ kind: 'egress', url, transport: 'http', ...(method ? { method } : {}) }, { run: { guest: false }, declaration: OPEN_DECLARATION, grant: worldGrantFromEnv(env), facts: { twin: method ? null : proxyTargetFor(url.hostname, env) } });
|
|
170
|
+
return answer.outcome === 'refuse' ? answer : null;
|
|
171
|
+
}
|
|
172
|
+
/** The World's refusal of a path no twin here claims on a host its twins serve. */
|
|
173
|
+
function twinGap(host, path, env) {
|
|
174
|
+
const inject = loadInject();
|
|
175
|
+
const vendors = inject.twinnedHostVendors(host, inject.readMap(env));
|
|
176
|
+
const named = vendors.length ? vendors : [proxyTargetFor(host, env)?.vendor ?? host];
|
|
177
|
+
return { outcome: 'refuse', capability: 'network.egress', target: `https://${host}${path}`, missing: 'twin', rule: 'unclaimed-path',
|
|
178
|
+
reason: `no twin in this World claims ${path} on ${host} (${named.join(', ')})` };
|
|
179
|
+
}
|
|
180
|
+
/** A refusal for a client that is not Node (curl, Python, Go): the decision's reason and side in words, its record as
|
|
181
|
+
* JSON on the line after, and the side in a header, as a Node client reads them from error.worldRefusal. */
|
|
182
|
+
function refusalText(refusal) {
|
|
183
|
+
return ` (${refusal.reason}: ${refusalSide(refusal)})\n${JSON.stringify(refusalRecord(refusal))}\n`;
|
|
184
|
+
}
|
|
185
|
+
const REFUSAL_HEADER = 'x-volter-world-refusal';
|
|
186
|
+
/** A World Machine guest's destination on the host's own networks (the address guestDestination found there): the
|
|
187
|
+
* World's decision for a guest's loopback or private destination that is neither a door nor a declared service. A
|
|
188
|
+
* name that does not resolve is no refusal of the World's, and carries none. */
|
|
189
|
+
function hostNetworkRefusal(host, port, address) {
|
|
190
|
+
const answer = decideCapability({ kind: 'loopback', host: address, port }, { run: { guest: true }, declaration: OPEN_DECLARATION, facts: {} });
|
|
191
|
+
if (answer.outcome !== 'refuse')
|
|
192
|
+
throw new Error(`the World's decision admits a guest's host-network destination ${host}:${port}`);
|
|
193
|
+
return { ...answer, target: `${host}:${port}` };
|
|
194
|
+
}
|
|
195
|
+
/** A World Machine guest's inspected egress: the proxy cannot keep the guest's checked destination pin on it. */
|
|
196
|
+
function guestPinRefusal(target) {
|
|
197
|
+
return { outcome: 'refuse', capability: 'network.egress', target, missing: 'transport', rule: 'guest-inspected',
|
|
198
|
+
reason: 'inspected egress cannot keep a guest\'s checked destination pin' };
|
|
195
199
|
}
|
|
196
200
|
function caPaths(tlsDir) {
|
|
197
201
|
return { dir: tlsDir, caKey: join(tlsDir, 'ca-key.pem'), caCert: join(tlsDir, 'ca-cert.pem') };
|
|
@@ -313,18 +317,16 @@ export async function startRedirectProxy(options) {
|
|
|
313
317
|
// Track raw client/upstream sockets so close() can forcibly drop tunnels (a blind tunnel to a slow
|
|
314
318
|
// or real upstream would otherwise keep the server from closing).
|
|
315
319
|
const openSockets = new Set();
|
|
316
|
-
const publicBuildRequest = (target, env) =>
|
|
317
|
-
const policy = worldNetworkPolicyFromEnv(env);
|
|
318
|
-
return !strictEgressEnabled(env) && !!policy && policy.phase === 'build' && allowsWorldNetworkEgress(policy, target);
|
|
319
|
-
};
|
|
320
|
+
const publicBuildRequest = (target, env) => publicBuildGrant(worldGrantFromEnv(env), target);
|
|
320
321
|
// One upstream HTTP operation, shared by plain-proxy and decrypted CONNECT requests.
|
|
321
322
|
// It never follows redirects and never lends the upstream socket to the caller.
|
|
322
323
|
const externalRequest = async (target, method, incoming, body, signal) => {
|
|
323
324
|
const env = currentEnv();
|
|
324
325
|
const policy = worldNetworkPolicyFromEnv(env);
|
|
325
326
|
// the method is visible here: an egress grant reads, a write needs `writes`
|
|
326
|
-
|
|
327
|
-
|
|
327
|
+
const refused = shouldBlockUntwinned(target, env, method);
|
|
328
|
+
if (!policy || refused)
|
|
329
|
+
throw Object.assign(new Error('World policy refuses the effective request'), refused ? { worldRefusal: refused } : {});
|
|
328
330
|
const host = incoming.get('host');
|
|
329
331
|
if (host && new URL(`${target.protocol}//${host}`).host !== target.host)
|
|
330
332
|
throw new Error('request Host differs from authorized authority');
|
|
@@ -463,25 +465,28 @@ export async function startRedirectProxy(options) {
|
|
|
463
465
|
// a person's browser shows where it was sent and why nothing is there, not an empty tunnel error; nothing
|
|
464
466
|
// leaves the World
|
|
465
467
|
const target = new URL(`https://${vendorHost}:${originPort}${u.pathname}${u.search}`);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
+
const refused = shouldBlockUntwinned(target.href, env);
|
|
469
|
+
if (refused)
|
|
470
|
+
return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'), undefined, refused);
|
|
468
471
|
if (!publicBuild && proxyTargetFor(vendorHost, env))
|
|
469
|
-
return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'));
|
|
472
|
+
return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'), undefined, twinGap(vendorHost, target.pathname, env));
|
|
470
473
|
const policy = worldNetworkPolicyFromEnv(env);
|
|
471
474
|
if (policy && worldNetworkNeedsPathInspection(policy, target)) {
|
|
472
475
|
if (guest)
|
|
473
|
-
return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'),
|
|
476
|
+
return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'), undefined, guestPinRefusal(target.href));
|
|
474
477
|
try {
|
|
475
478
|
return await externalRequest(target, req.method, req.headers, req.body, req.signal);
|
|
476
479
|
}
|
|
477
480
|
catch (error) {
|
|
478
|
-
|
|
481
|
+
const refused = error.worldRefusal;
|
|
482
|
+
return new Response(note(`inspected egress refused: ${error.message}${refused ? refusalText(refused) : '\n'}`), { status: 502, headers: refused ? { [REFUSAL_HEADER]: refused.missing } : {} });
|
|
479
483
|
}
|
|
480
484
|
}
|
|
481
485
|
const unreachable = unreachableHosts.get(hostHeader.toLowerCase());
|
|
482
486
|
if (unreachable)
|
|
483
487
|
return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'), unreachable);
|
|
484
|
-
|
|
488
|
+
const gap = twinGap(hostHeader, u.pathname, env);
|
|
489
|
+
return new Response(note(noTwinMessage(hostHeader, u.pathname)) + refusalText(gap).trimStart(), { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8', [REFUSAL_HEADER]: gap.missing } });
|
|
485
490
|
}
|
|
486
491
|
const origin = new URL(twin.origin);
|
|
487
492
|
const headers = new Headers(req.headers);
|
|
@@ -579,21 +584,24 @@ export async function startRedirectProxy(options) {
|
|
|
579
584
|
const guestServer = createNetServer(accept(true));
|
|
580
585
|
/** A refused host's answer: 502, naming the host and why, as a page a person reads in a browser (the address bar
|
|
581
586
|
* keeps where the request was sent) or as text for any other client. */
|
|
582
|
-
const outsideWorld = (host, path, accept, unreachable) => {
|
|
587
|
+
const outsideWorld = (host, path, accept, unreachable, refusal) => {
|
|
583
588
|
// the CONNECT that reached this server was noted when it was refused
|
|
584
589
|
const why = unreachable
|
|
585
590
|
? `no twin serves it, and it did not answer from this World (${unreachable}).`
|
|
586
591
|
: 'no twin serves it, and the World does not reach the internet for it.';
|
|
587
|
-
const text = `${host} is outside this World: ${why}\n`;
|
|
592
|
+
const text = `${host} is outside this World: ${why}${refusal ? refusalText(refusal) : '\n'}`;
|
|
593
|
+
const refused = refusal ? { [REFUSAL_HEADER]: refusal.missing } : {};
|
|
588
594
|
// connection: close, so a reload opens a new CONNECT, which reaches the origin once it answers (a kept-alive tunnel
|
|
589
595
|
// would keep answering this page)
|
|
590
596
|
if (!(accept ?? '').includes('text/html'))
|
|
591
|
-
return new Response(text, { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8', connection: 'close' } });
|
|
597
|
+
return new Response(text, { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8', connection: 'close', ...refused } });
|
|
592
598
|
const esc = (v) => v.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
593
599
|
const page = `<!doctype html><html><head><meta charset="utf-8"><title>${esc(host)} is outside this World</title>`
|
|
594
600
|
+ '<style>body{font:15px system-ui,sans-serif;max-width:620px;margin:12vh auto;padding:0 16px;color:#222}code{background:#f2f2f2;padding:1px 4px;border-radius:4px}</style></head><body>'
|
|
595
|
-
+ `<h1>${esc(host)} is outside this World</h1><p>You were sent to <code>https://${esc(host)}${esc(path)}</code>. ${esc(why.charAt(0).toUpperCase() + why.slice(1))} There is nothing to show here.</p
|
|
596
|
-
|
|
601
|
+
+ `<h1>${esc(host)} is outside this World</h1><p>You were sent to <code>https://${esc(host)}${esc(path)}</code>. ${esc(why.charAt(0).toUpperCase() + why.slice(1))} There is nothing to show here.</p>`
|
|
602
|
+
+ (refusal ? `<p>${esc(`${refusal.reason}: ${refusalSide(refusal)}`)}.</p><script type="application/json" id="volter-world-refusal">${esc(JSON.stringify(refusalRecord(refusal)))}</script>` : '')
|
|
603
|
+
+ '</body></html>';
|
|
604
|
+
return new Response(page, { status: 502, headers: { 'content-type': 'text/html; charset=utf-8', connection: 'close', ...refused } });
|
|
597
605
|
};
|
|
598
606
|
// the refused hosts answered with a page, each holding a leaf and a listener until the proxy closes: bounded
|
|
599
607
|
const REFUSED_PAGES_MAX = 64;
|
|
@@ -601,8 +609,9 @@ export async function startRedirectProxy(options) {
|
|
|
601
609
|
// the names on 443 a tunnel could not reach, with why, answered by their host server's page
|
|
602
610
|
const unreachableHosts = new Map();
|
|
603
611
|
const isDnsName = (host) => /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/i.test(host);
|
|
604
|
-
const writeResponse = (socket, status, body) => {
|
|
605
|
-
|
|
612
|
+
const writeResponse = (socket, status, body, refusal) => {
|
|
613
|
+
const text = refusal ? body.replace(/\n$/, '') + refusalText(refusal) : body;
|
|
614
|
+
socket.write(`${status}\r\ncontent-type: text/plain\r\ncontent-length: ${Buffer.byteLength(text)}\r\n${refusal ? `${REFUSAL_HEADER}: ${refusal.missing}\r\n` : ''}connection: close\r\n\r\n${text}`);
|
|
606
615
|
socket.end();
|
|
607
616
|
};
|
|
608
617
|
const forwardRawHttp = (origin, method, path, version, headers, socket, head, vendor, address) => {
|
|
@@ -650,11 +659,12 @@ export async function startRedirectProxy(options) {
|
|
|
650
659
|
// an unclaimed slice of a shared, virtualized host: refuse loudly (same rule as the MITM
|
|
651
660
|
// handler's noTwinMessage), never pass through to the real vendor.
|
|
652
661
|
if (!publicBuild && proxyTargetFor(u.hostname, currentEnv())) {
|
|
653
|
-
writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(noTwinMessage(u.hostname, u.pathname)));
|
|
662
|
+
writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(noTwinMessage(u.hostname, u.pathname)), twinGap(u.hostname, u.pathname, currentEnv()));
|
|
654
663
|
return;
|
|
655
664
|
}
|
|
656
|
-
|
|
657
|
-
|
|
665
|
+
const refused = shouldBlockUntwinned(u, currentEnv());
|
|
666
|
+
if (refused) {
|
|
667
|
+
writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`blocked untwinned external request to ${u.hostname}\n`), refused);
|
|
658
668
|
return;
|
|
659
669
|
}
|
|
660
670
|
const policy = worldNetworkPolicyFromEnv(currentEnv());
|
|
@@ -681,7 +691,7 @@ export async function startRedirectProxy(options) {
|
|
|
681
691
|
if (guest) {
|
|
682
692
|
void guestDestination(u.hostname).then((d) => {
|
|
683
693
|
if ('refused' in d) {
|
|
684
|
-
writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`${d.refused}\n`));
|
|
694
|
+
writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`${d.refused}\n`), d.hostNetwork ? hostNetworkRefusal(u.hostname, Number(u.port || (u.protocol === 'https:' ? 443 : 80)), d.address ?? d.refused) : undefined);
|
|
685
695
|
return;
|
|
686
696
|
}
|
|
687
697
|
// to the address checked (TLS still names the host: SNI and the certificate)
|
|
@@ -733,21 +743,22 @@ export async function startRedirectProxy(options) {
|
|
|
733
743
|
// MITM vendor hosts → tunnel to the per-host listening https server (terminates TLS, forwards to
|
|
734
744
|
// the twin). Non-vendor hosts → blind-tunnel to the REAL origin, untouched.
|
|
735
745
|
const scopedOrigin = connectHost && new URL(`https://${connectHost}:${connectPort}`);
|
|
746
|
+
const connectRefusal = connectHost ? shouldBlockUntwinned(`https://${connectHost}:${connectPort}`, currentEnv()) : null;
|
|
736
747
|
const scopedPolicy = worldNetworkPolicyFromEnv(currentEnv());
|
|
737
748
|
const inspected = scopedOrigin && scopedPolicy && worldNetworkNeedsPathInspection(scopedPolicy, scopedOrigin);
|
|
738
749
|
const passthrough = scopedOrigin && scopedPolicy && worldNetworkUsesPassthrough(scopedPolicy, scopedOrigin);
|
|
739
750
|
if (passthrough && !twin)
|
|
740
751
|
note(`build TLS passthrough ${scopedOrigin.origin} (explicit origin scope)`);
|
|
741
752
|
if (inspected && guest && !twin) {
|
|
742
|
-
writeResponse(clientSocket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n'));
|
|
753
|
+
writeResponse(clientSocket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n'), guestPinRefusal(`https://${connectHost}:${connectPort}`));
|
|
743
754
|
return;
|
|
744
755
|
}
|
|
745
|
-
if ((twin || (!
|
|
756
|
+
if ((twin || (!worldStrictEgress(currentEnv()) && inspected)) && connectHost) {
|
|
746
757
|
ensureHostServer(connectHost, guest, connectPort)
|
|
747
758
|
.then((port) => tunnel(port, '127.0.0.1'))
|
|
748
759
|
.catch(() => clientSocket.destroy());
|
|
749
760
|
}
|
|
750
|
-
else if (connectHost &&
|
|
761
|
+
else if (connectHost && connectRefusal) {
|
|
751
762
|
note(`blocked untwinned external request to ${connectHost}:${connectPort}`);
|
|
752
763
|
// a browser's https to a DNS name: terminated with the World's TLS and answered by the host server's page
|
|
753
764
|
// (outsideWorld), so the person sees where they were sent. Anything else (another port or protocol, an address,
|
|
@@ -760,7 +771,7 @@ export async function startRedirectProxy(options) {
|
|
|
760
771
|
.catch(() => clientSocket.destroy());
|
|
761
772
|
}
|
|
762
773
|
else {
|
|
763
|
-
clientSocket.write(
|
|
774
|
+
clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\n${REFUSAL_HEADER}: ${connectRefusal.missing}\r\n\r\nblocked untwinned external request${refusalText(connectRefusal)}`);
|
|
764
775
|
clientSocket.destroy();
|
|
765
776
|
}
|
|
766
777
|
}
|
|
@@ -769,7 +780,8 @@ export async function startRedirectProxy(options) {
|
|
|
769
780
|
void guestDestination(connectHost).then((d) => {
|
|
770
781
|
if ('refused' in d) {
|
|
771
782
|
note(`refused a machine's tunnel to ${connectHost}:${connectPort}: ${d.refused}`);
|
|
772
|
-
|
|
783
|
+
const refusal = d.hostNetwork ? hostNetworkRefusal(connectHost, connectPort, d.address ?? d.refused) : null;
|
|
784
|
+
clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\n${refusal ? `${REFUSAL_HEADER}: ${refusal.missing}\r\n` : ''}\r\n${d.refused}${refusal ? refusalText(refusal) : '\r\n'}`);
|
|
773
785
|
clientSocket.destroy();
|
|
774
786
|
}
|
|
775
787
|
else
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.68",
|
|
4
4
|
"description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -69,14 +69,14 @@
|
|
|
69
69
|
"node": ">=22.3"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"@volter/world-core": "3.0.
|
|
73
|
-
"@volter/world-console": "3.0.
|
|
72
|
+
"@volter/world-core": "3.0.68",
|
|
73
|
+
"@volter/world-console": "3.0.63"
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
76
|
"@electric-sql/pglite": "0.5.8",
|
|
77
77
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
78
78
|
"@volter/world-access": "3.0.1",
|
|
79
|
-
"@volter/world-core": "3.0.
|
|
79
|
+
"@volter/world-core": "3.0.68",
|
|
80
80
|
"mongodb": "~6.20.0",
|
|
81
81
|
"pg-gateway": "0.3.0-beta.4",
|
|
82
82
|
"smol-toml": "^1.8.0",
|
package/src/inject-map.ts
CHANGED
|
@@ -15,6 +15,8 @@ export type InjectModule = {
|
|
|
15
15
|
twinUrlVendor(name: string): string | null;
|
|
16
16
|
/** the headers a request routed to the application carries: the host and scheme it was made with */
|
|
17
17
|
appForwardHeaders(url: URL): Record<string, string>;
|
|
18
|
+
/** the vendors whose twins this map runs and whose host rules name `host`, whatever the path */
|
|
19
|
+
twinnedHostVendors(host: string, map: Record<string, string>): string[];
|
|
18
20
|
VENDOR_HOSTS: Record<string, (host: string, pathname?: string) => boolean>;
|
|
19
21
|
restore(): void;
|
|
20
22
|
};
|
package/src/redirect-proxy.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { errorMessage } from './error-message.ts';
|
|
2
2
|
import { serveHttp, type HttpServer } from '@volter/world-core/runtime';
|
|
3
|
-
import { worldNetworkPolicyFromEnv,
|
|
3
|
+
import { worldNetworkPolicyFromEnv, worldGrantFromEnv, worldStrictEgress, worldNetworkNeedsPathInspection, worldNetworkUsesPassthrough } from '@volter/world-core/network-policy';
|
|
4
|
+
import { OPEN_DECLARATION, decideCapability, publicBuildGrant, refusalRecord, refusalSide, type CapabilityAnswer } from '@volter/world-core/capability-decision';
|
|
4
5
|
import { websocketRelay } from './websocket-relay.ts';
|
|
5
6
|
// The ambient TLS redirect proxy — the substance of "any CLI, zero config" (WORLD_ACTIVATE.md §"How
|
|
6
7
|
// redirection works", tier 1). A local TLS-terminating forward proxy that maps vendor API hosts
|
|
@@ -58,10 +59,10 @@ import { sessionTrustEnv, type CaTrustInputs } from './ca-trust.ts';
|
|
|
58
59
|
*/
|
|
59
60
|
export function noTwinMessage(host: string, path: string): string {
|
|
60
61
|
return `host ${host} is twinned in this world, but path ${JSON.stringify(path)} belongs to no twin configured here.\n`
|
|
61
|
-
+ 'This host is shared between vendors, and no
|
|
62
|
+
+ 'This host is shared between vendors, and no twin in this world serves this path. Either the\n'
|
|
62
63
|
+ 'vendor that owns this path has a twin that is not configured — set its *_TWIN_URL (for\n'
|
|
63
64
|
+ 'www.googleapis.com: YOUTUBE_TWIN_URL serves /youtube/v3/*, GOOGLEAUTH_TWIN_URL serves the\n'
|
|
64
|
-
+ 'OAuth2 token paths) — or this API has no twin
|
|
65
|
+
+ 'OAuth2 token paths) — or this API has no twin yet (e.g. Google Drive /drive/v3/*),\n'
|
|
65
66
|
+ 'in which case this loud refusal is the honest outcome: the request is neither answered by the\n'
|
|
66
67
|
+ 'wrong twin nor leaked to the real vendor.\n';
|
|
67
68
|
}
|
|
@@ -88,27 +89,6 @@ export function activeVendorMap(env: Record<string, string | undefined>): Record
|
|
|
88
89
|
return loadInject().readMap(env);
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
function strictEgressEnabled(env: Record<string, string | undefined>): boolean {
|
|
92
|
-
return env.VOLTER_TWIN_STRICT_EGRESS === '1' || env.VOLTER_TWIN_STRICT_EGRESS === 'true';
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function isLocalOrPrivateHost(hostname: string): boolean {
|
|
96
|
-
const h = String(hostname || '').toLowerCase();
|
|
97
|
-
if (!h) return true;
|
|
98
|
-
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.test')) return true;
|
|
99
|
-
if (h === '0.0.0.0' || h === '127.0.0.1' || h === '::1' || h === '[::1]') return true;
|
|
100
|
-
if (isIP(h) !== 4) return false;
|
|
101
|
-
if (/^127\./.test(h)) return true;
|
|
102
|
-
if (/^10\./.test(h)) return true;
|
|
103
|
-
if (/^192\.168\./.test(h)) return true;
|
|
104
|
-
const match = h.match(/^172\.(\d+)\./);
|
|
105
|
-
if (match) {
|
|
106
|
-
const n = Number(match[1]);
|
|
107
|
-
if (n >= 16 && n <= 31) return true;
|
|
108
|
-
}
|
|
109
|
-
return false;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
92
|
/** A connection from anything but the proxy host's own loopback is a guest's (a World Machine: its forwarder connects
|
|
113
93
|
* from 127.0.0.2 on this host, or from its VM's address): architecture, "The scoped proxy's local pass-through". */
|
|
114
94
|
export function isGuestSource(address: string | undefined): boolean {
|
|
@@ -161,23 +141,62 @@ export function isGuestForbiddenAddress(ip: string): boolean {
|
|
|
161
141
|
|
|
162
142
|
/** Where a guest may be connected for `host`: the address checked (a literal, or what the name resolves to, every
|
|
163
143
|
* answer checked), or the refusal. The caller connects to that address, so a second resolution cannot move it. */
|
|
164
|
-
export async function guestDestination(host: string): Promise<{ address: string } | { refused: string }> {
|
|
144
|
+
export async function guestDestination(host: string): Promise<{ address: string } | { refused: string; hostNetwork?: true; address?: string }> {
|
|
165
145
|
const h = host.replace(/^\[|\]$/g, '');
|
|
166
146
|
let addresses: string[];
|
|
167
147
|
try { addresses = isIP(h) ? [h] : (await dnsLookup(h, { all: true })).map((r) => r.address); } catch (error) {
|
|
168
148
|
return { refused: `${host} does not resolve (${errorMessage(error)})` };
|
|
169
149
|
}
|
|
170
150
|
const bad = addresses.find(isGuestForbiddenAddress);
|
|
171
|
-
if (bad || !addresses.length) return { refused: `${host} is on the machine's host network (${bad ?? 'no address'}), which a World Machine cannot reach
|
|
151
|
+
if (bad || !addresses.length) return { refused: `${host} is on the machine's host network (${bad ?? 'no address'}), which a World Machine cannot reach`, hostNetwork: true, address: bad ?? 'no address' };
|
|
172
152
|
return { address: addresses[0]! };
|
|
173
153
|
}
|
|
174
154
|
|
|
175
|
-
|
|
155
|
+
type WorldRefusal = Extract<CapabilityAnswer, { outcome: 'refuse' }>;
|
|
156
|
+
|
|
157
|
+
/** The World's refusal of `value` (sealed, unlisted, an unreadable policy), or null: the World's capability decision
|
|
158
|
+
* (docs/contributing/architecture.md, "Capability decisions") for a host process, with the twin of its host as the fact.
|
|
159
|
+
* `method` is given where the proxy sees it, on the effective request it sends to the real origin itself: no twin
|
|
160
|
+
* answers that request, so its host's twin is no fact of it. */
|
|
161
|
+
function shouldBlockUntwinned(value: URL | string, env: Record<string, string | undefined>, method?: string): WorldRefusal | null {
|
|
176
162
|
let url: URL;
|
|
177
|
-
try { url = value instanceof URL ? value : new URL(value); } catch {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
163
|
+
try { url = value instanceof URL ? value : new URL(value); } catch {
|
|
164
|
+
return { outcome: 'refuse', capability: 'network.egress', target: String(value), missing: 'grant', rule: 'invalid', reason: 'the request has an invalid effective authority or path' };
|
|
165
|
+
}
|
|
166
|
+
const answer = decideCapability({ kind: 'egress', url, transport: 'http', ...(method ? { method } : {}) },
|
|
167
|
+
{ run: { guest: false }, declaration: OPEN_DECLARATION, grant: worldGrantFromEnv(env), facts: { twin: method ? null : proxyTargetFor(url.hostname, env) } });
|
|
168
|
+
return answer.outcome === 'refuse' ? answer : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The World's refusal of a path no twin here claims on a host its twins serve. */
|
|
172
|
+
function twinGap(host: string, path: string, env: Record<string, string | undefined>): WorldRefusal {
|
|
173
|
+
const inject = loadInject();
|
|
174
|
+
const vendors = inject.twinnedHostVendors(host, inject.readMap(env));
|
|
175
|
+
const named = vendors.length ? vendors : [proxyTargetFor(host, env)?.vendor ?? host];
|
|
176
|
+
return { outcome: 'refuse', capability: 'network.egress', target: `https://${host}${path}`, missing: 'twin', rule: 'unclaimed-path',
|
|
177
|
+
reason: `no twin in this World claims ${path} on ${host} (${named.join(', ')})` };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A refusal for a client that is not Node (curl, Python, Go): the decision's reason and side in words, its record as
|
|
181
|
+
* JSON on the line after, and the side in a header, as a Node client reads them from error.worldRefusal. */
|
|
182
|
+
function refusalText(refusal: WorldRefusal): string {
|
|
183
|
+
return ` (${refusal.reason}: ${refusalSide(refusal)})\n${JSON.stringify(refusalRecord(refusal))}\n`;
|
|
184
|
+
}
|
|
185
|
+
const REFUSAL_HEADER = 'x-volter-world-refusal';
|
|
186
|
+
|
|
187
|
+
/** A World Machine guest's destination on the host's own networks (the address guestDestination found there): the
|
|
188
|
+
* World's decision for a guest's loopback or private destination that is neither a door nor a declared service. A
|
|
189
|
+
* name that does not resolve is no refusal of the World's, and carries none. */
|
|
190
|
+
function hostNetworkRefusal(host: string, port: number, address: string): WorldRefusal {
|
|
191
|
+
const answer = decideCapability({ kind: 'loopback', host: address, port }, { run: { guest: true }, declaration: OPEN_DECLARATION, facts: {} });
|
|
192
|
+
if (answer.outcome !== 'refuse') throw new Error(`the World's decision admits a guest's host-network destination ${host}:${port}`);
|
|
193
|
+
return { ...answer, target: `${host}:${port}` };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A World Machine guest's inspected egress: the proxy cannot keep the guest's checked destination pin on it. */
|
|
197
|
+
function guestPinRefusal(target: string): WorldRefusal {
|
|
198
|
+
return { outcome: 'refuse', capability: 'network.egress', target, missing: 'transport', rule: 'guest-inspected',
|
|
199
|
+
reason: 'inspected egress cannot keep a guest\'s checked destination pin' };
|
|
181
200
|
}
|
|
182
201
|
|
|
183
202
|
// --- session-scoped CA + on-the-fly leaf certs (via openssl) ---------------------------------
|
|
@@ -341,10 +360,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
341
360
|
// or real upstream would otherwise keep the server from closing).
|
|
342
361
|
const openSockets = new Set<Socket>();
|
|
343
362
|
|
|
344
|
-
const publicBuildRequest = (target: URL, env: Record<string, string | undefined>): boolean =>
|
|
345
|
-
const policy = worldNetworkPolicyFromEnv(env);
|
|
346
|
-
return !strictEgressEnabled(env) && !!policy && policy.phase === 'build' && allowsWorldNetworkEgress(policy, target);
|
|
347
|
-
};
|
|
363
|
+
const publicBuildRequest = (target: URL, env: Record<string, string | undefined>): boolean => publicBuildGrant(worldGrantFromEnv(env), target);
|
|
348
364
|
|
|
349
365
|
// One upstream HTTP operation, shared by plain-proxy and decrypted CONNECT requests.
|
|
350
366
|
// It never follows redirects and never lends the upstream socket to the caller.
|
|
@@ -352,7 +368,8 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
352
368
|
const env = currentEnv();
|
|
353
369
|
const policy = worldNetworkPolicyFromEnv(env);
|
|
354
370
|
// the method is visible here: an egress grant reads, a write needs `writes`
|
|
355
|
-
|
|
371
|
+
const refused = shouldBlockUntwinned(target, env, method);
|
|
372
|
+
if (!policy || refused) throw Object.assign(new Error('World policy refuses the effective request'), refused ? { worldRefusal: refused } : {});
|
|
356
373
|
const host = incoming.get('host');
|
|
357
374
|
if (host && new URL(`${target.protocol}//${host}`).host !== target.host) throw new Error('request Host differs from authorized authority');
|
|
358
375
|
if (method === 'CONNECT' || incoming.has('upgrade')) throw new Error('inspected egress cannot upgrade its transport');
|
|
@@ -467,17 +484,22 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
467
484
|
// a person's browser shows where it was sent and why nothing is there, not an empty tunnel error; nothing
|
|
468
485
|
// leaves the World
|
|
469
486
|
const target = new URL(`https://${vendorHost}:${originPort}${u.pathname}${u.search}`);
|
|
470
|
-
|
|
471
|
-
if (
|
|
487
|
+
const refused = shouldBlockUntwinned(target.href, env);
|
|
488
|
+
if (refused) return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'), undefined, refused);
|
|
489
|
+
if (!publicBuild && proxyTargetFor(vendorHost, env)) return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'), undefined, twinGap(vendorHost, target.pathname, env));
|
|
472
490
|
const policy = worldNetworkPolicyFromEnv(env);
|
|
473
491
|
if (policy && worldNetworkNeedsPathInspection(policy, target)) {
|
|
474
|
-
if (guest) return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'),
|
|
492
|
+
if (guest) return outsideWorld(vendorHost, target.pathname, req.headers.get('accept'), undefined, guestPinRefusal(target.href));
|
|
475
493
|
try { return await externalRequest(target, req.method, req.headers, req.body, req.signal); }
|
|
476
|
-
catch (error) {
|
|
494
|
+
catch (error) {
|
|
495
|
+
const refused = (error as { worldRefusal?: WorldRefusal }).worldRefusal;
|
|
496
|
+
return new Response(note(`inspected egress refused: ${(error as Error).message}${refused ? refusalText(refused) : '\n'}`), { status: 502, headers: refused ? { [REFUSAL_HEADER]: refused.missing } : {} });
|
|
497
|
+
}
|
|
477
498
|
}
|
|
478
499
|
const unreachable = unreachableHosts.get(hostHeader.toLowerCase());
|
|
479
500
|
if (unreachable) return outsideWorld(hostHeader, `${u.pathname}${u.search}`, req.headers.get('accept'), unreachable);
|
|
480
|
-
|
|
501
|
+
const gap = twinGap(hostHeader, u.pathname, env);
|
|
502
|
+
return new Response(note(noTwinMessage(hostHeader, u.pathname)) + refusalText(gap).trimStart(), { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8', [REFUSAL_HEADER]: gap.missing } });
|
|
481
503
|
}
|
|
482
504
|
const origin = new URL(twin.origin);
|
|
483
505
|
|
|
@@ -568,20 +590,23 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
568
590
|
|
|
569
591
|
/** A refused host's answer: 502, naming the host and why, as a page a person reads in a browser (the address bar
|
|
570
592
|
* keeps where the request was sent) or as text for any other client. */
|
|
571
|
-
const outsideWorld = (host: string, path: string, accept: string | null, unreachable?: string): Response => {
|
|
593
|
+
const outsideWorld = (host: string, path: string, accept: string | null, unreachable?: string, refusal?: WorldRefusal): Response => {
|
|
572
594
|
// the CONNECT that reached this server was noted when it was refused
|
|
573
595
|
const why = unreachable
|
|
574
596
|
? `no twin serves it, and it did not answer from this World (${unreachable}).`
|
|
575
597
|
: 'no twin serves it, and the World does not reach the internet for it.';
|
|
576
|
-
const text = `${host} is outside this World: ${why}\n`;
|
|
598
|
+
const text = `${host} is outside this World: ${why}${refusal ? refusalText(refusal) : '\n'}`;
|
|
599
|
+
const refused: Record<string, string> = refusal ? { [REFUSAL_HEADER]: refusal.missing } : {};
|
|
577
600
|
// connection: close, so a reload opens a new CONNECT, which reaches the origin once it answers (a kept-alive tunnel
|
|
578
601
|
// would keep answering this page)
|
|
579
|
-
if (!(accept ?? '').includes('text/html')) return new Response(text, { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8', connection: 'close' } });
|
|
602
|
+
if (!(accept ?? '').includes('text/html')) return new Response(text, { status: 502, headers: { 'content-type': 'text/plain; charset=utf-8', connection: 'close', ...refused } });
|
|
580
603
|
const esc = (v: string) => v.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!);
|
|
581
604
|
const page = `<!doctype html><html><head><meta charset="utf-8"><title>${esc(host)} is outside this World</title>`
|
|
582
605
|
+ '<style>body{font:15px system-ui,sans-serif;max-width:620px;margin:12vh auto;padding:0 16px;color:#222}code{background:#f2f2f2;padding:1px 4px;border-radius:4px}</style></head><body>'
|
|
583
|
-
+ `<h1>${esc(host)} is outside this World</h1><p>You were sent to <code>https://${esc(host)}${esc(path)}</code>. ${esc(why.charAt(0).toUpperCase() + why.slice(1))} There is nothing to show here.</p
|
|
584
|
-
|
|
606
|
+
+ `<h1>${esc(host)} is outside this World</h1><p>You were sent to <code>https://${esc(host)}${esc(path)}</code>. ${esc(why.charAt(0).toUpperCase() + why.slice(1))} There is nothing to show here.</p>`
|
|
607
|
+
+ (refusal ? `<p>${esc(`${refusal.reason}: ${refusalSide(refusal)}`)}.</p><script type="application/json" id="volter-world-refusal">${esc(JSON.stringify(refusalRecord(refusal)))}</script>` : '')
|
|
608
|
+
+ '</body></html>';
|
|
609
|
+
return new Response(page, { status: 502, headers: { 'content-type': 'text/html; charset=utf-8', connection: 'close', ...refused } });
|
|
585
610
|
};
|
|
586
611
|
|
|
587
612
|
// the refused hosts answered with a page, each holding a leaf and a listener until the proxy closes: bounded
|
|
@@ -591,8 +616,9 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
591
616
|
const unreachableHosts = new Map<string, string>();
|
|
592
617
|
const isDnsName = (host: string): boolean => /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/i.test(host);
|
|
593
618
|
|
|
594
|
-
const writeResponse = (socket: Socket, status: string, body: string): void => {
|
|
595
|
-
|
|
619
|
+
const writeResponse = (socket: Socket, status: string, body: string, refusal?: WorldRefusal): void => {
|
|
620
|
+
const text = refusal ? body.replace(/\n$/, '') + refusalText(refusal) : body;
|
|
621
|
+
socket.write(`${status}\r\ncontent-type: text/plain\r\ncontent-length: ${Buffer.byteLength(text)}\r\n${refusal ? `${REFUSAL_HEADER}: ${refusal.missing}\r\n` : ''}connection: close\r\n\r\n${text}`);
|
|
596
622
|
socket.end();
|
|
597
623
|
};
|
|
598
624
|
|
|
@@ -659,11 +685,12 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
659
685
|
// an unclaimed slice of a shared, virtualized host: refuse loudly (same rule as the MITM
|
|
660
686
|
// handler's noTwinMessage), never pass through to the real vendor.
|
|
661
687
|
if (!publicBuild && proxyTargetFor(u.hostname, currentEnv())) {
|
|
662
|
-
writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(noTwinMessage(u.hostname, u.pathname)));
|
|
688
|
+
writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(noTwinMessage(u.hostname, u.pathname)), twinGap(u.hostname, u.pathname, currentEnv()));
|
|
663
689
|
return;
|
|
664
690
|
}
|
|
665
|
-
|
|
666
|
-
|
|
691
|
+
const refused = shouldBlockUntwinned(u, currentEnv());
|
|
692
|
+
if (refused) {
|
|
693
|
+
writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`blocked untwinned external request to ${u.hostname}\n`), refused);
|
|
667
694
|
return;
|
|
668
695
|
}
|
|
669
696
|
const policy = worldNetworkPolicyFromEnv(currentEnv());
|
|
@@ -686,7 +713,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
686
713
|
// the address checked (architecture, "The scoped proxy's local pass-through")
|
|
687
714
|
if (guest) {
|
|
688
715
|
void guestDestination(u.hostname).then((d) => {
|
|
689
|
-
if ('refused' in d) { writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`${d.refused}\n`)); return; }
|
|
716
|
+
if ('refused' in d) { writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', note(`${d.refused}\n`), d.hostNetwork ? hostNetworkRefusal(u.hostname, Number(u.port || (u.protocol === 'https:' ? 443 : 80)), d.address ?? d.refused) : undefined); return; }
|
|
690
717
|
// to the address checked (TLS still names the host: SNI and the certificate)
|
|
691
718
|
forwardRawHttp(new URL(`${u.protocol}//${u.host}`), method, path, version, headers, socket, head, undefined, d.address);
|
|
692
719
|
}).catch(() => writeResponse(socket, 'HTTP/1.1 502 Bad Gateway', 'bad gateway\n')); // (a throw must not end the proxy)
|
|
@@ -733,19 +760,20 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
733
760
|
// MITM vendor hosts → tunnel to the per-host listening https server (terminates TLS, forwards to
|
|
734
761
|
// the twin). Non-vendor hosts → blind-tunnel to the REAL origin, untouched.
|
|
735
762
|
const scopedOrigin = connectHost && new URL(`https://${connectHost}:${connectPort}`);
|
|
763
|
+
const connectRefusal = connectHost ? shouldBlockUntwinned(`https://${connectHost}:${connectPort}`, currentEnv()) : null;
|
|
736
764
|
const scopedPolicy = worldNetworkPolicyFromEnv(currentEnv());
|
|
737
765
|
const inspected = scopedOrigin && scopedPolicy && worldNetworkNeedsPathInspection(scopedPolicy, scopedOrigin);
|
|
738
766
|
const passthrough = scopedOrigin && scopedPolicy && worldNetworkUsesPassthrough(scopedPolicy, scopedOrigin);
|
|
739
767
|
if (passthrough && !twin) note(`build TLS passthrough ${scopedOrigin.origin} (explicit origin scope)`);
|
|
740
768
|
if (inspected && guest && !twin) {
|
|
741
|
-
writeResponse(clientSocket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n'));
|
|
769
|
+
writeResponse(clientSocket, 'HTTP/1.1 502 Bad Gateway', note('guest inspected egress requires a destination-pinned transport\n'), guestPinRefusal(`https://${connectHost}:${connectPort}`));
|
|
742
770
|
return;
|
|
743
771
|
}
|
|
744
|
-
if ((twin || (!
|
|
772
|
+
if ((twin || (!worldStrictEgress(currentEnv()) && inspected)) && connectHost) {
|
|
745
773
|
ensureHostServer(connectHost, guest, connectPort)
|
|
746
774
|
.then((port) => tunnel(port, '127.0.0.1'))
|
|
747
775
|
.catch(() => clientSocket.destroy());
|
|
748
|
-
} else if (connectHost &&
|
|
776
|
+
} else if (connectHost && connectRefusal) {
|
|
749
777
|
note(`blocked untwinned external request to ${connectHost}:${connectPort}`);
|
|
750
778
|
// a browser's https to a DNS name: terminated with the World's TLS and answered by the host server's page
|
|
751
779
|
// (outsideWorld), so the person sees where they were sent. Anything else (another port or protocol, an address,
|
|
@@ -757,7 +785,7 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
757
785
|
.then((port) => tunnel(port, '127.0.0.1'))
|
|
758
786
|
.catch(() => clientSocket.destroy());
|
|
759
787
|
} else {
|
|
760
|
-
clientSocket.write(
|
|
788
|
+
clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\n${REFUSAL_HEADER}: ${connectRefusal.missing}\r\n\r\nblocked untwinned external request${refusalText(connectRefusal)}`);
|
|
761
789
|
clientSocket.destroy();
|
|
762
790
|
}
|
|
763
791
|
} else if (guest) {
|
|
@@ -765,7 +793,8 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
|
|
|
765
793
|
void guestDestination(connectHost!).then((d) => {
|
|
766
794
|
if ('refused' in d) {
|
|
767
795
|
note(`refused a machine's tunnel to ${connectHost}:${connectPort}: ${d.refused}`);
|
|
768
|
-
|
|
796
|
+
const refusal = d.hostNetwork ? hostNetworkRefusal(connectHost!, connectPort, d.address ?? d.refused) : null;
|
|
797
|
+
clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\n${refusal ? `${REFUSAL_HEADER}: ${refusal.missing}\r\n` : ''}\r\n${d.refused}${refusal ? refusalText(refusal) : '\r\n'}`);
|
|
769
798
|
clientSocket.destroy();
|
|
770
799
|
} else tunnel(connectPort, d.address);
|
|
771
800
|
}).catch(() => clientSocket.destroy()); // (outside the handler's own try: a throw here must not end the proxy)
|