@questpie/sandbox 3.16.0 → 3.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -18
- package/dist/adapter-http-Coy4jqsj.mjs +865 -0
- package/dist/adapter-http-DDeL-Ym3.d.mts +259 -0
- package/dist/adapter.d.mts +3 -2
- package/dist/adapter.mjs +3 -2
- package/dist/custom-tools-CgfgyVNv.d.mts +55 -0
- package/dist/custom-tools-DxI7JvUn.mjs +407 -0
- package/dist/index.d.mts +40 -115
- package/dist/index.mjs +11 -14
- package/dist/modules/sandbox.d.mts +40 -0
- package/dist/modules/sandbox.mjs +211 -0
- package/package.json +20 -5
- package/src/broker-wire.ts +94 -0
- package/src/egress-firewall.ts +9 -3
- package/src/guest-bindings.ts +43 -1
- package/src/guest-entry.ts +132 -22
- package/src/guest-runtime-source.ts +38 -0
- package/src/net-validation.ts +204 -51
- package/src/sandbox-server.ts +860 -144
- package/src/server-internals.ts +195 -12
- package/src/types.ts +38 -10
- package/src/workload-admission.ts +289 -0
- package/src/workload.ts +441 -0
- package/dist/adapter-http-7ypWHBck.d.mts +0 -43
- package/dist/adapter-http-w7SjAfcC.mjs +0 -298
package/src/guest-entry.ts
CHANGED
|
@@ -38,12 +38,16 @@
|
|
|
38
38
|
|
|
39
39
|
// @ts-nocheck — Deno runtime file; not part of the Bun/tsc typecheck graph.
|
|
40
40
|
|
|
41
|
+
import nodeProcess from "node:process";
|
|
42
|
+
|
|
41
43
|
import { buildGuestBindings } from "./guest-bindings.ts";
|
|
42
44
|
|
|
43
45
|
/** Marker so the supervisor can pick the result line out of guest stdout noise. */
|
|
44
46
|
const RESULT_MARKER = "__QP_SANDBOX_RESULT__";
|
|
45
47
|
/** Marker prefixing each FRAMED protocol message (bindings path). */
|
|
46
48
|
const FRAME_MARKER = "__QP_SANDBOX_MSG__";
|
|
49
|
+
/** Injected by the supervisor from the canonical decoded broker-body budget. */
|
|
50
|
+
const HTTP_FETCH_BODY_CAP_BYTES = __QP_HTTP_FETCH_BODY_CAP_BYTES__;
|
|
47
51
|
|
|
48
52
|
// ── 1. HARDEN GLOBALS (must happen before importing/eval'ing guest source) ──
|
|
49
53
|
|
|
@@ -70,6 +74,39 @@ for (const name of ["SharedArrayBuffer", "Atomics"]) {
|
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
// The supervisor starts from the stable OS cwd `/` and loads this entry through
|
|
78
|
+
// an internal loopback module URL. Normalize every Deno/Node compatibility path
|
|
79
|
+
// surface before guest import. If the qualified Deno runtime cannot lock one of
|
|
80
|
+
// these surfaces, fail startup instead of leaking a host or principal path.
|
|
81
|
+
Object.defineProperty(Deno, "cwd", {
|
|
82
|
+
value: () => "/work",
|
|
83
|
+
configurable: false,
|
|
84
|
+
writable: false,
|
|
85
|
+
});
|
|
86
|
+
Object.defineProperty(Deno, "mainModule", {
|
|
87
|
+
value: "questpie://sandbox/guest-entry.ts",
|
|
88
|
+
configurable: false,
|
|
89
|
+
writable: false,
|
|
90
|
+
});
|
|
91
|
+
Object.defineProperty(Deno, "execPath", {
|
|
92
|
+
value: () => "/runtime/deno",
|
|
93
|
+
configurable: false,
|
|
94
|
+
writable: false,
|
|
95
|
+
});
|
|
96
|
+
Object.defineProperty(nodeProcess, "cwd", {
|
|
97
|
+
value: () => "/work",
|
|
98
|
+
configurable: false,
|
|
99
|
+
writable: false,
|
|
100
|
+
});
|
|
101
|
+
Object.defineProperty(nodeProcess, "argv", {
|
|
102
|
+
value: Object.freeze(["deno", "questpie://sandbox/guest-entry.ts"]),
|
|
103
|
+
configurable: false,
|
|
104
|
+
writable: false,
|
|
105
|
+
});
|
|
106
|
+
nodeProcess.execPath = "/runtime/deno";
|
|
107
|
+
// `argv0` is a non-configurable Deno Node-compat accessor. The supervisor sets
|
|
108
|
+
// the real child argv[0] to the stable runtime name before this module loads.
|
|
109
|
+
|
|
73
110
|
// ── 2. CAPTURE CONSOLE ──
|
|
74
111
|
|
|
75
112
|
const logs: string[] = [];
|
|
@@ -97,7 +134,9 @@ for (const level of ["log", "error", "warn", "info"] as const) {
|
|
|
97
134
|
}
|
|
98
135
|
})
|
|
99
136
|
.join(" ");
|
|
100
|
-
logs.push(
|
|
137
|
+
logs.push(
|
|
138
|
+
line.length > MAX_LOG_LEN ? line.slice(0, MAX_LOG_LEN) + "…" : line,
|
|
139
|
+
);
|
|
101
140
|
}
|
|
102
141
|
};
|
|
103
142
|
}
|
|
@@ -165,20 +204,45 @@ function createLineReader() {
|
|
|
165
204
|
};
|
|
166
205
|
}
|
|
167
206
|
|
|
168
|
-
/** Write one framed message line to stdout. */
|
|
169
|
-
function writeFrame(msg: unknown) {
|
|
207
|
+
/** Write one complete framed message line to stdout without blocking the guest loop. */
|
|
208
|
+
async function writeFrame(msg: unknown): Promise<void> {
|
|
170
209
|
let payload: string;
|
|
171
210
|
try {
|
|
172
211
|
payload = JSON.stringify(msg);
|
|
173
212
|
} catch {
|
|
174
|
-
payload = JSON.stringify({
|
|
213
|
+
payload = JSON.stringify({
|
|
214
|
+
type: "result",
|
|
215
|
+
ok: false,
|
|
216
|
+
error: "frame not serializable",
|
|
217
|
+
logs,
|
|
218
|
+
});
|
|
175
219
|
}
|
|
176
220
|
// deno-lint-ignore no-explicit-any
|
|
177
|
-
(Deno as any).stdout
|
|
221
|
+
const stdout = (Deno as any).stdout;
|
|
222
|
+
const frame = encoder.encode(FRAME_MARKER + payload + "\n");
|
|
223
|
+
const maximumWriteBytes = 64 * 1024;
|
|
224
|
+
let offset = 0;
|
|
225
|
+
while (offset < frame.byteLength) {
|
|
226
|
+
const written = await stdout.write(
|
|
227
|
+
frame.subarray(
|
|
228
|
+
offset,
|
|
229
|
+
Math.min(frame.byteLength, offset + maximumWriteBytes),
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
if (!Number.isSafeInteger(written) || written <= 0) {
|
|
233
|
+
throw new Error("sandbox stdout closed during frame");
|
|
234
|
+
}
|
|
235
|
+
offset += written;
|
|
236
|
+
}
|
|
178
237
|
}
|
|
179
238
|
|
|
180
239
|
/** Emit the LEGACY single-line result (no bindings). */
|
|
181
|
-
function emitLegacy(result: {
|
|
240
|
+
function emitLegacy(result: {
|
|
241
|
+
ok: boolean;
|
|
242
|
+
output?: unknown;
|
|
243
|
+
logs: string[];
|
|
244
|
+
error?: string;
|
|
245
|
+
}) {
|
|
182
246
|
let payload: string;
|
|
183
247
|
try {
|
|
184
248
|
payload = JSON.stringify(result);
|
|
@@ -195,7 +259,9 @@ function emitLegacy(result: { ok: boolean; output?: unknown; logs: string[]; err
|
|
|
195
259
|
// ── 4. IMPORT + INVOKE THE GUEST ──
|
|
196
260
|
|
|
197
261
|
/** Dynamic-import the guest source as a blob module and return its default fn. */
|
|
198
|
-
async function loadGuestEntry(
|
|
262
|
+
async function loadGuestEntry(
|
|
263
|
+
source: string,
|
|
264
|
+
): Promise<(input: unknown) => unknown> {
|
|
199
265
|
const guestUrl = URL.createObjectURL(
|
|
200
266
|
new Blob([source], { type: "application/typescript" }),
|
|
201
267
|
);
|
|
@@ -228,6 +294,7 @@ type Envelope = {
|
|
|
228
294
|
|
|
229
295
|
/** Encode raw bytes to base64 (works for arbitrary binary; no Buffer in Deno). */
|
|
230
296
|
function bytesToBase64(bytes: Uint8Array): string {
|
|
297
|
+
if (typeof bytes.toBase64 === "function") return bytes.toBase64();
|
|
231
298
|
let binary = "";
|
|
232
299
|
const chunk = 0x8000; // chunk to avoid arg-count limits on String.fromCharCode
|
|
233
300
|
for (let i = 0; i < bytes.length; i += chunk) {
|
|
@@ -238,6 +305,9 @@ function bytesToBase64(bytes: Uint8Array): string {
|
|
|
238
305
|
|
|
239
306
|
/** Decode base64 to raw bytes (inverse of {@link bytesToBase64}). */
|
|
240
307
|
function base64ToBytes(b64: string): Uint8Array {
|
|
308
|
+
if (typeof Uint8Array.fromBase64 === "function") {
|
|
309
|
+
return Uint8Array.fromBase64(b64);
|
|
310
|
+
}
|
|
241
311
|
const binary = atob(b64);
|
|
242
312
|
const out = new Uint8Array(binary.length);
|
|
243
313
|
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
@@ -251,7 +321,9 @@ function base64ToBytes(b64: string): Uint8Array {
|
|
|
251
321
|
* fetch (any body shape → bytes), then frames the {@link HttpFetchRequest} and
|
|
252
322
|
* rebuilds a `Response` from the {@link HttpFetchResponse}.
|
|
253
323
|
*/
|
|
254
|
-
function installFetchShim(
|
|
324
|
+
function installFetchShim(
|
|
325
|
+
hostCall: (method: string, args: unknown) => Promise<unknown>,
|
|
326
|
+
) {
|
|
255
327
|
const brokeredFetch = async (
|
|
256
328
|
input: unknown,
|
|
257
329
|
init?: unknown,
|
|
@@ -276,7 +348,14 @@ function installFetchShim(hostCall: (method: string, args: unknown) => Promise<u
|
|
|
276
348
|
// no body; `arrayBuffer()` yields an empty buffer which we omit.
|
|
277
349
|
let bodyBase64: string | undefined;
|
|
278
350
|
const buf = await req.arrayBuffer();
|
|
279
|
-
if (buf.byteLength >
|
|
351
|
+
if (buf.byteLength > HTTP_FETCH_BODY_CAP_BYTES) {
|
|
352
|
+
// Send a deliberately malformed, tiny value so the trusted host performs
|
|
353
|
+
// the authoritative rejection and the supervisor latches it terminally.
|
|
354
|
+
// This avoids base64-encoding data already known to exceed the shared cap.
|
|
355
|
+
bodyBase64 = 0 as unknown as string;
|
|
356
|
+
} else if (buf.byteLength > 0) {
|
|
357
|
+
bodyBase64 = bytesToBase64(new Uint8Array(buf));
|
|
358
|
+
}
|
|
280
359
|
|
|
281
360
|
const request: HttpFetchRequest = {
|
|
282
361
|
url: req.url,
|
|
@@ -296,10 +375,13 @@ function installFetchShim(hostCall: (method: string, args: unknown) => Promise<u
|
|
|
296
375
|
}
|
|
297
376
|
|
|
298
377
|
const status = typeof value?.status === "number" ? value.status : 0;
|
|
299
|
-
const body = value?.bodyBase64
|
|
378
|
+
const body = value?.bodyBase64
|
|
379
|
+
? base64ToBytes(value.bodyBase64)
|
|
380
|
+
: undefined;
|
|
300
381
|
// `Response` forbids a body for 101/204/205/304 and for null-body statuses;
|
|
301
382
|
// guard so reconstruction never throws on those.
|
|
302
|
-
const nullBody =
|
|
383
|
+
const nullBody =
|
|
384
|
+
status === 204 || status === 205 || status === 304 || status < 200;
|
|
303
385
|
return new Response(nullBody ? null : (body ?? null), {
|
|
304
386
|
status,
|
|
305
387
|
statusText: typeof value?.statusText === "string" ? value.statusText : "",
|
|
@@ -327,18 +409,30 @@ interface HttpFetchResponse {
|
|
|
327
409
|
}
|
|
328
410
|
|
|
329
411
|
/** BINDINGS run — framed channel + `globalThis.questpie` proxy. */
|
|
330
|
-
async function runWithBindings(
|
|
412
|
+
async function runWithBindings(
|
|
413
|
+
envelope: Envelope,
|
|
414
|
+
reader: { next(): Promise<string | null> },
|
|
415
|
+
) {
|
|
331
416
|
// Pending host calls keyed by correlation id.
|
|
332
|
-
const pending = new Map<
|
|
417
|
+
const pending = new Map<
|
|
418
|
+
number,
|
|
419
|
+
{ resolve: (v: unknown) => void; reject: (e: unknown) => void }
|
|
420
|
+
>();
|
|
333
421
|
let nextId = 1;
|
|
334
422
|
|
|
335
423
|
// The injected transport: write an `rpc` frame, await its `rpc-result`.
|
|
336
|
-
const hostCall = (method: string, args: unknown): Promise<unknown> => {
|
|
424
|
+
const hostCall = async (method: string, args: unknown): Promise<unknown> => {
|
|
337
425
|
const id = nextId++;
|
|
338
|
-
|
|
426
|
+
const response = new Promise<unknown>((resolve, reject) => {
|
|
339
427
|
pending.set(id, { resolve, reject });
|
|
340
|
-
writeFrame({ type: "rpc", id, method, args });
|
|
341
428
|
});
|
|
429
|
+
try {
|
|
430
|
+
await writeFrame({ type: "rpc", id, method, args });
|
|
431
|
+
} catch (error) {
|
|
432
|
+
pending.delete(id);
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
return response;
|
|
342
436
|
};
|
|
343
437
|
|
|
344
438
|
// Background loop: consume `rpc-result` frames and settle pending calls. It
|
|
@@ -348,8 +442,16 @@ async function runWithBindings(envelope: Envelope, reader: { next(): Promise<str
|
|
|
348
442
|
const line = await reader.next();
|
|
349
443
|
if (line === null) break;
|
|
350
444
|
if (line.length === 0) continue;
|
|
351
|
-
const json = line.startsWith(FRAME_MARKER)
|
|
352
|
-
|
|
445
|
+
const json = line.startsWith(FRAME_MARKER)
|
|
446
|
+
? line.slice(FRAME_MARKER.length)
|
|
447
|
+
: line;
|
|
448
|
+
let msg: {
|
|
449
|
+
type?: string;
|
|
450
|
+
id?: number;
|
|
451
|
+
ok?: boolean;
|
|
452
|
+
value?: unknown;
|
|
453
|
+
error?: { message?: string };
|
|
454
|
+
};
|
|
353
455
|
try {
|
|
354
456
|
msg = JSON.parse(json);
|
|
355
457
|
} catch {
|
|
@@ -386,9 +488,9 @@ async function runWithBindings(envelope: Envelope, reader: { next(): Promise<str
|
|
|
386
488
|
try {
|
|
387
489
|
const entry = await loadGuestEntry(envelope.source);
|
|
388
490
|
const output = await entry(envelope.input);
|
|
389
|
-
writeFrame({ type: "result", ok: true, output, logs });
|
|
491
|
+
await writeFrame({ type: "result", ok: true, output, logs });
|
|
390
492
|
} catch (err) {
|
|
391
|
-
writeFrame({
|
|
493
|
+
await writeFrame({
|
|
392
494
|
type: "result",
|
|
393
495
|
ok: false,
|
|
394
496
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -416,7 +518,11 @@ async function main() {
|
|
|
416
518
|
: firstLine;
|
|
417
519
|
envelope = JSON.parse(json);
|
|
418
520
|
} catch {
|
|
419
|
-
emitLegacy({
|
|
521
|
+
emitLegacy({
|
|
522
|
+
ok: false,
|
|
523
|
+
error: "failed to read/parse request envelope",
|
|
524
|
+
logs,
|
|
525
|
+
});
|
|
420
526
|
return;
|
|
421
527
|
}
|
|
422
528
|
|
|
@@ -435,7 +541,11 @@ async function main() {
|
|
|
435
541
|
const output = await entry(envelope.input);
|
|
436
542
|
emitLegacy({ ok: true, output, logs });
|
|
437
543
|
} catch (err) {
|
|
438
|
-
emitLegacy({
|
|
544
|
+
emitLegacy({
|
|
545
|
+
ok: false,
|
|
546
|
+
error: err instanceof Error ? err.message : String(err),
|
|
547
|
+
logs,
|
|
548
|
+
});
|
|
439
549
|
}
|
|
440
550
|
}
|
|
441
551
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const GUEST_BINDINGS_IMPORT =
|
|
2
|
+
'import { buildGuestBindings } from "./guest-bindings.ts";';
|
|
3
|
+
const HTTP_BODY_CAP_MARKER = "__QP_HTTP_FETCH_BODY_CAP_BYTES__";
|
|
4
|
+
|
|
5
|
+
/** Inline the trusted dependency so the guest entry needs no host path/socket. */
|
|
6
|
+
export function bundleGuestRuntimeSource(
|
|
7
|
+
guestEntrySource: string,
|
|
8
|
+
guestBindingsSource: string,
|
|
9
|
+
httpBodyCapBytes: number,
|
|
10
|
+
): string {
|
|
11
|
+
if (!guestEntrySource.includes(GUEST_BINDINGS_IMPORT)) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
"Guest entry does not contain the qualified bindings import.",
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
if (
|
|
17
|
+
!guestEntrySource.includes(HTTP_BODY_CAP_MARKER) ||
|
|
18
|
+
!Number.isSafeInteger(httpBodyCapBytes) ||
|
|
19
|
+
httpBodyCapBytes <= 0
|
|
20
|
+
) {
|
|
21
|
+
throw new Error("Guest entry does not contain a valid HTTP body cap.");
|
|
22
|
+
}
|
|
23
|
+
const inlinedBindings = guestBindingsSource.replace(/^export /gm, "");
|
|
24
|
+
return `${inlinedBindings}\n${guestEntrySource
|
|
25
|
+
.replace(GUEST_BINDINGS_IMPORT, "")
|
|
26
|
+
.replaceAll(HTTP_BODY_CAP_MARKER, String(httpBodyCapBytes))}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build a local module URL that works inside an isolated network namespace. */
|
|
30
|
+
export function guestRuntimeDataUrl(source: string): string {
|
|
31
|
+
const bytes = new TextEncoder().encode(source);
|
|
32
|
+
let binary = "";
|
|
33
|
+
const chunkSize = 0x8000;
|
|
34
|
+
for (let index = 0; index < bytes.length; index += chunkSize) {
|
|
35
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
|
|
36
|
+
}
|
|
37
|
+
return `data:application/typescript;base64,${btoa(binary)}`;
|
|
38
|
+
}
|
package/src/net-validation.ts
CHANGED
|
@@ -144,7 +144,11 @@ function isPrivateIpv6(raw: string): boolean {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
// fc00::/7 unique-local (fc.. and fd..).
|
|
147
|
-
if (
|
|
147
|
+
if (
|
|
148
|
+
/^f[cd][0-9a-f]{0,2}:/.test(ip) ||
|
|
149
|
+
ip.startsWith("fc") ||
|
|
150
|
+
ip.startsWith("fd")
|
|
151
|
+
) {
|
|
148
152
|
const first = ip.split(":")[0];
|
|
149
153
|
if (first.length > 0) {
|
|
150
154
|
const hi = Number.parseInt(first.padStart(4, "0").slice(0, 2), 16);
|
|
@@ -153,7 +157,12 @@ function isPrivateIpv6(raw: string): boolean {
|
|
|
153
157
|
}
|
|
154
158
|
|
|
155
159
|
// fe80::/10 link-local.
|
|
156
|
-
if (
|
|
160
|
+
if (
|
|
161
|
+
ip.startsWith("fe8") ||
|
|
162
|
+
ip.startsWith("fe9") ||
|
|
163
|
+
ip.startsWith("fea") ||
|
|
164
|
+
ip.startsWith("feb")
|
|
165
|
+
) {
|
|
157
166
|
return true;
|
|
158
167
|
}
|
|
159
168
|
|
|
@@ -182,13 +191,36 @@ export function classifyIpLiteral(ip: string): IpValidationResult {
|
|
|
182
191
|
/** Bound each DNS lookup so a slow/hanging resolver can't stall validation. */
|
|
183
192
|
const DNS_TIMEOUT_MS = 3_000;
|
|
184
193
|
|
|
185
|
-
function withDnsTimeout<T>(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
194
|
+
function withDnsTimeout<T>(
|
|
195
|
+
promise: Promise<T>,
|
|
196
|
+
timeoutMs = DNS_TIMEOUT_MS,
|
|
197
|
+
signal?: AbortSignal,
|
|
198
|
+
): Promise<T> {
|
|
199
|
+
return new Promise<T>((resolve, reject) => {
|
|
200
|
+
let settled = false;
|
|
201
|
+
const finish = (callback: () => void) => {
|
|
202
|
+
if (settled) return;
|
|
203
|
+
settled = true;
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
signal?.removeEventListener("abort", onAbort);
|
|
206
|
+
callback();
|
|
207
|
+
};
|
|
208
|
+
const onAbort = () =>
|
|
209
|
+
finish(() => reject(new Error("DNS lookup cancelled")));
|
|
210
|
+
const timer = setTimeout(
|
|
211
|
+
() =>
|
|
212
|
+
finish(() =>
|
|
213
|
+
reject(new Error(`DNS lookup timed out (${timeoutMs}ms)`)),
|
|
214
|
+
),
|
|
215
|
+
timeoutMs,
|
|
216
|
+
);
|
|
217
|
+
if (signal?.aborted) onAbort();
|
|
218
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
219
|
+
void promise.then(
|
|
220
|
+
(value) => finish(() => resolve(value)),
|
|
221
|
+
(error) => finish(() => reject(error)),
|
|
222
|
+
);
|
|
223
|
+
});
|
|
192
224
|
}
|
|
193
225
|
|
|
194
226
|
/** Lazily load `node:dns/promises` `resolve` (works on both Bun and Deno). */
|
|
@@ -208,7 +240,11 @@ async function defaultResolver(hostname: string): Promise<string[]> {
|
|
|
208
240
|
// If BOTH lookups failed (incl. timeout), surface an error so the caller
|
|
209
241
|
// fails CLOSED (rejects the host) rather than treating it as "no private
|
|
210
242
|
// addresses found" → allowed.
|
|
211
|
-
if (
|
|
243
|
+
if (
|
|
244
|
+
out.length === 0 &&
|
|
245
|
+
v4.status === "rejected" &&
|
|
246
|
+
v6.status === "rejected"
|
|
247
|
+
) {
|
|
212
248
|
throw new Error(
|
|
213
249
|
`DNS resolution failed (v4: ${reasonOf(v4)}, v6: ${reasonOf(v6)})`,
|
|
214
250
|
);
|
|
@@ -236,8 +272,15 @@ function reasonOf(settled: PromiseRejectedResult): string {
|
|
|
236
272
|
*/
|
|
237
273
|
export async function validateHostEgress(
|
|
238
274
|
entry: string,
|
|
239
|
-
opts: {
|
|
275
|
+
opts: {
|
|
276
|
+
resolve?: DnsResolver;
|
|
277
|
+
signal?: AbortSignal;
|
|
278
|
+
timeoutMs?: number;
|
|
279
|
+
} = {},
|
|
240
280
|
): Promise<IpValidationResult> {
|
|
281
|
+
if (opts.signal?.aborted) {
|
|
282
|
+
return { ok: false, reason: "DNS lookup cancelled" };
|
|
283
|
+
}
|
|
241
284
|
const { host } = parseHostEntry(entry);
|
|
242
285
|
const lower = host.toLowerCase();
|
|
243
286
|
|
|
@@ -245,7 +288,11 @@ export async function validateHostEgress(
|
|
|
245
288
|
return { ok: false, reason: "empty host" };
|
|
246
289
|
}
|
|
247
290
|
// Obvious loopback / mDNS names — never resolve these out.
|
|
248
|
-
if (
|
|
291
|
+
if (
|
|
292
|
+
lower === "localhost" ||
|
|
293
|
+
lower.endsWith(".localhost") ||
|
|
294
|
+
lower.endsWith(".local")
|
|
295
|
+
) {
|
|
249
296
|
return { ok: false, reason: `loopback/mDNS hostname not allowed: ${host}` };
|
|
250
297
|
}
|
|
251
298
|
|
|
@@ -260,7 +307,11 @@ export async function validateHostEgress(
|
|
|
260
307
|
const resolver = opts.resolve ?? defaultResolver;
|
|
261
308
|
let addrs: string[];
|
|
262
309
|
try {
|
|
263
|
-
addrs = await
|
|
310
|
+
addrs = await withDnsTimeout(
|
|
311
|
+
resolver(host),
|
|
312
|
+
opts.timeoutMs ?? DNS_TIMEOUT_MS,
|
|
313
|
+
opts.signal,
|
|
314
|
+
);
|
|
264
315
|
} catch (err) {
|
|
265
316
|
return {
|
|
266
317
|
ok: false,
|
|
@@ -268,7 +319,10 @@ export async function validateHostEgress(
|
|
|
268
319
|
};
|
|
269
320
|
}
|
|
270
321
|
if (addrs.length === 0) {
|
|
271
|
-
return {
|
|
322
|
+
return {
|
|
323
|
+
ok: false,
|
|
324
|
+
reason: `DNS resolution returned no addresses for ${host}`,
|
|
325
|
+
};
|
|
272
326
|
}
|
|
273
327
|
for (const addr of addrs) {
|
|
274
328
|
const c = classifyIpLiteral(addr);
|
|
@@ -288,11 +342,60 @@ export async function validateHostEgress(
|
|
|
288
342
|
*/
|
|
289
343
|
export async function validateEgressHosts(
|
|
290
344
|
hosts: readonly string[],
|
|
291
|
-
opts: {
|
|
345
|
+
opts: {
|
|
346
|
+
resolve?: DnsResolver;
|
|
347
|
+
signal?: AbortSignal;
|
|
348
|
+
timeoutMs?: number;
|
|
349
|
+
concurrency?: number;
|
|
350
|
+
} = {},
|
|
292
351
|
): Promise<IpValidationResult> {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
352
|
+
if (hosts.length === 0) return { ok: true };
|
|
353
|
+
const controller = new AbortController();
|
|
354
|
+
const onAbort = () => controller.abort();
|
|
355
|
+
if (opts.signal?.aborted) controller.abort();
|
|
356
|
+
else opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
357
|
+
const timeoutMs = Math.max(
|
|
358
|
+
1,
|
|
359
|
+
Math.min(DNS_TIMEOUT_MS, Math.floor(opts.timeoutMs ?? DNS_TIMEOUT_MS)),
|
|
360
|
+
);
|
|
361
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
362
|
+
const concurrency = Math.max(
|
|
363
|
+
1,
|
|
364
|
+
Math.min(8, Math.floor(opts.concurrency ?? 4)),
|
|
365
|
+
);
|
|
366
|
+
const results = Array.from<IpValidationResult | undefined>({
|
|
367
|
+
length: hosts.length,
|
|
368
|
+
});
|
|
369
|
+
let cursor = 0;
|
|
370
|
+
const worker = async () => {
|
|
371
|
+
while (cursor < hosts.length) {
|
|
372
|
+
const index = cursor;
|
|
373
|
+
cursor += 1;
|
|
374
|
+
results[index] = await validateHostEgress(hosts[index]!, {
|
|
375
|
+
...opts,
|
|
376
|
+
signal: controller.signal,
|
|
377
|
+
timeoutMs,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
try {
|
|
382
|
+
await Promise.all(
|
|
383
|
+
Array.from({ length: Math.min(concurrency, hosts.length) }, () =>
|
|
384
|
+
worker(),
|
|
385
|
+
),
|
|
386
|
+
);
|
|
387
|
+
} finally {
|
|
388
|
+
clearTimeout(timer);
|
|
389
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
390
|
+
}
|
|
391
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
392
|
+
const result = results[index]!;
|
|
393
|
+
if (!result.ok) {
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
reason: `host "${hosts[index]}": ${result.reason}`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
296
399
|
}
|
|
297
400
|
return { ok: true };
|
|
298
401
|
}
|
|
@@ -314,45 +417,95 @@ export interface ResolvedEndpoint {
|
|
|
314
417
|
* so a private result here is a rebind/inconsistency and must NOT become an
|
|
315
418
|
* `accept` rule). The drop rules cover the private space regardless.
|
|
316
419
|
*
|
|
317
|
-
* Best-effort
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* carried by the DROP rules + the default-deny policy, not by this function.
|
|
420
|
+
* Best-effort and fail-safe on resolution error: a host that fails to resolve
|
|
421
|
+
* simply yields no `accept` rule (the guest can't reach it). This never throws,
|
|
422
|
+
* and the security guarantee remains the DROP rules plus default-deny policy.
|
|
321
423
|
*/
|
|
322
424
|
export async function resolveAllowedEndpoints(
|
|
323
425
|
hosts: readonly string[],
|
|
324
|
-
opts: {
|
|
426
|
+
opts: {
|
|
427
|
+
resolve?: DnsResolver;
|
|
428
|
+
signal?: AbortSignal;
|
|
429
|
+
timeoutMs?: number;
|
|
430
|
+
concurrency?: number;
|
|
431
|
+
} = {},
|
|
325
432
|
): Promise<ResolvedEndpoint[]> {
|
|
326
|
-
const out: ResolvedEndpoint[] = [];
|
|
327
|
-
const seen = new Set<string>();
|
|
328
|
-
const push = (ip: string, port?: number) => {
|
|
329
|
-
const key = `${ip}|${port ?? ""}`;
|
|
330
|
-
if (seen.has(key)) return;
|
|
331
|
-
seen.add(key);
|
|
332
|
-
out.push(port === undefined ? { ip } : { ip, port });
|
|
333
|
-
};
|
|
334
433
|
const resolver = opts.resolve ?? defaultResolver;
|
|
434
|
+
const controller = new AbortController();
|
|
435
|
+
const onAbort = () => controller.abort();
|
|
436
|
+
if (opts.signal?.aborted) controller.abort();
|
|
437
|
+
else opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
438
|
+
const timeoutMs = Math.max(
|
|
439
|
+
1,
|
|
440
|
+
Math.min(DNS_TIMEOUT_MS, Math.floor(opts.timeoutMs ?? DNS_TIMEOUT_MS)),
|
|
441
|
+
);
|
|
442
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
443
|
+
const concurrency = Math.max(
|
|
444
|
+
1,
|
|
445
|
+
Math.min(8, Math.floor(opts.concurrency ?? 4)),
|
|
446
|
+
);
|
|
447
|
+
const resolved = Array.from<ResolvedEndpoint[] | undefined>({
|
|
448
|
+
length: hosts.length,
|
|
449
|
+
});
|
|
450
|
+
let cursor = 0;
|
|
335
451
|
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
452
|
+
try {
|
|
453
|
+
const worker = async () => {
|
|
454
|
+
while (cursor < hosts.length && !controller.signal.aborted) {
|
|
455
|
+
const index = cursor;
|
|
456
|
+
cursor += 1;
|
|
457
|
+
const { host, port } = parseHostEntry(hosts[index]!);
|
|
458
|
+
const lower = host.toLowerCase();
|
|
459
|
+
if (
|
|
460
|
+
lower.length === 0 ||
|
|
461
|
+
lower === "localhost" ||
|
|
462
|
+
lower.endsWith(".localhost") ||
|
|
463
|
+
lower.endsWith(".local")
|
|
464
|
+
) {
|
|
465
|
+
resolved[index] = [];
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
// IP literal → accept only if it classifies as PUBLIC.
|
|
469
|
+
if (isIpv4Literal(host) || isIpv6Literal(host)) {
|
|
470
|
+
resolved[index] = classifyIpLiteral(host).ok
|
|
471
|
+
? [port === undefined ? { ip: host } : { ip: host, port }]
|
|
472
|
+
: [];
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
// Hostname → resolve; pin each PUBLIC address.
|
|
476
|
+
try {
|
|
477
|
+
const addrs = await withDnsTimeout(
|
|
478
|
+
resolver(host),
|
|
479
|
+
timeoutMs,
|
|
480
|
+
controller.signal,
|
|
481
|
+
);
|
|
482
|
+
resolved[index] = addrs
|
|
483
|
+
.filter((addr) => classifyIpLiteral(addr).ok)
|
|
484
|
+
.map((ip) => (port === undefined ? { ip } : { ip, port }));
|
|
485
|
+
} catch {
|
|
486
|
+
// Unresolvable or cancelled → no accept rule.
|
|
487
|
+
resolved[index] = [];
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
await Promise.all(
|
|
492
|
+
Array.from({ length: Math.min(concurrency, hosts.length) }, () =>
|
|
493
|
+
worker(),
|
|
494
|
+
),
|
|
495
|
+
);
|
|
496
|
+
} finally {
|
|
497
|
+
clearTimeout(timer);
|
|
498
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const out: ResolvedEndpoint[] = [];
|
|
502
|
+
const seen = new Set<string>();
|
|
503
|
+
for (const endpoints of resolved) {
|
|
504
|
+
for (const endpoint of endpoints ?? []) {
|
|
505
|
+
const key = `${endpoint.ip}|${endpoint.port ?? ""}`;
|
|
506
|
+
if (seen.has(key)) continue;
|
|
507
|
+
seen.add(key);
|
|
508
|
+
out.push(endpoint);
|
|
356
509
|
}
|
|
357
510
|
}
|
|
358
511
|
return out;
|