@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
|
@@ -1,298 +0,0 @@
|
|
|
1
|
-
//#region src/net-validation.ts
|
|
2
|
-
/**
|
|
3
|
-
* Parse a `host[:port]` allowlist entry (Deno `--allow-net` syntax).
|
|
4
|
-
* Bracketed IPv6 (`[::1]:443`) and bare IPv6 (`::1`) are both handled.
|
|
5
|
-
*/
|
|
6
|
-
function parseHostEntry(entry) {
|
|
7
|
-
const trimmed = entry.trim();
|
|
8
|
-
if (trimmed.startsWith("[")) {
|
|
9
|
-
const close = trimmed.indexOf("]");
|
|
10
|
-
if (close === -1) return { host: trimmed };
|
|
11
|
-
const host = trimmed.slice(1, close);
|
|
12
|
-
const rest = trimmed.slice(close + 1);
|
|
13
|
-
if (rest.startsWith(":")) {
|
|
14
|
-
const port = Number(rest.slice(1));
|
|
15
|
-
return Number.isInteger(port) ? {
|
|
16
|
-
host,
|
|
17
|
-
port
|
|
18
|
-
} : { host };
|
|
19
|
-
}
|
|
20
|
-
return { host };
|
|
21
|
-
}
|
|
22
|
-
if ((trimmed.match(/:/g)?.length ?? 0) > 1) return { host: trimmed };
|
|
23
|
-
const lastColon = trimmed.lastIndexOf(":");
|
|
24
|
-
if (lastColon !== -1) {
|
|
25
|
-
const maybePort = Number(trimmed.slice(lastColon + 1));
|
|
26
|
-
if (Number.isInteger(maybePort)) return {
|
|
27
|
-
host: trimmed.slice(0, lastColon),
|
|
28
|
-
port: maybePort
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
return { host: trimmed };
|
|
32
|
-
}
|
|
33
|
-
/** True if `s` is a syntactically-valid dotted-quad IPv4 literal. */
|
|
34
|
-
function isIpv4Literal(s) {
|
|
35
|
-
const parts = s.split(".");
|
|
36
|
-
if (parts.length !== 4) return false;
|
|
37
|
-
return parts.every((p) => {
|
|
38
|
-
if (!/^\d{1,3}$/.test(p)) return false;
|
|
39
|
-
const n = Number(p);
|
|
40
|
-
return n >= 0 && n <= 255;
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
/** True if `s` looks like an IPv6 literal (contains a colon, hex/`:`/`.` only). */
|
|
44
|
-
function isIpv6Literal(s) {
|
|
45
|
-
if (!s.includes(":")) return false;
|
|
46
|
-
return /^[0-9a-fA-F:.]+$/.test(s);
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Classify an IPv4 literal as private/link-local/loopback/metadata/CGNAT.
|
|
50
|
-
* Ranges (all per the task + cloud-metadata convention):
|
|
51
|
-
* 0.0.0.0/8 (this-host), 127/8 (loopback), 10/8, 172.16/12, 192.168/16 (RFC-1918),
|
|
52
|
-
* 169.254/16 (link-local, incl. 169.254.169.254 metadata), 100.64/10 (CGNAT/RFC-6598).
|
|
53
|
-
*/
|
|
54
|
-
function isPrivateIpv4(ip) {
|
|
55
|
-
const [a, b] = ip.split(".").map(Number);
|
|
56
|
-
if (a === 0) return true;
|
|
57
|
-
if (a === 127) return true;
|
|
58
|
-
if (a === 10) return true;
|
|
59
|
-
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
60
|
-
if (a === 192 && b === 168) return true;
|
|
61
|
-
if (a === 169 && b === 254) return true;
|
|
62
|
-
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
63
|
-
return false;
|
|
64
|
-
}
|
|
65
|
-
/** Normalize an IPv6 literal to lowercase, stripping a `%zone` suffix. */
|
|
66
|
-
function normalizeIpv6(ip) {
|
|
67
|
-
const pct = ip.indexOf("%");
|
|
68
|
-
return (pct === -1 ? ip : ip.slice(0, pct)).toLowerCase();
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* Classify an IPv6 literal as private/link-local/loopback/metadata.
|
|
72
|
-
* Covers: ::1 (loopback), :: (unspecified), fc00::/7 (unique-local),
|
|
73
|
-
* fe80::/10 (link-local), and IPv4-mapped/translated forms (::ffff:a.b.c.d,
|
|
74
|
-
* ::a.b.c.d) by delegating their embedded IPv4 to `isPrivateIpv4`.
|
|
75
|
-
*/
|
|
76
|
-
function isPrivateIpv6(raw) {
|
|
77
|
-
const ip = normalizeIpv6(raw);
|
|
78
|
-
if (ip === "::1") return true;
|
|
79
|
-
if (ip === "::" || ip === "") return true;
|
|
80
|
-
const v4Tail = ip.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
81
|
-
if (v4Tail && isIpv4Literal(v4Tail[1])) {
|
|
82
|
-
if (ip.startsWith("::")) return isPrivateIpv4(v4Tail[1]);
|
|
83
|
-
}
|
|
84
|
-
const mapped = ip.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
85
|
-
if (mapped) {
|
|
86
|
-
const hi = Number.parseInt(mapped[1], 16) & 65535;
|
|
87
|
-
const lo = Number.parseInt(mapped[2], 16) & 65535;
|
|
88
|
-
return isPrivateIpv4(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
|
|
89
|
-
}
|
|
90
|
-
if (/^f[cd][0-9a-f]{0,2}:/.test(ip) || ip.startsWith("fc") || ip.startsWith("fd")) {
|
|
91
|
-
const first = ip.split(":")[0];
|
|
92
|
-
if (first.length > 0) {
|
|
93
|
-
if ((Number.parseInt(first.padStart(4, "0").slice(0, 2), 16) & 254) === 252) return true;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (ip.startsWith("fe8") || ip.startsWith("fe9") || ip.startsWith("fea") || ip.startsWith("feb")) return true;
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* Classify a bare IP literal (v4 or v6) as a blocked egress target.
|
|
101
|
-
* Returns `{ ok:false, reason }` when the IP is private/link-local/loopback/metadata.
|
|
102
|
-
*/
|
|
103
|
-
function classifyIpLiteral(ip) {
|
|
104
|
-
if (isIpv4Literal(ip)) return isPrivateIpv4(ip) ? {
|
|
105
|
-
ok: false,
|
|
106
|
-
reason: `private/link-local/loopback IPv4: ${ip}`
|
|
107
|
-
} : { ok: true };
|
|
108
|
-
if (isIpv6Literal(ip)) return isPrivateIpv6(ip) ? {
|
|
109
|
-
ok: false,
|
|
110
|
-
reason: `private/link-local/loopback IPv6: ${ip}`
|
|
111
|
-
} : { ok: true };
|
|
112
|
-
return { ok: true };
|
|
113
|
-
}
|
|
114
|
-
/** Bound each DNS lookup so a slow/hanging resolver can't stall validation. */
|
|
115
|
-
const DNS_TIMEOUT_MS = 3e3;
|
|
116
|
-
function withDnsTimeout(p) {
|
|
117
|
-
return Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`DNS lookup timed out (${DNS_TIMEOUT_MS}ms)`)), DNS_TIMEOUT_MS))]);
|
|
118
|
-
}
|
|
119
|
-
/** Lazily load `node:dns/promises` `resolve` (works on both Bun and Deno). */
|
|
120
|
-
let cachedResolver;
|
|
121
|
-
async function defaultResolver(hostname) {
|
|
122
|
-
if (!cachedResolver) {
|
|
123
|
-
const dns = await import("node:dns/promises");
|
|
124
|
-
cachedResolver = async (h) => {
|
|
125
|
-
const [v4, v6] = await Promise.allSettled([withDnsTimeout(dns.resolve4(h)), withDnsTimeout(dns.resolve6(h))]);
|
|
126
|
-
const out = [];
|
|
127
|
-
if (v4.status === "fulfilled") out.push(...v4.value);
|
|
128
|
-
if (v6.status === "fulfilled") out.push(...v6.value);
|
|
129
|
-
if (out.length === 0 && v4.status === "rejected" && v6.status === "rejected") throw new Error(`DNS resolution failed (v4: ${reasonOf(v4)}, v6: ${reasonOf(v6)})`);
|
|
130
|
-
return out;
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
return cachedResolver(hostname);
|
|
134
|
-
}
|
|
135
|
-
function reasonOf(settled) {
|
|
136
|
-
const r = settled.reason;
|
|
137
|
-
return r instanceof Error ? r.message : String(r);
|
|
138
|
-
}
|
|
139
|
-
/**
|
|
140
|
-
* Validate a single `host[:port]` allowlist entry against the private-IP policy.
|
|
141
|
-
*
|
|
142
|
-
* - IP literals are classified directly (no DNS).
|
|
143
|
-
* - Hostnames are DNS-resolved; if ANY resolved address is private/link-local/
|
|
144
|
-
* loopback/metadata, the host is REJECTED (DNS-rebind defense at manifest time).
|
|
145
|
-
* - `localhost` and the `.local` mDNS suffix are rejected up-front.
|
|
146
|
-
*
|
|
147
|
-
* `resolve` is injectable for tests / Deno; defaults to `node:dns/promises`.
|
|
148
|
-
*/
|
|
149
|
-
async function validateHostEgress(entry, opts = {}) {
|
|
150
|
-
const { host } = parseHostEntry(entry);
|
|
151
|
-
const lower = host.toLowerCase();
|
|
152
|
-
if (lower.length === 0) return {
|
|
153
|
-
ok: false,
|
|
154
|
-
reason: "empty host"
|
|
155
|
-
};
|
|
156
|
-
if (lower === "localhost" || lower.endsWith(".localhost") || lower.endsWith(".local")) return {
|
|
157
|
-
ok: false,
|
|
158
|
-
reason: `loopback/mDNS hostname not allowed: ${host}`
|
|
159
|
-
};
|
|
160
|
-
const literal = classifyIpLiteral(host);
|
|
161
|
-
if (!literal.ok) return literal;
|
|
162
|
-
if (isIpv4Literal(host) || isIpv6Literal(host)) return { ok: true };
|
|
163
|
-
const resolver = opts.resolve ?? defaultResolver;
|
|
164
|
-
let addrs;
|
|
165
|
-
try {
|
|
166
|
-
addrs = await resolver(host);
|
|
167
|
-
} catch (err) {
|
|
168
|
-
return {
|
|
169
|
-
ok: false,
|
|
170
|
-
reason: `DNS resolution failed for ${host}: ${err instanceof Error ? err.message : String(err)}`
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
if (addrs.length === 0) return {
|
|
174
|
-
ok: false,
|
|
175
|
-
reason: `DNS resolution returned no addresses for ${host}`
|
|
176
|
-
};
|
|
177
|
-
for (const addr of addrs) {
|
|
178
|
-
const c = classifyIpLiteral(addr);
|
|
179
|
-
if (!c.ok) return {
|
|
180
|
-
ok: false,
|
|
181
|
-
reason: `${host} resolves to a blocked address (${c.reason})`
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
return { ok: true };
|
|
185
|
-
}
|
|
186
|
-
/**
|
|
187
|
-
* Validate ALL `net` + `import` hosts in a capability set.
|
|
188
|
-
* Returns the first rejection found, or `{ ok:true }` if every host passes.
|
|
189
|
-
*/
|
|
190
|
-
async function validateEgressHosts(hosts, opts = {}) {
|
|
191
|
-
for (const entry of hosts) {
|
|
192
|
-
const r = await validateHostEgress(entry, opts);
|
|
193
|
-
if (!r.ok) return {
|
|
194
|
-
ok: false,
|
|
195
|
-
reason: `host "${entry}": ${r.reason}`
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
return { ok: true };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
//#endregion
|
|
202
|
-
//#region src/adapter-http.ts
|
|
203
|
-
const DEFAULT_GUEST_TIMEOUT_MS = 5e3;
|
|
204
|
-
/**
|
|
205
|
-
* Production sandboxed `ExecutorAdapter` — POSTs the run to the standalone Deno
|
|
206
|
-
* sandbox service over HTTP. The main app (Bun/Node) holds NO Deno; it only
|
|
207
|
-
* speaks this JSON contract.
|
|
208
|
-
*
|
|
209
|
-
* Use as the `sandboxed` adapter in the executor config:
|
|
210
|
-
* executor: { sandboxed: httpSandboxAdapter({ url: process.env.SANDBOX_URL }) }
|
|
211
|
-
*/
|
|
212
|
-
var HttpSandboxAdapter = class {
|
|
213
|
-
fetchImpl;
|
|
214
|
-
validateEgress;
|
|
215
|
-
constructor(options = {}) {
|
|
216
|
-
this.options = options;
|
|
217
|
-
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
218
|
-
this.validateEgress = options.validateEgress ?? true;
|
|
219
|
-
}
|
|
220
|
-
resolveUrl() {
|
|
221
|
-
const url = this.options.url ?? (typeof process !== "undefined" ? process.env?.SANDBOX_URL : void 0);
|
|
222
|
-
if (!url) throw new Error("HttpSandboxAdapter: no sandbox URL configured. Pass `url` or set SANDBOX_URL.");
|
|
223
|
-
return url.replace(/\/$/, "");
|
|
224
|
-
}
|
|
225
|
-
async run(options) {
|
|
226
|
-
const base = this.resolveUrl();
|
|
227
|
-
const caps = options.capabilities ?? {};
|
|
228
|
-
const net = caps.net ?? [];
|
|
229
|
-
const importHosts = caps.import ?? [];
|
|
230
|
-
const timeoutMs = caps.timeoutMs ?? DEFAULT_GUEST_TIMEOUT_MS;
|
|
231
|
-
if (this.validateEgress) {
|
|
232
|
-
const egress = await validateEgressHosts([...net, ...importHosts]);
|
|
233
|
-
if (!egress.ok) return {
|
|
234
|
-
ok: false,
|
|
235
|
-
error: `egress blocked: ${egress.reason}`,
|
|
236
|
-
logs: []
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
const sandboxCaps = {
|
|
240
|
-
net,
|
|
241
|
-
import: importHosts,
|
|
242
|
-
timeoutMs,
|
|
243
|
-
memoryMb: caps.memoryMb ?? 128
|
|
244
|
-
};
|
|
245
|
-
const fetchTimeoutMs = this.options.fetchTimeoutMs ?? timeoutMs + 1e4;
|
|
246
|
-
const controller = new AbortController();
|
|
247
|
-
const abortTimer = setTimeout(() => controller.abort(), fetchTimeoutMs);
|
|
248
|
-
try {
|
|
249
|
-
const bindings = options.sandboxBindings;
|
|
250
|
-
const res = await this.fetchImpl(`${base}/run`, {
|
|
251
|
-
method: "POST",
|
|
252
|
-
headers: { "content-type": "application/json" },
|
|
253
|
-
body: JSON.stringify({
|
|
254
|
-
source: options.source,
|
|
255
|
-
input: options.input ?? null,
|
|
256
|
-
capabilities: sandboxCaps,
|
|
257
|
-
secrets: options.secrets ?? {},
|
|
258
|
-
...bindings ? { bindings } : {}
|
|
259
|
-
}),
|
|
260
|
-
signal: controller.signal
|
|
261
|
-
});
|
|
262
|
-
const text = await res.text();
|
|
263
|
-
let parsed;
|
|
264
|
-
try {
|
|
265
|
-
parsed = JSON.parse(text);
|
|
266
|
-
} catch {
|
|
267
|
-
return {
|
|
268
|
-
ok: false,
|
|
269
|
-
error: `sandbox returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`,
|
|
270
|
-
logs: []
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
return {
|
|
274
|
-
ok: !!parsed.ok,
|
|
275
|
-
output: parsed.output,
|
|
276
|
-
error: parsed.error,
|
|
277
|
-
logs: Array.isArray(parsed.logs) ? parsed.logs : [],
|
|
278
|
-
timedOut: parsed.timedOut,
|
|
279
|
-
ms: parsed.ms
|
|
280
|
-
};
|
|
281
|
-
} catch (err) {
|
|
282
|
-
return {
|
|
283
|
-
ok: false,
|
|
284
|
-
error: err instanceof Error && err.name === "AbortError" ? `sandbox request timed out after ${fetchTimeoutMs}ms` : `sandbox request failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
285
|
-
logs: []
|
|
286
|
-
};
|
|
287
|
-
} finally {
|
|
288
|
-
clearTimeout(abortTimer);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
};
|
|
292
|
-
/** Factory for the production sandboxed adapter. */
|
|
293
|
-
function httpSandboxAdapter(options = {}) {
|
|
294
|
-
return new HttpSandboxAdapter(options);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
//#endregion
|
|
298
|
-
export { validateEgressHosts as a, parseHostEntry as i, httpSandboxAdapter as n, validateHostEgress as o, classifyIpLiteral as r, HttpSandboxAdapter as t };
|