@questpie/sandbox 3.1.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.
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Network egress validation — SSRF / DNS-rebind defense for sandboxed guests.
3
+ *
4
+ * The sandbox grants each guest a per-request `net`/`import` allowlist of
5
+ * `host[:port]` entries (driving Deno's `--allow-net` / `--allow-import`).
6
+ * Deno's allowlist is a hostname-STRING match with NO IP validation, so an
7
+ * allowlisted domain that resolves to a private/link-local/loopback/metadata
8
+ * address (e.g. `169.254.169.254`, `127.0.0.1`, RFC-1918) would still connect.
9
+ * This module rejects those at MANIFEST-VALIDATION time.
10
+ *
11
+ * Runtime-agnostic: the DNS resolver is injectable so this same code runs under
12
+ * Bun/Node (manifest validation in the adapter) and Deno (the sandbox server).
13
+ * It defaults to `node:dns/promises`, which both Bun and Deno implement.
14
+ *
15
+ * SCOPE (M2): manifest-time rejection of private-IP hosts is implemented and
16
+ * enforced here. Full connect-time DNS-rebind PINNING (re-resolving and
17
+ * re-checking the exact IP the socket connects to, including across redirects)
18
+ * is NOT yet implemented — see `TODO(security)` in `sandbox-server.ts`.
19
+ */
20
+
21
+ /** A single parsed `host[:port]` allowlist entry. */
22
+ export interface ParsedHost {
23
+ /** Bare hostname or IP literal (IPv6 without brackets). */
24
+ host: string;
25
+ /** Port if specified, else undefined. */
26
+ port?: number;
27
+ }
28
+
29
+ export interface IpValidationResult {
30
+ ok: boolean;
31
+ /** Reason the host was rejected (only set when `ok` is false). */
32
+ reason?: string;
33
+ }
34
+
35
+ /** Async DNS resolver shape (subset of `node:dns/promises`). */
36
+ export type DnsResolver = (hostname: string) => Promise<string[]>;
37
+
38
+ /**
39
+ * Parse a `host[:port]` allowlist entry (Deno `--allow-net` syntax).
40
+ * Bracketed IPv6 (`[::1]:443`) and bare IPv6 (`::1`) are both handled.
41
+ */
42
+ export function parseHostEntry(entry: string): ParsedHost {
43
+ const trimmed = entry.trim();
44
+
45
+ // Bracketed IPv6 form: [::1] or [::1]:443
46
+ if (trimmed.startsWith("[")) {
47
+ const close = trimmed.indexOf("]");
48
+ if (close === -1) return { host: trimmed };
49
+ const host = trimmed.slice(1, close);
50
+ const rest = trimmed.slice(close + 1);
51
+ if (rest.startsWith(":")) {
52
+ const port = Number(rest.slice(1));
53
+ return Number.isInteger(port) ? { host, port } : { host };
54
+ }
55
+ return { host };
56
+ }
57
+
58
+ // Bare IPv6 (contains multiple colons, no brackets) → no port parsing.
59
+ if ((trimmed.match(/:/g)?.length ?? 0) > 1) {
60
+ return { host: trimmed };
61
+ }
62
+
63
+ // IPv4 or hostname, optionally host:port.
64
+ const lastColon = trimmed.lastIndexOf(":");
65
+ if (lastColon !== -1) {
66
+ const maybePort = Number(trimmed.slice(lastColon + 1));
67
+ if (Number.isInteger(maybePort)) {
68
+ return { host: trimmed.slice(0, lastColon), port: maybePort };
69
+ }
70
+ }
71
+ return { host: trimmed };
72
+ }
73
+
74
+ /** True if `s` is a syntactically-valid dotted-quad IPv4 literal. */
75
+ function isIpv4Literal(s: string): boolean {
76
+ const parts = s.split(".");
77
+ if (parts.length !== 4) return false;
78
+ return parts.every((p) => {
79
+ if (!/^\d{1,3}$/.test(p)) return false;
80
+ const n = Number(p);
81
+ return n >= 0 && n <= 255;
82
+ });
83
+ }
84
+
85
+ /** True if `s` looks like an IPv6 literal (contains a colon, hex/`:`/`.` only). */
86
+ function isIpv6Literal(s: string): boolean {
87
+ if (!s.includes(":")) return false;
88
+ // Allow hex digits, colons, and an embedded IPv4 tail (e.g. ::ffff:127.0.0.1).
89
+ return /^[0-9a-fA-F:.]+$/.test(s);
90
+ }
91
+
92
+ /**
93
+ * Classify an IPv4 literal as private/link-local/loopback/metadata/CGNAT.
94
+ * Ranges (all per the task + cloud-metadata convention):
95
+ * 0.0.0.0/8 (this-host), 127/8 (loopback), 10/8, 172.16/12, 192.168/16 (RFC-1918),
96
+ * 169.254/16 (link-local, incl. 169.254.169.254 metadata), 100.64/10 (CGNAT/RFC-6598).
97
+ */
98
+ function isPrivateIpv4(ip: string): boolean {
99
+ const [a, b] = ip.split(".").map(Number);
100
+ if (a === 0) return true; // 0.0.0.0/8 "this host"
101
+ if (a === 127) return true; // loopback
102
+ if (a === 10) return true; // RFC-1918
103
+ if (a === 172 && b >= 16 && b <= 31) return true; // RFC-1918
104
+ if (a === 192 && b === 168) return true; // RFC-1918
105
+ if (a === 169 && b === 254) return true; // link-local + AWS/GCP/Azure metadata
106
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT (RFC-6598)
107
+ return false;
108
+ }
109
+
110
+ /** Normalize an IPv6 literal to lowercase, stripping a `%zone` suffix. */
111
+ function normalizeIpv6(ip: string): string {
112
+ const pct = ip.indexOf("%");
113
+ return (pct === -1 ? ip : ip.slice(0, pct)).toLowerCase();
114
+ }
115
+
116
+ /**
117
+ * Classify an IPv6 literal as private/link-local/loopback/metadata.
118
+ * Covers: ::1 (loopback), :: (unspecified), fc00::/7 (unique-local),
119
+ * fe80::/10 (link-local), and IPv4-mapped/translated forms (::ffff:a.b.c.d,
120
+ * ::a.b.c.d) by delegating their embedded IPv4 to `isPrivateIpv4`.
121
+ */
122
+ function isPrivateIpv6(raw: string): boolean {
123
+ const ip = normalizeIpv6(raw);
124
+ if (ip === "::1") return true; // loopback
125
+ if (ip === "::" || ip === "") return true; // unspecified
126
+
127
+ // IPv4-mapped / -compatible: pull out the trailing dotted-quad and reuse v4 logic.
128
+ const v4Tail = ip.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
129
+ if (v4Tail && isIpv4Literal(v4Tail[1])) {
130
+ // Bare "::a.b.c.d" / "::ffff:a.b.c.d" wrap a v4 address — classify by it.
131
+ if (ip.startsWith("::")) return isPrivateIpv4(v4Tail[1]);
132
+ }
133
+
134
+ // IPv4-mapped with a HEX-COMPRESSED tail. Bun's `URL.hostname` normalizes
135
+ // `::ffff:127.0.0.1` → `::ffff:7f00:1` (and `::ffff:169.254.169.254` →
136
+ // `::ffff:a9fe:a9fe`), so the dotted match above misses loopback/metadata
137
+ // smuggled as a mapped literal. Rebuild the 32-bit v4 from the two embedded
138
+ // hextets and classify it (each derived octet is already in 0-255).
139
+ const mapped = ip.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
140
+ if (mapped) {
141
+ const hi = Number.parseInt(mapped[1], 16) & 0xffff;
142
+ const lo = Number.parseInt(mapped[2], 16) & 0xffff;
143
+ return isPrivateIpv4(`${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`);
144
+ }
145
+
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")) {
148
+ const first = ip.split(":")[0];
149
+ if (first.length > 0) {
150
+ const hi = Number.parseInt(first.padStart(4, "0").slice(0, 2), 16);
151
+ if ((hi & 0xfe) === 0xfc) return true; // 1111 110x
152
+ }
153
+ }
154
+
155
+ // fe80::/10 link-local.
156
+ if (ip.startsWith("fe8") || ip.startsWith("fe9") || ip.startsWith("fea") || ip.startsWith("feb")) {
157
+ return true;
158
+ }
159
+
160
+ return false;
161
+ }
162
+
163
+ /**
164
+ * Classify a bare IP literal (v4 or v6) as a blocked egress target.
165
+ * Returns `{ ok:false, reason }` when the IP is private/link-local/loopback/metadata.
166
+ */
167
+ export function classifyIpLiteral(ip: string): IpValidationResult {
168
+ if (isIpv4Literal(ip)) {
169
+ return isPrivateIpv4(ip)
170
+ ? { ok: false, reason: `private/link-local/loopback IPv4: ${ip}` }
171
+ : { ok: true };
172
+ }
173
+ if (isIpv6Literal(ip)) {
174
+ return isPrivateIpv6(ip)
175
+ ? { ok: false, reason: `private/link-local/loopback IPv6: ${ip}` }
176
+ : { ok: true };
177
+ }
178
+ // Not an IP literal — caller must DNS-resolve and re-check.
179
+ return { ok: true };
180
+ }
181
+
182
+ /** Bound each DNS lookup so a slow/hanging resolver can't stall validation. */
183
+ const DNS_TIMEOUT_MS = 3_000;
184
+
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
+ ]);
192
+ }
193
+
194
+ /** Lazily load `node:dns/promises` `resolve` (works on both Bun and Deno). */
195
+ let cachedResolver: DnsResolver | undefined;
196
+ async function defaultResolver(hostname: string): Promise<string[]> {
197
+ if (!cachedResolver) {
198
+ // Dynamic import so the module loads in environments without node:dns at parse time.
199
+ const dns = await import("node:dns/promises");
200
+ cachedResolver = async (h: string) => {
201
+ const [v4, v6] = await Promise.allSettled([
202
+ withDnsTimeout(dns.resolve4(h)),
203
+ withDnsTimeout(dns.resolve6(h)),
204
+ ]);
205
+ const out: string[] = [];
206
+ if (v4.status === "fulfilled") out.push(...v4.value);
207
+ if (v6.status === "fulfilled") out.push(...v6.value);
208
+ // If BOTH lookups failed (incl. timeout), surface an error so the caller
209
+ // fails CLOSED (rejects the host) rather than treating it as "no private
210
+ // addresses found" → allowed.
211
+ if (out.length === 0 && v4.status === "rejected" && v6.status === "rejected") {
212
+ throw new Error(
213
+ `DNS resolution failed (v4: ${reasonOf(v4)}, v6: ${reasonOf(v6)})`,
214
+ );
215
+ }
216
+ return out;
217
+ };
218
+ }
219
+ return cachedResolver(hostname);
220
+ }
221
+
222
+ function reasonOf(settled: PromiseRejectedResult): string {
223
+ const r = settled.reason;
224
+ return r instanceof Error ? r.message : String(r);
225
+ }
226
+
227
+ /**
228
+ * Validate a single `host[:port]` allowlist entry against the private-IP policy.
229
+ *
230
+ * - IP literals are classified directly (no DNS).
231
+ * - Hostnames are DNS-resolved; if ANY resolved address is private/link-local/
232
+ * loopback/metadata, the host is REJECTED (DNS-rebind defense at manifest time).
233
+ * - `localhost` and the `.local` mDNS suffix are rejected up-front.
234
+ *
235
+ * `resolve` is injectable for tests / Deno; defaults to `node:dns/promises`.
236
+ */
237
+ export async function validateHostEgress(
238
+ entry: string,
239
+ opts: { resolve?: DnsResolver } = {},
240
+ ): Promise<IpValidationResult> {
241
+ const { host } = parseHostEntry(entry);
242
+ const lower = host.toLowerCase();
243
+
244
+ if (lower.length === 0) {
245
+ return { ok: false, reason: "empty host" };
246
+ }
247
+ // Obvious loopback / mDNS names — never resolve these out.
248
+ if (lower === "localhost" || lower.endsWith(".localhost") || lower.endsWith(".local")) {
249
+ return { ok: false, reason: `loopback/mDNS hostname not allowed: ${host}` };
250
+ }
251
+
252
+ // Direct IP literal → classify without DNS.
253
+ const literal = classifyIpLiteral(host);
254
+ if (!literal.ok) return literal;
255
+ if (isIpv4Literal(host) || isIpv6Literal(host)) {
256
+ return { ok: true }; // a public IP literal is fine
257
+ }
258
+
259
+ // Hostname → resolve and reject if any address is private.
260
+ const resolver = opts.resolve ?? defaultResolver;
261
+ let addrs: string[];
262
+ try {
263
+ addrs = await resolver(host);
264
+ } catch (err) {
265
+ return {
266
+ ok: false,
267
+ reason: `DNS resolution failed for ${host}: ${err instanceof Error ? err.message : String(err)}`,
268
+ };
269
+ }
270
+ if (addrs.length === 0) {
271
+ return { ok: false, reason: `DNS resolution returned no addresses for ${host}` };
272
+ }
273
+ for (const addr of addrs) {
274
+ const c = classifyIpLiteral(addr);
275
+ if (!c.ok) {
276
+ return {
277
+ ok: false,
278
+ reason: `${host} resolves to a blocked address (${c.reason})`,
279
+ };
280
+ }
281
+ }
282
+ return { ok: true };
283
+ }
284
+
285
+ /**
286
+ * Validate ALL `net` + `import` hosts in a capability set.
287
+ * Returns the first rejection found, or `{ ok:true }` if every host passes.
288
+ */
289
+ export async function validateEgressHosts(
290
+ hosts: readonly string[],
291
+ opts: { resolve?: DnsResolver } = {},
292
+ ): 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}` };
296
+ }
297
+ return { ok: true };
298
+ }
299
+
300
+ /** A resolved, validated PUBLIC egress endpoint (IP + optional port). */
301
+ export interface ResolvedEndpoint {
302
+ /** Public IP literal (v4 or v6) the host resolved to / is. */
303
+ ip: string;
304
+ /** Port from the `host[:port]` entry, if any. */
305
+ port?: number;
306
+ }
307
+
308
+ /**
309
+ * Resolve a `host[:port]` allowlist to the concrete PUBLIC IPs it points at, for
310
+ * the defense-in-depth kernel firewall (`egress-firewall.ts`) which can only
311
+ * match IPs — never hostnames. This is the resolution half of `validateHostEgress`:
312
+ * IP literals pass through; hostnames are DNS-resolved; any PRIVATE/metadata
313
+ * address is SKIPPED (the request-time validator has already rejected such hosts,
314
+ * so a private result here is a rebind/inconsistency and must NOT become an
315
+ * `accept` rule). The drop rules cover the private space regardless.
316
+ *
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.
321
+ */
322
+ export async function resolveAllowedEndpoints(
323
+ hosts: readonly string[],
324
+ opts: { resolve?: DnsResolver } = {},
325
+ ): 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
+ const resolver = opts.resolve ?? defaultResolver;
335
+
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);
356
+ }
357
+ }
358
+ return out;
359
+ }