@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.
@@ -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(line.length > MAX_LOG_LEN ? line.slice(0, MAX_LOG_LEN) + "…" : line);
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({ type: "result", ok: false, error: "frame not serializable", logs });
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.writeSync(encoder.encode(FRAME_MARKER + payload + "\n"));
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: { ok: boolean; output?: unknown; logs: string[]; error?: string }) {
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(source: string): Promise<(input: unknown) => unknown> {
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(hostCall: (method: string, args: unknown) => Promise<unknown>) {
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 > 0) bodyBase64 = bytesToBase64(new Uint8Array(buf));
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 ? base64ToBytes(value.bodyBase64) : undefined;
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 = status === 204 || status === 205 || status === 304 || status < 200;
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(envelope: Envelope, reader: { next(): Promise<string | null> }) {
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<number, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>();
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
- return new Promise<unknown>((resolve, reject) => {
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) ? line.slice(FRAME_MARKER.length) : line;
352
- let msg: { type?: string; id?: number; ok?: boolean; value?: unknown; error?: { message?: string } };
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({ ok: false, error: "failed to read/parse request envelope", logs });
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({ ok: false, error: err instanceof Error ? err.message : String(err), logs });
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
+ }
@@ -144,7 +144,11 @@ function isPrivateIpv6(raw: string): boolean {
144
144
  }
145
145
 
146
146
  // fc00::/7 unique-local (fc.. and fd..).
147
- if (/^f[cd][0-9a-f]{0,2}:/.test(ip) || ip.startsWith("fc") || ip.startsWith("fd")) {
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 (ip.startsWith("fe8") || ip.startsWith("fe9") || ip.startsWith("fea") || ip.startsWith("feb")) {
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>(p: Promise<T>): Promise<T> {
186
- return Promise.race([
187
- p,
188
- new Promise<T>((_, reject) =>
189
- setTimeout(() => reject(new Error(`DNS lookup timed out (${DNS_TIMEOUT_MS}ms)`)), DNS_TIMEOUT_MS),
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 (out.length === 0 && v4.status === "rejected" && v6.status === "rejected") {
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: { resolve?: DnsResolver } = {},
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 (lower === "localhost" || lower.endsWith(".localhost") || lower.endsWith(".local")) {
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 resolver(host);
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 { ok: false, reason: `DNS resolution returned no addresses for ${host}` };
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: { resolve?: DnsResolver } = {},
345
+ opts: {
346
+ resolve?: DnsResolver;
347
+ signal?: AbortSignal;
348
+ timeoutMs?: number;
349
+ concurrency?: number;
350
+ } = {},
292
351
  ): Promise<IpValidationResult> {
293
- for (const entry of hosts) {
294
- const r = await validateHostEgress(entry, opts);
295
- if (!r.ok) return { ok: false, reason: `host "${entry}": ${r.reason}` };
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 + fail-OPEN-on-resolution-error by design: a host that fails to
318
- * resolve simply yields no `accept` rule (the guest can't reach it → still safe);
319
- * this never throws so it cannot break the spawn path. The SECURITY guarantee is
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: { resolve?: DnsResolver } = {},
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
- for (const entry of hosts) {
337
- const { host, port } = parseHostEntry(entry);
338
- const lower = host.toLowerCase();
339
- if (lower.length === 0 || lower === "localhost" || lower.endsWith(".localhost") || lower.endsWith(".local")) {
340
- continue;
341
- }
342
- // IP literal → accept only if it classifies as PUBLIC.
343
- if (isIpv4Literal(host) || isIpv6Literal(host)) {
344
- if (classifyIpLiteral(host).ok) push(host, port);
345
- continue;
346
- }
347
- // Hostname → resolve; pin each PUBLIC address.
348
- let addrs: string[];
349
- try {
350
- addrs = await resolver(host);
351
- } catch {
352
- continue; // unresolvable → no accept rule (guest can't reach it anyway)
353
- }
354
- for (const addr of addrs) {
355
- if (classifyIpLiteral(addr).ok) push(addr, port);
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;