@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.
- package/README.md +58 -0
- package/dist/adapter-http-7ypWHBck.d.mts +43 -0
- package/dist/adapter-http-w7SjAfcC.mjs +298 -0
- package/dist/adapter.d.mts +2 -0
- package/dist/adapter.mjs +3 -0
- package/dist/index.d.mts +280 -0
- package/dist/index.mjs +69 -0
- package/package.json +71 -0
- package/src/egress-firewall.ts +330 -0
- package/src/guest-bindings.ts +146 -0
- package/src/guest-entry.ts +446 -0
- package/src/net-validation.ts +359 -0
- package/src/sandbox-server.ts +682 -0
- package/src/server-internals.ts +127 -0
- package/src/types.ts +132 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox server — standalone Deno HTTP supervisor for UNTRUSTED code execution.
|
|
3
|
+
*
|
|
4
|
+
* Runs under Deno ONLY (the main QUESTPIE app, Bun/Node, calls it over HTTP via
|
|
5
|
+
* the `executor` adapter — the main app image needs NO Deno). Excluded from the
|
|
6
|
+
* package's tsc typecheck; shipped as source.
|
|
7
|
+
*
|
|
8
|
+
* SECURITY MODEL (hardened per M1 adversarial review — see decision
|
|
9
|
+
* `untrusted-exec-needs-process-isolation-not-in-process-workers`):
|
|
10
|
+
*
|
|
11
|
+
* Each untrusted request spawns a FRESH `deno run` SUBPROCESS (process-per-
|
|
12
|
+
* request), NOT a warm in-process Worker. This is the core fix: in-process
|
|
13
|
+
* Workers are a permission boundary but NOT a resource boundary —
|
|
14
|
+
* (1) `memoryMb` is unenforceable in-process (V8 heap is process-wide); a
|
|
15
|
+
* memory bomb OOM-kills the whole service + concurrent tenants.
|
|
16
|
+
* (2) `worker.terminate()` does not reap grandchild Workers; a nested
|
|
17
|
+
* `while(true)` Worker orphan pins a core forever.
|
|
18
|
+
* A per-request subprocess fixes BOTH: `--v8-flags=--max-old-space-size` is a
|
|
19
|
+
* REAL per-guest memory bound, and killing the process reaps every orphan.
|
|
20
|
+
*
|
|
21
|
+
* Per-subprocess flags:
|
|
22
|
+
* --v8-flags=--max-old-space-size=<memoryMb> hard memory bound
|
|
23
|
+
* --allow-net=<hosts> scoped fetch() allowlist (INDEPENDENT axis)
|
|
24
|
+
* --allow-import=<hosts> scoped module-import allowlist (INDEPENDENT axis)
|
|
25
|
+
* --no-prompt never block on a permission prompt
|
|
26
|
+
* (no --allow-read/write/env/run/ffi/sys → all DENIED by default)
|
|
27
|
+
* Plus in-guest hardening in `guest-entry.ts`: Worker=undefined,
|
|
28
|
+
* SharedArrayBuffer/Atomics removed.
|
|
29
|
+
*
|
|
30
|
+
* I/O: request envelope in via stdin, result line out via stdout.
|
|
31
|
+
* Hard kill on wall-timeout. Spawn failures → structured {ok:false,error}.
|
|
32
|
+
*
|
|
33
|
+
* EGRESS / SSRF: net + import hosts are validated against the private-IP policy
|
|
34
|
+
* (`net-validation.ts`) at request time — a host that IS or RESOLVES TO a
|
|
35
|
+
* private/link-local/loopback/metadata IP is rejected before spawn. The BASELINE
|
|
36
|
+
* runtime defense is the brokered `http.fetch` path (guest runs `--allow-net=[]`,
|
|
37
|
+
* so it cannot open sockets at all → no DNS-rebind surface).
|
|
38
|
+
*
|
|
39
|
+
* DEFENSE-IN-DEPTH (LINUX cloud workers only): on a capable Linux host the guest
|
|
40
|
+
* subprocess is additionally wrapped in a NETWORK NAMESPACE with an nftables
|
|
41
|
+
* egress firewall (`egress-firewall.ts`) that DROPS all traffic to private/link-
|
|
42
|
+
* local/loopback/metadata ranges at the KERNEL and permits only the resolved
|
|
43
|
+
* per-run allowlist — a second, independent boundary that also covers a
|
|
44
|
+
* hypothetical low-level escape. It is GRACEFULLY ABSENT off Linux (and on Linux
|
|
45
|
+
* without `unshare`/`nft`/`ip` or CAP_NET_ADMIN): a one-line notice is logged and
|
|
46
|
+
* the guest spawns exactly as before, with the baseline staying safe via the
|
|
47
|
+
* brokered fetch. The kernel-drop behavior is verified manually on a Linux worker
|
|
48
|
+
* (it cannot be exercised on macOS/Windows). See `egress-firewall.ts`.
|
|
49
|
+
*
|
|
50
|
+
* Run:
|
|
51
|
+
* deno run --allow-net --allow-env --allow-run --allow-read --allow-write=$TMPDIR \
|
|
52
|
+
* src/sandbox-server.ts
|
|
53
|
+
*
|
|
54
|
+
* (The SERVER needs: net to bind + spawn children that inherit nothing from it;
|
|
55
|
+
* run to spawn `deno` — and, on Linux, `unshare`/`nft`/`ip` for the egress
|
|
56
|
+
* firewall; read to load guest-entry.ts; env to read PORT/DENO_BIN; write to a
|
|
57
|
+
* temp dir to stage the per-run nft ruleset (Linux only — harmless elsewhere).
|
|
58
|
+
* These are the SUPERVISOR's perms — each GUEST subprocess gets only the
|
|
59
|
+
* scoped flags above and inherits none of the supervisor's grants.)
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
// @ts-nocheck — Deno runtime file; not part of the Bun/tsc typecheck graph.
|
|
63
|
+
|
|
64
|
+
import {
|
|
65
|
+
type EgressFirewallPlan,
|
|
66
|
+
planEgressFirewall,
|
|
67
|
+
wrapWithNetns,
|
|
68
|
+
} from "./egress-firewall.ts";
|
|
69
|
+
import { resolveAllowedEndpoints, validateEgressHosts } from "./net-validation.ts";
|
|
70
|
+
import {
|
|
71
|
+
RESULT_MARKER,
|
|
72
|
+
brokerUrlRejection,
|
|
73
|
+
clampInt,
|
|
74
|
+
extractResult,
|
|
75
|
+
importFlags,
|
|
76
|
+
netFlag,
|
|
77
|
+
type RunOutcome,
|
|
78
|
+
} from "./server-internals.ts";
|
|
79
|
+
import { BINDINGS_TOKEN_HEADER, FRAME_MARKER } from "./types.ts";
|
|
80
|
+
import type {
|
|
81
|
+
SandboxCapabilities,
|
|
82
|
+
SandboxRunRequest,
|
|
83
|
+
SandboxRunResult,
|
|
84
|
+
} from "./types.ts";
|
|
85
|
+
|
|
86
|
+
/** Bound on a single binding RPC round-trip to the host broker. */
|
|
87
|
+
const BROKER_FETCH_TIMEOUT_MS = 10_000;
|
|
88
|
+
|
|
89
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
90
|
+
const MAX_TIMEOUT_MS = 30_000;
|
|
91
|
+
const DEFAULT_MEMORY_MB = 128;
|
|
92
|
+
const MIN_MEMORY_MB = 16;
|
|
93
|
+
const MAX_MEMORY_MB = 1024;
|
|
94
|
+
/** Grace period after wall-timeout before SIGKILL (SIGTERM first). */
|
|
95
|
+
const KILL_GRACE_MS = 250;
|
|
96
|
+
|
|
97
|
+
const GUEST_ENTRY_URL = new URL("./guest-entry.ts", import.meta.url);
|
|
98
|
+
const GUEST_ENTRY_PATH = GUEST_ENTRY_URL.pathname;
|
|
99
|
+
const DENO_BIN = Deno.env.get("DENO_BIN") ?? Deno.execPath();
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* DEFENSE-IN-DEPTH for the bindings path: the TRUSTED broker URL the supervisor
|
|
103
|
+
* is willing to relay a guest's binding RPCs to (carrying the per-run scoped
|
|
104
|
+
* token). The runner already sources this from CONFIG (never the request `Host`),
|
|
105
|
+
* but if a future/spoofed caller hands a stray `bindings.url`, the supervisor
|
|
106
|
+
* must NOT mail the token to it. Set this env to the SAME trusted internal broker
|
|
107
|
+
* URL the app uses; any `bindings.url` that does not match is rejected before any
|
|
108
|
+
* relay. When UNSET, this check is skipped (back-compat) — set it in production.
|
|
109
|
+
*/
|
|
110
|
+
const EXPECTED_BROKER_URL = Deno.env.get("SANDBOX_BROKER_URL")?.trim();
|
|
111
|
+
|
|
112
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
113
|
+
// DEFENSE-IN-DEPTH: kernel egress firewall (LINUX-ONLY, cloud workers). S6 of
|
|
114
|
+
// spec `sandbox-brokered-http-egress`. The pure planning/ruleset/argv logic
|
|
115
|
+
// lives in `egress-firewall.ts` (typechecked + unit-tested under bun). The
|
|
116
|
+
// Deno-only PROBING (platform, tool presence, capabilities) + the real spawn
|
|
117
|
+
// wrap live here. On non-Linux — or Linux without the tools/caps — the firewall
|
|
118
|
+
// NO-OPS with a one-line notice and the guest spawns exactly as before, so the
|
|
119
|
+
// macOS/Windows local workers are untouched (baseline safe via brokered fetch).
|
|
120
|
+
//
|
|
121
|
+
// HONESTY: the kernel-drop guarantee is UNVERIFIABLE off Linux; it is verified
|
|
122
|
+
// manually on a Linux worker (see the repro in `egress-firewall.ts`).
|
|
123
|
+
//
|
|
124
|
+
// Off-switch: set SANDBOX_DISABLE_NETNS_FIREWALL=1 to force the no-op path even
|
|
125
|
+
// on a capable Linux host (e.g. when the orchestrator already provides netns).
|
|
126
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
const NETNS_FIREWALL_DISABLED = Deno.env.get("SANDBOX_DISABLE_NETNS_FIREWALL") === "1";
|
|
129
|
+
|
|
130
|
+
/** Probe PATH for a tool by exec'ing `<tool> --version` (no shell). Never throws. */
|
|
131
|
+
async function hasTool(tool: string, versionArg = "--version"): Promise<boolean> {
|
|
132
|
+
try {
|
|
133
|
+
const out = await new Deno.Command(tool, {
|
|
134
|
+
args: [versionArg],
|
|
135
|
+
stdin: "null",
|
|
136
|
+
stdout: "null",
|
|
137
|
+
stderr: "null",
|
|
138
|
+
}).output();
|
|
139
|
+
// `unshare --version` / `nft --version` / `ip -V` all exit 0 when present.
|
|
140
|
+
return out.success;
|
|
141
|
+
} catch {
|
|
142
|
+
return false; // ENOENT (not on PATH) or spawn denied
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Probe whether the supervisor can actually CREATE a filtered netns. We don't
|
|
148
|
+
* parse capability bitmasks — we just attempt the real operation cheaply:
|
|
149
|
+
* `unshare --net --map-root-user true`. If it exits 0 the host supports an
|
|
150
|
+
* (unprivileged-userns or root) network namespace; if it EPERMs, we no-op.
|
|
151
|
+
* This is the most honest probe: it tests the exact syscall path we'll use.
|
|
152
|
+
*/
|
|
153
|
+
async function canCreateNetns(): Promise<boolean> {
|
|
154
|
+
try {
|
|
155
|
+
const out = await new Deno.Command("unshare", {
|
|
156
|
+
args: ["--net", "--map-root-user", "true"],
|
|
157
|
+
stdin: "null",
|
|
158
|
+
stdout: "null",
|
|
159
|
+
stderr: "null",
|
|
160
|
+
}).output();
|
|
161
|
+
return out.success;
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build the per-run egress-firewall plan: probe the platform/tools/caps and
|
|
169
|
+
* resolve the guest's allowlist (net+import hosts) to the PUBLIC IPs that become
|
|
170
|
+
* the kernel `accept` rules. Pure decision logic is delegated to
|
|
171
|
+
* `planEgressFirewall`; this only feeds it real probe results. Never throws.
|
|
172
|
+
*/
|
|
173
|
+
async function buildEgressFirewallPlan(
|
|
174
|
+
netHosts: string[],
|
|
175
|
+
importHosts: string[],
|
|
176
|
+
): Promise<EgressFirewallPlan> {
|
|
177
|
+
if (NETNS_FIREWALL_DISABLED) {
|
|
178
|
+
return { applied: false, reason: "disabled via SANDBOX_DISABLE_NETNS_FIREWALL=1" };
|
|
179
|
+
}
|
|
180
|
+
const os = Deno.build.os; // "linux" | "darwin" | "windows" — Deno's vocabulary
|
|
181
|
+
// Cheap OS gate FIRST: skip the (slowish) tool/cap probes entirely off Linux.
|
|
182
|
+
if (os !== "linux") {
|
|
183
|
+
return planEgressFirewall({ os, allow: [], tools: { unshare: false, nft: false, ip: false }, hasCaps: false });
|
|
184
|
+
}
|
|
185
|
+
const [unshare, nft, ip] = await Promise.all([
|
|
186
|
+
hasTool("unshare"),
|
|
187
|
+
hasTool("nft"),
|
|
188
|
+
hasTool("ip", "-V"),
|
|
189
|
+
]);
|
|
190
|
+
const hasCaps = unshare ? await canCreateNetns() : false;
|
|
191
|
+
// Resolve the allowlist to public IPs (best-effort; never throws). For the
|
|
192
|
+
// brokered path netHosts is [] → pure default-deny netns.
|
|
193
|
+
const allow = await resolveAllowedEndpoints([...netHosts, ...importHosts]);
|
|
194
|
+
return planEgressFirewall({ os, allow, tools: { unshare, nft, ip }, hasCaps });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Write the per-run nft ruleset to a temp file and return `{ cmd, args, cleanup }`
|
|
199
|
+
* wrapping the guest argv with `unshare`. The temp file is read by `nft -f` inside
|
|
200
|
+
* the new netns, then removed by `cleanup()` after the run. Never throws — on any
|
|
201
|
+
* IO failure it returns `null` so the caller falls back to the un-wrapped spawn.
|
|
202
|
+
*/
|
|
203
|
+
async function applyEgressFirewall(
|
|
204
|
+
guestArgv: string[],
|
|
205
|
+
ruleset: string,
|
|
206
|
+
): Promise<{ cmd: string; args: string[]; cleanup: () => Promise<void> } | null> {
|
|
207
|
+
let rulesetPath: string;
|
|
208
|
+
try {
|
|
209
|
+
rulesetPath = await Deno.makeTempFile({ prefix: "qp-sandbox-egress-", suffix: ".nft" });
|
|
210
|
+
await Deno.writeTextFile(rulesetPath, ruleset);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
console.warn(
|
|
213
|
+
`[sandbox] egress firewall: failed to stage ruleset (${err instanceof Error ? err.message : String(err)}); spawning without netns`,
|
|
214
|
+
);
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
const { cmd, args } = wrapWithNetns(guestArgv, rulesetPath);
|
|
218
|
+
return {
|
|
219
|
+
cmd,
|
|
220
|
+
args,
|
|
221
|
+
cleanup: async () => {
|
|
222
|
+
try {
|
|
223
|
+
await Deno.remove(rulesetPath);
|
|
224
|
+
} catch {
|
|
225
|
+
/* best-effort */
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Relay ONE binding RPC to the host broker (supervisor → app, server-to-server),
|
|
233
|
+
* carrying the per-run scoped token. NEVER throws — a broker/network failure is
|
|
234
|
+
* surfaced to the guest as a structured `rpc-result` error, so the guest's call
|
|
235
|
+
* rejects cleanly instead of hanging.
|
|
236
|
+
*
|
|
237
|
+
* This is the ONLY component that knows the token + broker URL; guest code never
|
|
238
|
+
* sees them. The broker is reached over the network HERE (trusted host↔host),
|
|
239
|
+
* which is distinct from the GUEST's egress (the guest cannot fetch the broker —
|
|
240
|
+
* its allowlist rejects loopback/private IPs by design).
|
|
241
|
+
*/
|
|
242
|
+
async function brokerCall(
|
|
243
|
+
bindings: { url: string; token: string },
|
|
244
|
+
method: string,
|
|
245
|
+
args: unknown,
|
|
246
|
+
): Promise<{ ok: boolean; value?: unknown; error?: { code: string; message: string } }> {
|
|
247
|
+
const controller = new AbortController();
|
|
248
|
+
const t = setTimeout(() => controller.abort(), BROKER_FETCH_TIMEOUT_MS);
|
|
249
|
+
try {
|
|
250
|
+
const res = await fetch(bindings.url, {
|
|
251
|
+
method: "POST",
|
|
252
|
+
headers: {
|
|
253
|
+
"content-type": "application/json",
|
|
254
|
+
[BINDINGS_TOKEN_HEADER]: bindings.token,
|
|
255
|
+
},
|
|
256
|
+
body: JSON.stringify({ method, args }),
|
|
257
|
+
signal: controller.signal,
|
|
258
|
+
});
|
|
259
|
+
const text = await res.text();
|
|
260
|
+
try {
|
|
261
|
+
return JSON.parse(text);
|
|
262
|
+
} catch {
|
|
263
|
+
return {
|
|
264
|
+
ok: false,
|
|
265
|
+
error: {
|
|
266
|
+
code: "execution_error",
|
|
267
|
+
message: `broker returned non-JSON (HTTP ${res.status})`,
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
} catch (err) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
error: {
|
|
275
|
+
code: "execution_error",
|
|
276
|
+
message: `broker request failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
} finally {
|
|
280
|
+
clearTimeout(t);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Drive a BINDINGS run: feed the framed envelope, stream the guest's stdout,
|
|
286
|
+
* demultiplex `rpc` (relay to the broker, reply on the guest's stdin) vs the
|
|
287
|
+
* final `result`. Returns the run outcome (without `ms`).
|
|
288
|
+
*
|
|
289
|
+
* Multiplexing contract: the guest writes one JSON line per message, each
|
|
290
|
+
* prefixed by {@link FRAME_MARKER}. The supervisor:
|
|
291
|
+
* - `rpc` → `brokerCall()` → write a framed `rpc-result` to stdin.
|
|
292
|
+
* - `result` → capture as the run outcome and stop.
|
|
293
|
+
* Non-framed stdout lines (stray guest prints) are ignored.
|
|
294
|
+
*/
|
|
295
|
+
async function relayBindingsRun(
|
|
296
|
+
child: Deno.ChildProcess,
|
|
297
|
+
req: SandboxRunRequest,
|
|
298
|
+
timedOut: () => boolean,
|
|
299
|
+
): Promise<RunOutcome> {
|
|
300
|
+
const bindings = req.bindings as { url: string; token: string };
|
|
301
|
+
const encoder = new TextEncoder();
|
|
302
|
+
const decoder = new TextDecoder();
|
|
303
|
+
const stdinWriter = child.stdin.getWriter();
|
|
304
|
+
|
|
305
|
+
let stdinOpen = true;
|
|
306
|
+
const closeStdin = async () => {
|
|
307
|
+
if (!stdinOpen) return;
|
|
308
|
+
stdinOpen = false;
|
|
309
|
+
try {
|
|
310
|
+
await stdinWriter.close();
|
|
311
|
+
} catch {
|
|
312
|
+
/* already closed */
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
const writeLine = async (obj: unknown) => {
|
|
316
|
+
if (!stdinOpen) return;
|
|
317
|
+
try {
|
|
318
|
+
await stdinWriter.write(encoder.encode(FRAME_MARKER + JSON.stringify(obj) + "\n"));
|
|
319
|
+
} catch {
|
|
320
|
+
/* guest gone */
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// 1. Send the envelope as the FIRST framed line (note: `bindings:true` is a
|
|
325
|
+
// boolean FLAG to the guest — the token/url are NEVER sent into the guest).
|
|
326
|
+
await writeLine({
|
|
327
|
+
source: req.source,
|
|
328
|
+
input: req.input,
|
|
329
|
+
secrets: req.secrets ?? {},
|
|
330
|
+
bindings: true,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// 2. Stream stdout, demuxing frames. Outstanding RPCs run concurrently so a
|
|
334
|
+
// slow broker call doesn't block reading further frames.
|
|
335
|
+
let outcome: RunOutcome | null = null;
|
|
336
|
+
let pending = "";
|
|
337
|
+
const inflight: Array<Promise<void>> = [];
|
|
338
|
+
|
|
339
|
+
const handleLine = async (line: string) => {
|
|
340
|
+
if (!line.startsWith(FRAME_MARKER)) return; // stray output → ignore
|
|
341
|
+
let msg: {
|
|
342
|
+
type?: string;
|
|
343
|
+
id?: number;
|
|
344
|
+
method?: string;
|
|
345
|
+
args?: unknown;
|
|
346
|
+
ok?: boolean;
|
|
347
|
+
output?: unknown;
|
|
348
|
+
error?: string;
|
|
349
|
+
logs?: string[];
|
|
350
|
+
};
|
|
351
|
+
try {
|
|
352
|
+
msg = JSON.parse(line.slice(FRAME_MARKER.length));
|
|
353
|
+
} catch {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (msg.type === "rpc" && typeof msg.id === "number" && typeof msg.method === "string") {
|
|
357
|
+
const id = msg.id;
|
|
358
|
+
const p = (async () => {
|
|
359
|
+
const r = await brokerCall(bindings, msg.method as string, msg.args);
|
|
360
|
+
await writeLine({
|
|
361
|
+
type: "rpc-result",
|
|
362
|
+
id,
|
|
363
|
+
ok: !!r.ok,
|
|
364
|
+
value: r.value,
|
|
365
|
+
error: r.error,
|
|
366
|
+
});
|
|
367
|
+
})();
|
|
368
|
+
inflight.push(p);
|
|
369
|
+
} else if (msg.type === "result") {
|
|
370
|
+
outcome = {
|
|
371
|
+
ok: !!msg.ok,
|
|
372
|
+
output: msg.output,
|
|
373
|
+
error: msg.error,
|
|
374
|
+
logs: Array.isArray(msg.logs) ? msg.logs : [],
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const reader = child.stdout.getReader();
|
|
381
|
+
while (true) {
|
|
382
|
+
const { value, done } = await reader.read();
|
|
383
|
+
if (done) break;
|
|
384
|
+
pending += decoder.decode(value, { stream: true });
|
|
385
|
+
let idx: number;
|
|
386
|
+
while ((idx = pending.indexOf("\n")) !== -1) {
|
|
387
|
+
const line = pending.slice(0, idx);
|
|
388
|
+
pending = pending.slice(idx + 1);
|
|
389
|
+
await handleLine(line);
|
|
390
|
+
}
|
|
391
|
+
if (outcome) break; // final result seen
|
|
392
|
+
if (timedOut()) break;
|
|
393
|
+
}
|
|
394
|
+
// Flush a trailing line without newline (the final result frame may not
|
|
395
|
+
// be newline-terminated before exit).
|
|
396
|
+
if (!outcome && pending.length > 0) {
|
|
397
|
+
await handleLine(pending.trim());
|
|
398
|
+
}
|
|
399
|
+
} catch {
|
|
400
|
+
/* stdout closed abruptly — fall through to the no-result handling */
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Settle outstanding broker relays, then close the guest's stdin so its drain
|
|
404
|
+
// loop ends and the process can exit.
|
|
405
|
+
await Promise.allSettled(inflight);
|
|
406
|
+
await closeStdin();
|
|
407
|
+
|
|
408
|
+
// Drain remaining stdout + collect stderr for diagnostics, then reap.
|
|
409
|
+
let stderrText = "";
|
|
410
|
+
try {
|
|
411
|
+
const { stderr } = await child.output();
|
|
412
|
+
stderrText = decoder.decode(stderr).trim();
|
|
413
|
+
} catch {
|
|
414
|
+
/* already consumed/killed */
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (outcome) return outcome;
|
|
418
|
+
return {
|
|
419
|
+
ok: false,
|
|
420
|
+
error:
|
|
421
|
+
stderrText.length > 0
|
|
422
|
+
? `subprocess exited without result: ${stderrText.slice(0, 800)}`
|
|
423
|
+
: "subprocess exited without producing a result",
|
|
424
|
+
logs: [],
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Execute one untrusted request in a fresh, hardened Deno subprocess.
|
|
430
|
+
* Never throws — spawn/IO failures are caught and returned as structured errors.
|
|
431
|
+
*/
|
|
432
|
+
async function runInSubprocess(req: SandboxRunRequest): Promise<SandboxRunResult> {
|
|
433
|
+
const started = performance.now();
|
|
434
|
+
// Best-effort teardown for any per-run resource (e.g. the egress-firewall
|
|
435
|
+
// ruleset temp file). Set when the firewall is applied; fired on every return.
|
|
436
|
+
let onFinish: (() => void) | undefined;
|
|
437
|
+
const finish = (r: RunOutcome): SandboxRunResult => {
|
|
438
|
+
if (onFinish) {
|
|
439
|
+
try {
|
|
440
|
+
onFinish();
|
|
441
|
+
} catch {
|
|
442
|
+
/* best-effort cleanup */
|
|
443
|
+
}
|
|
444
|
+
onFinish = undefined;
|
|
445
|
+
}
|
|
446
|
+
return { ...r, ms: Math.round(performance.now() - started) };
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const caps: SandboxCapabilities = req.capabilities ?? {
|
|
450
|
+
net: [],
|
|
451
|
+
import: [],
|
|
452
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
453
|
+
memoryMb: DEFAULT_MEMORY_MB,
|
|
454
|
+
};
|
|
455
|
+
const timeoutMs = clampInt(caps.timeoutMs, DEFAULT_TIMEOUT_MS, 1, MAX_TIMEOUT_MS);
|
|
456
|
+
const memoryMb = clampInt(caps.memoryMb, DEFAULT_MEMORY_MB, MIN_MEMORY_MB, MAX_MEMORY_MB);
|
|
457
|
+
const netHosts = Array.isArray(caps.net) ? caps.net : [];
|
|
458
|
+
const importHosts = Array.isArray(caps.import) ? caps.import : [];
|
|
459
|
+
|
|
460
|
+
// ── EGRESS VALIDATION (SSRF / private-IP rejection) — before spawn. ──
|
|
461
|
+
const egress = await validateEgressHosts([...netHosts, ...importHosts]);
|
|
462
|
+
if (!egress.ok) {
|
|
463
|
+
return finish({ ok: false, error: `egress blocked: ${egress.reason}`, logs: [] });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// BINDINGS URL VALIDATION (token-exfiltration defense) — before spawn.
|
|
467
|
+
// A bindings run mails the per-run token to `bindings.url`; never relay to a
|
|
468
|
+
// URL the supervisor was not configured to trust (see brokerUrlRejection).
|
|
469
|
+
if (req.bindings) {
|
|
470
|
+
const bindingsRejection = brokerUrlRejection(
|
|
471
|
+
(req.bindings as { url?: unknown }).url,
|
|
472
|
+
EXPECTED_BROKER_URL,
|
|
473
|
+
);
|
|
474
|
+
if (bindingsRejection !== null) {
|
|
475
|
+
return finish({
|
|
476
|
+
ok: false,
|
|
477
|
+
error: `bindings rejected: ${bindingsRejection}`,
|
|
478
|
+
logs: [],
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ── BUILD THE HARDENED SUBPROCESS COMMAND. ──
|
|
484
|
+
const args = [
|
|
485
|
+
"run",
|
|
486
|
+
"--no-prompt",
|
|
487
|
+
`--v8-flags=--max-old-space-size=${memoryMb}`,
|
|
488
|
+
// net + import are INDEPENDENT axes from the manifest (do NOT alias import=net).
|
|
489
|
+
...netFlag(netHosts),
|
|
490
|
+
...importFlags(importHosts),
|
|
491
|
+
// Guest-entry needs to read ITS OWN file. We grant read scoped to that one
|
|
492
|
+
// path; the guest source runs as a blob module (no fs), and fs ops inside the
|
|
493
|
+
// guest still fail because the guest cannot widen this scope.
|
|
494
|
+
`--allow-read=${GUEST_ENTRY_PATH}`,
|
|
495
|
+
GUEST_ENTRY_PATH,
|
|
496
|
+
];
|
|
497
|
+
|
|
498
|
+
// ── DEFENSE-IN-DEPTH: wrap the spawn in a filtered netns on capable Linux. ──
|
|
499
|
+
// The guest argv is `[DENO_BIN, ...args]`. On Linux+tools+caps we prefix it
|
|
500
|
+
// with `unshare --net … nft -f <ruleset> … exec <guest>` so the kernel DROPS
|
|
501
|
+
// egress to private/metadata ranges. Everywhere else this is a logged no-op
|
|
502
|
+
// and `spawnCmd`/`spawnArgs` stay the un-wrapped guest command.
|
|
503
|
+
let spawnCmd = DENO_BIN;
|
|
504
|
+
let spawnArgs = args;
|
|
505
|
+
try {
|
|
506
|
+
const plan = await buildEgressFirewallPlan(netHosts, importHosts);
|
|
507
|
+
if (plan.applied) {
|
|
508
|
+
const wrapped = await applyEgressFirewall([DENO_BIN, ...args], plan.nftRuleset);
|
|
509
|
+
if (wrapped) {
|
|
510
|
+
spawnCmd = wrapped.cmd;
|
|
511
|
+
spawnArgs = wrapped.args;
|
|
512
|
+
onFinish = () => void wrapped.cleanup();
|
|
513
|
+
console.log(
|
|
514
|
+
`[sandbox] egress firewall: ACTIVE (netns + nftables, ${plan.allowCount} allow rule(s))`,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
// wrapped === null → staging failed; fall through to the un-wrapped spawn.
|
|
518
|
+
} else {
|
|
519
|
+
// One-line notice so operators see WHY it's off (non-Linux, missing
|
|
520
|
+
// tool, no caps, or disabled). Not an error — the baseline is safe.
|
|
521
|
+
console.log(`[sandbox] egress firewall: skipped — ${plan.reason}`);
|
|
522
|
+
}
|
|
523
|
+
} catch (err) {
|
|
524
|
+
// The firewall is additive: never let a probe error block a run.
|
|
525
|
+
console.warn(
|
|
526
|
+
`[sandbox] egress firewall: probe failed (${err instanceof Error ? err.message : String(err)}); spawning without netns`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let child: Deno.ChildProcess;
|
|
531
|
+
try {
|
|
532
|
+
const command = new Deno.Command(spawnCmd, {
|
|
533
|
+
args: spawnArgs,
|
|
534
|
+
stdin: "piped",
|
|
535
|
+
stdout: "piped",
|
|
536
|
+
stderr: "piped",
|
|
537
|
+
// Do NOT leak the supervisor's env into the guest process.
|
|
538
|
+
clearEnv: true,
|
|
539
|
+
env: {},
|
|
540
|
+
});
|
|
541
|
+
child = command.spawn();
|
|
542
|
+
} catch (err) {
|
|
543
|
+
// Spawn-throw → structured error, never a bare 500.
|
|
544
|
+
return finish({
|
|
545
|
+
ok: false,
|
|
546
|
+
error: `failed to spawn sandbox subprocess: ${err instanceof Error ? err.message : String(err)}`,
|
|
547
|
+
logs: [],
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
let timedOut = false;
|
|
552
|
+
let killTimer: number | undefined;
|
|
553
|
+
let graceTimer: number | undefined;
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
// HARD wall-time: SIGTERM, then SIGKILL after a grace period. Shared by
|
|
557
|
+
// both the legacy and the bindings I/O paths.
|
|
558
|
+
killTimer = setTimeout(() => {
|
|
559
|
+
timedOut = true;
|
|
560
|
+
try {
|
|
561
|
+
child.kill("SIGTERM");
|
|
562
|
+
} catch {
|
|
563
|
+
/* already gone */
|
|
564
|
+
}
|
|
565
|
+
graceTimer = setTimeout(() => {
|
|
566
|
+
try {
|
|
567
|
+
child.kill("SIGKILL");
|
|
568
|
+
} catch {
|
|
569
|
+
/* already gone */
|
|
570
|
+
}
|
|
571
|
+
}, KILL_GRACE_MS);
|
|
572
|
+
}, timeoutMs);
|
|
573
|
+
|
|
574
|
+
// ── BINDINGS PATH: framed stdio relay to the host broker. ──
|
|
575
|
+
if (req.bindings) {
|
|
576
|
+
const outcome = await relayBindingsRun(child, req, () => timedOut);
|
|
577
|
+
clearTimeout(killTimer);
|
|
578
|
+
if (graceTimer !== undefined) clearTimeout(graceTimer);
|
|
579
|
+
if (timedOut) {
|
|
580
|
+
return finish({
|
|
581
|
+
ok: false,
|
|
582
|
+
timedOut: true,
|
|
583
|
+
error: `wall-time exceeded (${timeoutMs}ms)`,
|
|
584
|
+
logs: outcome?.logs ?? [],
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
return finish(outcome);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ── LEGACY PATH: one envelope in, one result line out (M2 behavior). ──
|
|
591
|
+
const envelope = JSON.stringify({
|
|
592
|
+
source: req.source,
|
|
593
|
+
input: req.input,
|
|
594
|
+
secrets: req.secrets ?? {},
|
|
595
|
+
});
|
|
596
|
+
const writer = child.stdin.getWriter();
|
|
597
|
+
await writer.write(new TextEncoder().encode(envelope));
|
|
598
|
+
await writer.close();
|
|
599
|
+
|
|
600
|
+
const { stdout, stderr } = await child.output();
|
|
601
|
+
clearTimeout(killTimer);
|
|
602
|
+
if (graceTimer !== undefined) clearTimeout(graceTimer);
|
|
603
|
+
|
|
604
|
+
if (timedOut) {
|
|
605
|
+
return finish({
|
|
606
|
+
ok: false,
|
|
607
|
+
timedOut: true,
|
|
608
|
+
error: `wall-time exceeded (${timeoutMs}ms)`,
|
|
609
|
+
logs: [],
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const stdoutText = new TextDecoder().decode(stdout);
|
|
614
|
+
const result = extractResult(stdoutText, RESULT_MARKER);
|
|
615
|
+
if (result) return finish(result);
|
|
616
|
+
|
|
617
|
+
// No marker line → the guest crashed hard (e.g. OOM in its OWN process,
|
|
618
|
+
// blocked import). The subprocess died; the SERVICE is unaffected.
|
|
619
|
+
const stderrText = new TextDecoder().decode(stderr).trim();
|
|
620
|
+
return finish({
|
|
621
|
+
ok: false,
|
|
622
|
+
error:
|
|
623
|
+
stderrText.length > 0
|
|
624
|
+
? `subprocess exited without result: ${stderrText.slice(0, 800)}`
|
|
625
|
+
: "subprocess exited without producing a result",
|
|
626
|
+
logs: [],
|
|
627
|
+
});
|
|
628
|
+
} catch (err) {
|
|
629
|
+
if (killTimer !== undefined) clearTimeout(killTimer);
|
|
630
|
+
if (graceTimer !== undefined) clearTimeout(graceTimer);
|
|
631
|
+
try {
|
|
632
|
+
child.kill("SIGKILL");
|
|
633
|
+
} catch {
|
|
634
|
+
/* noop */
|
|
635
|
+
}
|
|
636
|
+
return finish({
|
|
637
|
+
ok: false,
|
|
638
|
+
error: `sandbox IO error: ${err instanceof Error ? err.message : String(err)}`,
|
|
639
|
+
logs: [],
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
645
|
+
return new Response(JSON.stringify(body), {
|
|
646
|
+
status,
|
|
647
|
+
headers: { "content-type": "application/json" },
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const port = Number(Deno.env.get("PORT") ?? 8787);
|
|
652
|
+
|
|
653
|
+
Deno.serve(
|
|
654
|
+
{ port, onListen: ({ port }) => console.log(`sandbox-server listening on :${port}`) },
|
|
655
|
+
async (request) => {
|
|
656
|
+
const url = new URL(request.url);
|
|
657
|
+
|
|
658
|
+
if (request.method === "GET" && url.pathname === "/health") {
|
|
659
|
+
return jsonResponse({ ok: true, runtime: "deno", version: Deno.version.deno });
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (request.method === "POST" && url.pathname === "/run") {
|
|
663
|
+
let req: SandboxRunRequest;
|
|
664
|
+
try {
|
|
665
|
+
req = (await request.json()) as SandboxRunRequest;
|
|
666
|
+
} catch {
|
|
667
|
+
return jsonResponse({ ok: false, error: "invalid JSON body", logs: [] }, 400);
|
|
668
|
+
}
|
|
669
|
+
if (typeof req?.source !== "string" || !req?.capabilities) {
|
|
670
|
+
return jsonResponse(
|
|
671
|
+
{ ok: false, error: "missing 'source' or 'capabilities'", logs: [] },
|
|
672
|
+
400,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
// runInSubprocess never throws — always a structured result.
|
|
676
|
+
const result = await runInSubprocess(req);
|
|
677
|
+
return jsonResponse(result);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return jsonResponse({ ok: false, error: "not found", logs: [] }, 404);
|
|
681
|
+
},
|
|
682
|
+
);
|