@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/sandbox-server.ts
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Per-subprocess flags:
|
|
22
22
|
* --v8-flags=--max-old-space-size=<memoryMb> hard memory bound
|
|
23
|
-
* --allow-net=<hosts>
|
|
23
|
+
* --allow-net=<hosts> compute-only direct fetch allowlist
|
|
24
|
+
* --allow-net=[] all bindings guests (network stays host-brokered)
|
|
24
25
|
* --allow-import=<hosts> scoped module-import allowlist (INDEPENDENT axis)
|
|
25
26
|
* --no-prompt never block on a permission prompt
|
|
26
27
|
* (no --allow-read/write/env/run/ffi/sys → all DENIED by default)
|
|
@@ -32,9 +33,10 @@
|
|
|
32
33
|
*
|
|
33
34
|
* EGRESS / SSRF: net + import hosts are validated against the private-IP policy
|
|
34
35
|
* (`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.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
36
|
+
* private/link-local/loopback/metadata IP is rejected before spawn. Bindings
|
|
37
|
+
* guests always run with `--allow-net=[]`; their declared `net` capability stays
|
|
38
|
+
* host-side and only the brokered, resolve/validate/pin `http.fetch` can use it.
|
|
39
|
+
* Legacy compute-only guests retain direct Deno networking for compatibility.
|
|
38
40
|
*
|
|
39
41
|
* DEFENSE-IN-DEPTH (LINUX cloud workers only): on a capable Linux host the guest
|
|
40
42
|
* subprocess is additionally wrapped in a NETWORK NAMESPACE with an nftables
|
|
@@ -61,12 +63,30 @@
|
|
|
61
63
|
|
|
62
64
|
// @ts-nocheck — Deno runtime file; not part of the Bun/tsc typecheck graph.
|
|
63
65
|
|
|
66
|
+
import { spawn } from "node:child_process";
|
|
67
|
+
import { Readable, Writable } from "node:stream";
|
|
68
|
+
|
|
69
|
+
import {
|
|
70
|
+
BROKER_FRAME_CAP_BYTES,
|
|
71
|
+
BROKER_HTTP_BODY_CAP_BYTES,
|
|
72
|
+
BROKER_TOTAL_OUTPUT_CAP_BYTES,
|
|
73
|
+
brokerRequestWireCap,
|
|
74
|
+
brokerResultValueCap,
|
|
75
|
+
brokerResponseWireCap,
|
|
76
|
+
} from "./broker-wire.ts";
|
|
64
77
|
import {
|
|
65
78
|
type EgressFirewallPlan,
|
|
66
79
|
planEgressFirewall,
|
|
67
80
|
wrapWithNetns,
|
|
68
81
|
} from "./egress-firewall.ts";
|
|
69
|
-
import {
|
|
82
|
+
import {
|
|
83
|
+
bundleGuestRuntimeSource,
|
|
84
|
+
guestRuntimeDataUrl,
|
|
85
|
+
} from "./guest-runtime-source.ts";
|
|
86
|
+
import {
|
|
87
|
+
resolveAllowedEndpoints,
|
|
88
|
+
validateEgressHosts,
|
|
89
|
+
} from "./net-validation.ts";
|
|
70
90
|
import {
|
|
71
91
|
RESULT_MARKER,
|
|
72
92
|
brokerUrlRejection,
|
|
@@ -74,14 +94,33 @@ import {
|
|
|
74
94
|
extractResult,
|
|
75
95
|
importFlags,
|
|
76
96
|
netFlag,
|
|
97
|
+
normalizeBrokerUrl,
|
|
98
|
+
parseBrokerResponse,
|
|
99
|
+
parseSandboxRunResult,
|
|
77
100
|
type RunOutcome,
|
|
78
101
|
} from "./server-internals.ts";
|
|
79
|
-
import {
|
|
102
|
+
import {
|
|
103
|
+
BINDINGS_TOKEN_HEADER,
|
|
104
|
+
FRAME_MARKER,
|
|
105
|
+
HOST_ADMISSION_HEADER,
|
|
106
|
+
WORKLOAD_ADMISSION_HEADER,
|
|
107
|
+
} from "./types.ts";
|
|
80
108
|
import type {
|
|
81
109
|
SandboxCapabilities,
|
|
82
110
|
SandboxRunRequest,
|
|
83
111
|
SandboxRunResult,
|
|
84
112
|
} from "./types.ts";
|
|
113
|
+
import {
|
|
114
|
+
SandboxWorkloadReplayCache,
|
|
115
|
+
assertSandboxWorkloadAdmissionKey,
|
|
116
|
+
verifySandboxWorkloadAdmission,
|
|
117
|
+
type SandboxWorkloadAdmissionClaims,
|
|
118
|
+
type SandboxWorkloadAdmissionDenialReason,
|
|
119
|
+
} from "./workload-admission.ts";
|
|
120
|
+
import {
|
|
121
|
+
SANDBOX_WORKLOAD_DENIAL_MESSAGE,
|
|
122
|
+
parseSandboxRunRequest,
|
|
123
|
+
} from "./workload.ts";
|
|
85
124
|
|
|
86
125
|
/** Bound on a single binding RPC round-trip to the host broker. */
|
|
87
126
|
const BROKER_FETCH_TIMEOUT_MS = 10_000;
|
|
@@ -91,11 +130,27 @@ const MAX_TIMEOUT_MS = 30_000;
|
|
|
91
130
|
const DEFAULT_MEMORY_MB = 128;
|
|
92
131
|
const MIN_MEMORY_MB = 16;
|
|
93
132
|
const MAX_MEMORY_MB = 1024;
|
|
133
|
+
const MAX_REQUEST_BODY_BYTES = 2 * 1024 * 1024;
|
|
134
|
+
const MAX_CONSUMED_WORKLOAD_ADMISSIONS = 10_000;
|
|
135
|
+
const MAX_STDERR_BYTES = 256 * 1024;
|
|
136
|
+
const MAX_RPC_COUNT = 256;
|
|
137
|
+
const MAX_INFLIGHT_RPCS = 16;
|
|
94
138
|
/** Grace period after wall-timeout before SIGKILL (SIGTERM first). */
|
|
95
139
|
const KILL_GRACE_MS = 250;
|
|
96
140
|
|
|
97
141
|
const GUEST_ENTRY_URL = new URL("./guest-entry.ts", import.meta.url);
|
|
98
142
|
const GUEST_ENTRY_PATH = GUEST_ENTRY_URL.pathname;
|
|
143
|
+
const GUEST_BINDINGS_PATH = new URL("./guest-bindings.ts", import.meta.url)
|
|
144
|
+
.pathname;
|
|
145
|
+
const GUEST_ENTRY_SOURCE = await Deno.readTextFile(GUEST_ENTRY_PATH);
|
|
146
|
+
const GUEST_BINDINGS_SOURCE = await Deno.readTextFile(GUEST_BINDINGS_PATH);
|
|
147
|
+
const GUEST_RUNTIME_MODULE = guestRuntimeDataUrl(
|
|
148
|
+
bundleGuestRuntimeSource(
|
|
149
|
+
GUEST_ENTRY_SOURCE,
|
|
150
|
+
GUEST_BINDINGS_SOURCE,
|
|
151
|
+
BROKER_HTTP_BODY_CAP_BYTES,
|
|
152
|
+
),
|
|
153
|
+
);
|
|
99
154
|
const DENO_BIN = Deno.env.get("DENO_BIN") ?? Deno.execPath();
|
|
100
155
|
|
|
101
156
|
/**
|
|
@@ -105,9 +160,149 @@ const DENO_BIN = Deno.env.get("DENO_BIN") ?? Deno.execPath();
|
|
|
105
160
|
* but if a future/spoofed caller hands a stray `bindings.url`, the supervisor
|
|
106
161
|
* must NOT mail the token to it. Set this env to the SAME trusted internal broker
|
|
107
162
|
* URL the app uses; any `bindings.url` that does not match is rejected before any
|
|
108
|
-
* relay.
|
|
163
|
+
* relay. Bindings fail closed when this canonical URL is missing or malformed.
|
|
109
164
|
*/
|
|
110
165
|
const EXPECTED_BROKER_URL = Deno.env.get("SANDBOX_BROKER_URL")?.trim();
|
|
166
|
+
const WORKLOAD_ADMISSION_SECRET = Deno.env
|
|
167
|
+
.get("SANDBOX_WORKLOAD_ADMISSION_SECRET")
|
|
168
|
+
?.trim();
|
|
169
|
+
const WORKLOAD_ADMISSION_KEY_ID =
|
|
170
|
+
Deno.env.get("SANDBOX_WORKLOAD_ADMISSION_KEY_ID")?.trim() ??
|
|
171
|
+
"sandbox-workload-v1";
|
|
172
|
+
const SANDBOX_INSTANCE_ID = Deno.env.get("SANDBOX_INSTANCE_ID")?.trim() ?? "";
|
|
173
|
+
const HOST_ADMISSION_SECRET = Deno.env
|
|
174
|
+
.get("SANDBOX_HOST_ADMISSION_SECRET")
|
|
175
|
+
?.trim();
|
|
176
|
+
if (WORKLOAD_ADMISSION_SECRET) {
|
|
177
|
+
assertSandboxWorkloadAdmissionKey({
|
|
178
|
+
keyId: WORKLOAD_ADMISSION_KEY_ID,
|
|
179
|
+
secret: new TextEncoder().encode(WORKLOAD_ADMISSION_SECRET),
|
|
180
|
+
instanceId: SANDBOX_INSTANCE_ID,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const consumedWorkloadAdmissions = new SandboxWorkloadReplayCache(
|
|
184
|
+
MAX_CONSUMED_WORKLOAD_ADMISSIONS,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
interface SandboxChildProcess {
|
|
188
|
+
readonly stdin: WritableStream<Uint8Array>;
|
|
189
|
+
readonly stdout: ReadableStream<Uint8Array>;
|
|
190
|
+
readonly stderr: ReadableStream<Uint8Array>;
|
|
191
|
+
readonly exited: Promise<void>;
|
|
192
|
+
kill(signal: "SIGKILL" | "SIGTERM"): void;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
class SandboxOutputLimitError extends Error {}
|
|
196
|
+
|
|
197
|
+
interface OutputBudget {
|
|
198
|
+
total: number;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function readStream(
|
|
202
|
+
stream: ReadableStream<Uint8Array>,
|
|
203
|
+
budget: OutputBudget,
|
|
204
|
+
maximumBytes: number,
|
|
205
|
+
onLimit: () => void,
|
|
206
|
+
): Promise<Uint8Array> {
|
|
207
|
+
const reader = stream.getReader();
|
|
208
|
+
const chunks: Uint8Array[] = [];
|
|
209
|
+
let streamTotal = 0;
|
|
210
|
+
while (true) {
|
|
211
|
+
const { value, done } = await reader.read();
|
|
212
|
+
if (done) break;
|
|
213
|
+
streamTotal += value.byteLength;
|
|
214
|
+
budget.total += value.byteLength;
|
|
215
|
+
if (
|
|
216
|
+
streamTotal > maximumBytes ||
|
|
217
|
+
budget.total > BROKER_TOTAL_OUTPUT_CAP_BYTES
|
|
218
|
+
) {
|
|
219
|
+
onLimit();
|
|
220
|
+
await reader.cancel();
|
|
221
|
+
throw new SandboxOutputLimitError();
|
|
222
|
+
}
|
|
223
|
+
chunks.push(value);
|
|
224
|
+
}
|
|
225
|
+
const output = new Uint8Array(streamTotal);
|
|
226
|
+
let offset = 0;
|
|
227
|
+
for (const chunk of chunks) {
|
|
228
|
+
output.set(chunk, offset);
|
|
229
|
+
offset += chunk.byteLength;
|
|
230
|
+
}
|
|
231
|
+
return output;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function spawnSandboxChild(
|
|
235
|
+
command: string,
|
|
236
|
+
args: string[],
|
|
237
|
+
argv0: string,
|
|
238
|
+
): SandboxChildProcess {
|
|
239
|
+
const child = spawn(command, args, {
|
|
240
|
+
argv0,
|
|
241
|
+
cwd: "/",
|
|
242
|
+
env: {},
|
|
243
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
244
|
+
});
|
|
245
|
+
const exited = new Promise<void>((resolve, reject) => {
|
|
246
|
+
child.once("error", reject);
|
|
247
|
+
child.once("close", () => resolve());
|
|
248
|
+
});
|
|
249
|
+
void exited.catch(() => {});
|
|
250
|
+
return {
|
|
251
|
+
stdin: Writable.toWeb(child.stdin),
|
|
252
|
+
stdout: Readable.toWeb(child.stdout),
|
|
253
|
+
stderr: Readable.toWeb(child.stderr),
|
|
254
|
+
exited,
|
|
255
|
+
kill: (signal) => {
|
|
256
|
+
child.kill(signal);
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function authenticatesHost(value: string | null): boolean {
|
|
262
|
+
if (
|
|
263
|
+
!value ||
|
|
264
|
+
!HOST_ADMISSION_SECRET ||
|
|
265
|
+
new TextEncoder().encode(HOST_ADMISSION_SECRET).byteLength < 32
|
|
266
|
+
) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
const expected = new TextEncoder().encode(HOST_ADMISSION_SECRET);
|
|
270
|
+
const actual = new TextEncoder().encode(value);
|
|
271
|
+
if (actual.byteLength !== expected.byteLength) return false;
|
|
272
|
+
let difference = 0;
|
|
273
|
+
for (let index = 0; index < expected.byteLength; index += 1) {
|
|
274
|
+
difference |= expected[index] ^ actual[index];
|
|
275
|
+
}
|
|
276
|
+
return difference === 0;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
type WorkloadAdmissionReason =
|
|
280
|
+
| SandboxWorkloadAdmissionDenialReason
|
|
281
|
+
| "authorized"
|
|
282
|
+
| "host_unauthorized"
|
|
283
|
+
| "missing"
|
|
284
|
+
| "replay"
|
|
285
|
+
| "unknown_mode";
|
|
286
|
+
|
|
287
|
+
function auditWorkloadAdmission(
|
|
288
|
+
decision: "allowed" | "denied",
|
|
289
|
+
reason: WorkloadAdmissionReason,
|
|
290
|
+
claims?: SandboxWorkloadAdmissionClaims,
|
|
291
|
+
): void {
|
|
292
|
+
console.log(
|
|
293
|
+
JSON.stringify(
|
|
294
|
+
Object.freeze({
|
|
295
|
+
event: "questpie.sandbox.workload_admission",
|
|
296
|
+
boundary: "sandbox.runtime_admission",
|
|
297
|
+
decision,
|
|
298
|
+
reason,
|
|
299
|
+
...(claims
|
|
300
|
+
? { supervisorInstanceId: claims.supervisorInstanceId }
|
|
301
|
+
: {}),
|
|
302
|
+
}),
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
111
306
|
|
|
112
307
|
// ──────────────────────────────────────────────────────────────────────────
|
|
113
308
|
// DEFENSE-IN-DEPTH: kernel egress firewall (LINUX-ONLY, cloud workers). S6 of
|
|
@@ -125,24 +320,51 @@ const EXPECTED_BROKER_URL = Deno.env.get("SANDBOX_BROKER_URL")?.trim();
|
|
|
125
320
|
// on a capable Linux host (e.g. when the orchestrator already provides netns).
|
|
126
321
|
// ──────────────────────────────────────────────────────────────────────────
|
|
127
322
|
|
|
128
|
-
const NETNS_FIREWALL_DISABLED =
|
|
323
|
+
const NETNS_FIREWALL_DISABLED =
|
|
324
|
+
Deno.env.get("SANDBOX_DISABLE_NETNS_FIREWALL") === "1";
|
|
129
325
|
|
|
130
|
-
|
|
131
|
-
|
|
326
|
+
async function runProbe(
|
|
327
|
+
command: string,
|
|
328
|
+
args: string[],
|
|
329
|
+
signal?: AbortSignal,
|
|
330
|
+
): Promise<boolean> {
|
|
331
|
+
if (signal?.aborted) return false;
|
|
332
|
+
let child;
|
|
132
333
|
try {
|
|
133
|
-
|
|
134
|
-
args
|
|
334
|
+
child = new Deno.Command(command, {
|
|
335
|
+
args,
|
|
135
336
|
stdin: "null",
|
|
136
337
|
stdout: "null",
|
|
137
338
|
stderr: "null",
|
|
138
|
-
}).
|
|
139
|
-
|
|
140
|
-
|
|
339
|
+
}).spawn();
|
|
340
|
+
const onAbort = () => {
|
|
341
|
+
try {
|
|
342
|
+
child.kill("SIGKILL");
|
|
343
|
+
} catch {
|
|
344
|
+
/* already exited */
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
348
|
+
if (signal?.aborted) onAbort();
|
|
349
|
+
try {
|
|
350
|
+
return (await child.output()).success && !signal?.aborted;
|
|
351
|
+
} finally {
|
|
352
|
+
signal?.removeEventListener("abort", onAbort);
|
|
353
|
+
}
|
|
141
354
|
} catch {
|
|
142
|
-
return false;
|
|
355
|
+
return false;
|
|
143
356
|
}
|
|
144
357
|
}
|
|
145
358
|
|
|
359
|
+
/** Probe PATH for a tool by exec'ing `<tool> --version` (no shell). Never throws. */
|
|
360
|
+
function hasTool(
|
|
361
|
+
tool: string,
|
|
362
|
+
versionArg = "--version",
|
|
363
|
+
signal?: AbortSignal,
|
|
364
|
+
): Promise<boolean> {
|
|
365
|
+
return runProbe(tool, [versionArg], signal);
|
|
366
|
+
}
|
|
367
|
+
|
|
146
368
|
/**
|
|
147
369
|
* Probe whether the supervisor can actually CREATE a filtered netns. We don't
|
|
148
370
|
* parse capability bitmasks — we just attempt the real operation cheaply:
|
|
@@ -150,18 +372,13 @@ async function hasTool(tool: string, versionArg = "--version"): Promise<boolean>
|
|
|
150
372
|
* (unprivileged-userns or root) network namespace; if it EPERMs, we no-op.
|
|
151
373
|
* This is the most honest probe: it tests the exact syscall path we'll use.
|
|
152
374
|
*/
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}).output();
|
|
161
|
-
return out.success;
|
|
162
|
-
} catch {
|
|
163
|
-
return false;
|
|
164
|
-
}
|
|
375
|
+
function canCreateNetns(signal?: AbortSignal): Promise<boolean> {
|
|
376
|
+
return runProbe("unshare", ["--net", "--map-root-user", "true"], signal);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** GNU env can set argv[0] for the Deno process inside the netns wrapper. */
|
|
380
|
+
function supportsVirtualArgv0Env(signal?: AbortSignal): Promise<boolean> {
|
|
381
|
+
return runProbe("env", ["--argv0=/runtime/deno", "true"], signal);
|
|
165
382
|
}
|
|
166
383
|
|
|
167
384
|
/**
|
|
@@ -173,25 +390,41 @@ async function canCreateNetns(): Promise<boolean> {
|
|
|
173
390
|
async function buildEgressFirewallPlan(
|
|
174
391
|
netHosts: string[],
|
|
175
392
|
importHosts: string[],
|
|
393
|
+
signal?: AbortSignal,
|
|
176
394
|
): Promise<EgressFirewallPlan> {
|
|
177
395
|
if (NETNS_FIREWALL_DISABLED) {
|
|
178
|
-
return {
|
|
396
|
+
return {
|
|
397
|
+
applied: false,
|
|
398
|
+
reason: "disabled via SANDBOX_DISABLE_NETNS_FIREWALL=1",
|
|
399
|
+
};
|
|
179
400
|
}
|
|
180
401
|
const os = Deno.build.os; // "linux" | "darwin" | "windows" — Deno's vocabulary
|
|
181
402
|
// Cheap OS gate FIRST: skip the (slowish) tool/cap probes entirely off Linux.
|
|
182
403
|
if (os !== "linux") {
|
|
183
|
-
return planEgressFirewall({
|
|
404
|
+
return planEgressFirewall({
|
|
405
|
+
os,
|
|
406
|
+
allow: [],
|
|
407
|
+
tools: { unshare: false, nft: false, ip: false },
|
|
408
|
+
hasCaps: false,
|
|
409
|
+
});
|
|
184
410
|
}
|
|
185
411
|
const [unshare, nft, ip] = await Promise.all([
|
|
186
|
-
hasTool("unshare"),
|
|
187
|
-
hasTool("nft"),
|
|
188
|
-
hasTool("ip", "-V"),
|
|
412
|
+
hasTool("unshare", "--version", signal),
|
|
413
|
+
hasTool("nft", "--version", signal),
|
|
414
|
+
hasTool("ip", "-V", signal),
|
|
189
415
|
]);
|
|
190
|
-
const hasCaps = unshare ? await canCreateNetns() : false;
|
|
416
|
+
const hasCaps = unshare ? await canCreateNetns(signal) : false;
|
|
191
417
|
// Resolve the allowlist to public IPs (best-effort; never throws). For the
|
|
192
418
|
// brokered path netHosts is [] → pure default-deny netns.
|
|
193
|
-
const allow = await resolveAllowedEndpoints([...netHosts, ...importHosts]
|
|
194
|
-
|
|
419
|
+
const allow = await resolveAllowedEndpoints([...netHosts, ...importHosts], {
|
|
420
|
+
signal,
|
|
421
|
+
});
|
|
422
|
+
return planEgressFirewall({
|
|
423
|
+
os,
|
|
424
|
+
allow,
|
|
425
|
+
tools: { unshare, nft, ip },
|
|
426
|
+
hasCaps,
|
|
427
|
+
});
|
|
195
428
|
}
|
|
196
429
|
|
|
197
430
|
/**
|
|
@@ -203,11 +436,28 @@ async function buildEgressFirewallPlan(
|
|
|
203
436
|
async function applyEgressFirewall(
|
|
204
437
|
guestArgv: string[],
|
|
205
438
|
ruleset: string,
|
|
206
|
-
|
|
439
|
+
signal?: AbortSignal,
|
|
440
|
+
): Promise<{
|
|
441
|
+
cmd: string;
|
|
442
|
+
args: string[];
|
|
443
|
+
cleanup: () => Promise<void>;
|
|
444
|
+
} | null> {
|
|
207
445
|
let rulesetPath: string;
|
|
208
446
|
try {
|
|
209
|
-
|
|
447
|
+
if (signal?.aborted) return null;
|
|
448
|
+
rulesetPath = await Deno.makeTempFile({
|
|
449
|
+
prefix: "qp-sandbox-egress-",
|
|
450
|
+
suffix: ".nft",
|
|
451
|
+
});
|
|
452
|
+
if (signal?.aborted) {
|
|
453
|
+
await Deno.remove(rulesetPath);
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
210
456
|
await Deno.writeTextFile(rulesetPath, ruleset);
|
|
457
|
+
if (signal?.aborted) {
|
|
458
|
+
await Deno.remove(rulesetPath);
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
211
461
|
} catch (err) {
|
|
212
462
|
console.warn(
|
|
213
463
|
`[sandbox] egress firewall: failed to stage ruleset (${err instanceof Error ? err.message : String(err)}); spawning without netns`,
|
|
@@ -243,12 +493,21 @@ async function brokerCall(
|
|
|
243
493
|
bindings: { url: string; token: string },
|
|
244
494
|
method: string,
|
|
245
495
|
args: unknown,
|
|
246
|
-
|
|
496
|
+
parentSignal?: AbortSignal,
|
|
497
|
+
): Promise<{
|
|
498
|
+
ok: boolean;
|
|
499
|
+
value?: unknown;
|
|
500
|
+
error?: { code: string; message: string };
|
|
501
|
+
}> {
|
|
247
502
|
const controller = new AbortController();
|
|
503
|
+
const onAbort = () => controller.abort();
|
|
504
|
+
if (parentSignal?.aborted) controller.abort();
|
|
505
|
+
else parentSignal?.addEventListener("abort", onAbort, { once: true });
|
|
248
506
|
const t = setTimeout(() => controller.abort(), BROKER_FETCH_TIMEOUT_MS);
|
|
249
507
|
try {
|
|
250
508
|
const res = await fetch(bindings.url, {
|
|
251
509
|
method: "POST",
|
|
510
|
+
redirect: "error",
|
|
252
511
|
headers: {
|
|
253
512
|
"content-type": "application/json",
|
|
254
513
|
[BINDINGS_TOKEN_HEADER]: bindings.token,
|
|
@@ -256,28 +515,79 @@ async function brokerCall(
|
|
|
256
515
|
body: JSON.stringify({ method, args }),
|
|
257
516
|
signal: controller.signal,
|
|
258
517
|
});
|
|
259
|
-
const
|
|
518
|
+
const reader = res.body?.getReader();
|
|
519
|
+
const chunks: Uint8Array[] = [];
|
|
520
|
+
let total = 0;
|
|
521
|
+
if (reader) {
|
|
522
|
+
while (true) {
|
|
523
|
+
const { value, done } = await reader.read();
|
|
524
|
+
if (done) break;
|
|
525
|
+
total += value.byteLength;
|
|
526
|
+
if (total > brokerResponseWireCap(method)) {
|
|
527
|
+
await reader.cancel();
|
|
528
|
+
return {
|
|
529
|
+
ok: false,
|
|
530
|
+
error: {
|
|
531
|
+
code: "execution_error",
|
|
532
|
+
message: "sandbox broker returned an invalid response",
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
chunks.push(value);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const bytes = new Uint8Array(total);
|
|
540
|
+
let offset = 0;
|
|
541
|
+
for (const chunk of chunks) {
|
|
542
|
+
bytes.set(chunk, offset);
|
|
543
|
+
offset += chunk.byteLength;
|
|
544
|
+
}
|
|
545
|
+
const text = new TextDecoder().decode(bytes);
|
|
260
546
|
try {
|
|
261
|
-
|
|
547
|
+
const parsed = parseBrokerResponse(
|
|
548
|
+
JSON.parse(text),
|
|
549
|
+
brokerResultValueCap(method),
|
|
550
|
+
);
|
|
551
|
+
if (!res.ok) {
|
|
552
|
+
return parsed && !parsed.ok
|
|
553
|
+
? parsed
|
|
554
|
+
: {
|
|
555
|
+
ok: false,
|
|
556
|
+
error: {
|
|
557
|
+
code: "execution_error",
|
|
558
|
+
message: "sandbox binding operation failed",
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
return (
|
|
563
|
+
parsed ?? {
|
|
564
|
+
ok: false,
|
|
565
|
+
error: {
|
|
566
|
+
code: "execution_error",
|
|
567
|
+
message: "sandbox broker returned an invalid response",
|
|
568
|
+
},
|
|
569
|
+
}
|
|
570
|
+
);
|
|
262
571
|
} catch {
|
|
263
572
|
return {
|
|
264
573
|
ok: false,
|
|
265
574
|
error: {
|
|
266
575
|
code: "execution_error",
|
|
267
|
-
message:
|
|
576
|
+
message: "sandbox broker returned an invalid response",
|
|
268
577
|
},
|
|
269
578
|
};
|
|
270
579
|
}
|
|
271
|
-
} catch
|
|
580
|
+
} catch {
|
|
272
581
|
return {
|
|
273
582
|
ok: false,
|
|
274
583
|
error: {
|
|
275
584
|
code: "execution_error",
|
|
276
|
-
message:
|
|
585
|
+
message: "sandbox broker request failed",
|
|
277
586
|
},
|
|
278
587
|
};
|
|
279
588
|
} finally {
|
|
280
589
|
clearTimeout(t);
|
|
590
|
+
parentSignal?.removeEventListener("abort", onAbort);
|
|
281
591
|
}
|
|
282
592
|
}
|
|
283
593
|
|
|
@@ -293,11 +603,20 @@ async function brokerCall(
|
|
|
293
603
|
* Non-framed stdout lines (stray guest prints) are ignored.
|
|
294
604
|
*/
|
|
295
605
|
async function relayBindingsRun(
|
|
296
|
-
child:
|
|
606
|
+
child: SandboxChildProcess,
|
|
297
607
|
req: SandboxRunRequest,
|
|
298
608
|
timedOut: () => boolean,
|
|
609
|
+
stderrPromise: Promise<Uint8Array>,
|
|
610
|
+
outputBudget: OutputBudget,
|
|
611
|
+
onLimit: () => void,
|
|
612
|
+
onTerminal: () => void,
|
|
613
|
+
signal?: AbortSignal,
|
|
299
614
|
): Promise<RunOutcome> {
|
|
300
|
-
const
|
|
615
|
+
const requestedBindings = req.bindings as { url: string; token: string };
|
|
616
|
+
const bindings = {
|
|
617
|
+
...requestedBindings,
|
|
618
|
+
url: normalizeBrokerUrl(requestedBindings.url)!,
|
|
619
|
+
};
|
|
301
620
|
const encoder = new TextEncoder();
|
|
302
621
|
const decoder = new TextDecoder();
|
|
303
622
|
const stdinWriter = child.stdin.getWriter();
|
|
@@ -315,7 +634,9 @@ async function relayBindingsRun(
|
|
|
315
634
|
const writeLine = async (obj: unknown) => {
|
|
316
635
|
if (!stdinOpen) return;
|
|
317
636
|
try {
|
|
318
|
-
await stdinWriter.write(
|
|
637
|
+
await stdinWriter.write(
|
|
638
|
+
encoder.encode(FRAME_MARKER + JSON.stringify(obj) + "\n"),
|
|
639
|
+
);
|
|
319
640
|
} catch {
|
|
320
641
|
/* guest gone */
|
|
321
642
|
}
|
|
@@ -333,8 +654,22 @@ async function relayBindingsRun(
|
|
|
333
654
|
// 2. Stream stdout, demuxing frames. Outstanding RPCs run concurrently so a
|
|
334
655
|
// slow broker call doesn't block reading further frames.
|
|
335
656
|
let outcome: RunOutcome | null = null;
|
|
336
|
-
let
|
|
337
|
-
|
|
657
|
+
let terminalFailure: string | null = null;
|
|
658
|
+
let pendingChunks: Uint8Array[] = [];
|
|
659
|
+
let pendingBytes = 0;
|
|
660
|
+
let rpcCount = 0;
|
|
661
|
+
const inflight = new Set<Promise<void>>();
|
|
662
|
+
const takePendingLine = (): string => {
|
|
663
|
+
const bytes = new Uint8Array(pendingBytes);
|
|
664
|
+
let offset = 0;
|
|
665
|
+
for (const chunk of pendingChunks) {
|
|
666
|
+
bytes.set(chunk, offset);
|
|
667
|
+
offset += chunk.byteLength;
|
|
668
|
+
}
|
|
669
|
+
pendingChunks = [];
|
|
670
|
+
pendingBytes = 0;
|
|
671
|
+
return decoder.decode(bytes);
|
|
672
|
+
};
|
|
338
673
|
|
|
339
674
|
const handleLine = async (line: string) => {
|
|
340
675
|
if (!line.startsWith(FRAME_MARKER)) return; // stray output → ignore
|
|
@@ -353,10 +688,42 @@ async function relayBindingsRun(
|
|
|
353
688
|
} catch {
|
|
354
689
|
return;
|
|
355
690
|
}
|
|
356
|
-
if (
|
|
691
|
+
if (
|
|
692
|
+
msg.type === "rpc" &&
|
|
693
|
+
typeof msg.id === "number" &&
|
|
694
|
+
typeof msg.method === "string"
|
|
695
|
+
) {
|
|
696
|
+
if (
|
|
697
|
+
encoder.encode(line).byteLength >
|
|
698
|
+
brokerRequestWireCap(msg.method) + FRAME_MARKER.length
|
|
699
|
+
) {
|
|
700
|
+
onLimit();
|
|
701
|
+
throw new SandboxOutputLimitError();
|
|
702
|
+
}
|
|
703
|
+
rpcCount += 1;
|
|
704
|
+
if (rpcCount > MAX_RPC_COUNT) {
|
|
705
|
+
onLimit();
|
|
706
|
+
throw new SandboxOutputLimitError();
|
|
707
|
+
}
|
|
708
|
+
while (inflight.size >= MAX_INFLIGHT_RPCS) {
|
|
709
|
+
await Promise.race(inflight);
|
|
710
|
+
}
|
|
357
711
|
const id = msg.id;
|
|
358
712
|
const p = (async () => {
|
|
359
|
-
const r = await brokerCall(
|
|
713
|
+
const r = await brokerCall(
|
|
714
|
+
bindings,
|
|
715
|
+
msg.method as string,
|
|
716
|
+
msg.args,
|
|
717
|
+
signal,
|
|
718
|
+
);
|
|
719
|
+
if (!r.ok) {
|
|
720
|
+
if (!terminalFailure && !outcome) {
|
|
721
|
+
terminalFailure = r.error.message;
|
|
722
|
+
onTerminal();
|
|
723
|
+
}
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (signal?.aborted || terminalFailure || outcome) return;
|
|
360
727
|
await writeLine({
|
|
361
728
|
type: "rpc-result",
|
|
362
729
|
id,
|
|
@@ -365,14 +732,35 @@ async function relayBindingsRun(
|
|
|
365
732
|
error: r.error,
|
|
366
733
|
});
|
|
367
734
|
})();
|
|
368
|
-
inflight.
|
|
735
|
+
inflight.add(p);
|
|
736
|
+
void p.finally(() => inflight.delete(p));
|
|
369
737
|
} else if (msg.type === "result") {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
738
|
+
const keys = Object.keys(msg);
|
|
739
|
+
if (
|
|
740
|
+
keys.every((key) =>
|
|
741
|
+
["type", "ok", "output", "error", "logs"].includes(key),
|
|
742
|
+
)
|
|
743
|
+
) {
|
|
744
|
+
const parsed = parseSandboxRunResult({
|
|
745
|
+
ok: msg.ok,
|
|
746
|
+
...(Object.hasOwn(msg, "output") ? { output: msg.output } : {}),
|
|
747
|
+
...(Object.hasOwn(msg, "error") ? { error: msg.error } : {}),
|
|
748
|
+
logs: msg.logs,
|
|
749
|
+
});
|
|
750
|
+
if (!parsed) {
|
|
751
|
+
terminalFailure = "sandbox guest returned an invalid result";
|
|
752
|
+
onTerminal();
|
|
753
|
+
} else if (inflight.size > 0) {
|
|
754
|
+
terminalFailure = "sandbox result raced binding calls";
|
|
755
|
+
onTerminal();
|
|
756
|
+
} else {
|
|
757
|
+
outcome = parsed;
|
|
758
|
+
onTerminal();
|
|
759
|
+
}
|
|
760
|
+
} else {
|
|
761
|
+
terminalFailure = "sandbox guest returned an invalid result";
|
|
762
|
+
onTerminal();
|
|
763
|
+
}
|
|
376
764
|
}
|
|
377
765
|
};
|
|
378
766
|
|
|
@@ -381,22 +769,39 @@ async function relayBindingsRun(
|
|
|
381
769
|
while (true) {
|
|
382
770
|
const { value, done } = await reader.read();
|
|
383
771
|
if (done) break;
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
772
|
+
outputBudget.total += value.byteLength;
|
|
773
|
+
if (outputBudget.total > BROKER_TOTAL_OUTPUT_CAP_BYTES) {
|
|
774
|
+
onLimit();
|
|
775
|
+
throw new SandboxOutputLimitError();
|
|
776
|
+
}
|
|
777
|
+
let start = 0;
|
|
778
|
+
while (start < value.byteLength) {
|
|
779
|
+
const newline = value.indexOf(0x0a, start);
|
|
780
|
+
const end = newline === -1 ? value.byteLength : newline;
|
|
781
|
+
const segment = value.subarray(start, end);
|
|
782
|
+
if (segment.byteLength > 0) {
|
|
783
|
+
pendingChunks.push(segment);
|
|
784
|
+
pendingBytes += segment.byteLength;
|
|
785
|
+
}
|
|
786
|
+
if (pendingBytes > BROKER_FRAME_CAP_BYTES) {
|
|
787
|
+
onLimit();
|
|
788
|
+
throw new SandboxOutputLimitError();
|
|
789
|
+
}
|
|
790
|
+
if (newline === -1) break;
|
|
791
|
+
await handleLine(takePendingLine());
|
|
792
|
+
if (outcome || terminalFailure) break;
|
|
793
|
+
start = newline + 1;
|
|
390
794
|
}
|
|
391
|
-
if (outcome) break;
|
|
795
|
+
if (outcome || terminalFailure) break;
|
|
392
796
|
if (timedOut()) break;
|
|
393
797
|
}
|
|
394
798
|
// Flush a trailing line without newline (the final result frame may not
|
|
395
799
|
// be newline-terminated before exit).
|
|
396
|
-
if (!outcome &&
|
|
397
|
-
await handleLine(
|
|
800
|
+
if (!outcome && !terminalFailure && pendingBytes > 0) {
|
|
801
|
+
await handleLine(takePendingLine().trim());
|
|
398
802
|
}
|
|
399
|
-
} catch {
|
|
803
|
+
} catch (error) {
|
|
804
|
+
if (error instanceof SandboxOutputLimitError) throw error;
|
|
400
805
|
/* stdout closed abruptly — fall through to the no-result handling */
|
|
401
806
|
}
|
|
402
807
|
|
|
@@ -408,12 +813,16 @@ async function relayBindingsRun(
|
|
|
408
813
|
// Drain remaining stdout + collect stderr for diagnostics, then reap.
|
|
409
814
|
let stderrText = "";
|
|
410
815
|
try {
|
|
411
|
-
const
|
|
816
|
+
const stderr = await stderrPromise;
|
|
817
|
+
await child.exited;
|
|
412
818
|
stderrText = decoder.decode(stderr).trim();
|
|
413
819
|
} catch {
|
|
414
820
|
/* already consumed/killed */
|
|
415
821
|
}
|
|
416
822
|
|
|
823
|
+
if (terminalFailure) {
|
|
824
|
+
return { ok: false, error: terminalFailure, logs: [] };
|
|
825
|
+
}
|
|
417
826
|
if (outcome) return outcome;
|
|
418
827
|
return {
|
|
419
828
|
ok: false,
|
|
@@ -429,38 +838,95 @@ async function relayBindingsRun(
|
|
|
429
838
|
* Execute one untrusted request in a fresh, hardened Deno subprocess.
|
|
430
839
|
* Never throws — spawn/IO failures are caught and returned as structured errors.
|
|
431
840
|
*/
|
|
432
|
-
async function runInSubprocess(
|
|
841
|
+
async function runInSubprocess(
|
|
842
|
+
req: SandboxRunRequest,
|
|
843
|
+
signal?: AbortSignal,
|
|
844
|
+
): Promise<SandboxRunResult> {
|
|
433
845
|
const started = performance.now();
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
846
|
+
const caps: SandboxCapabilities = req.capabilities ?? {
|
|
847
|
+
net: [],
|
|
848
|
+
import: [],
|
|
849
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
850
|
+
memoryMb: DEFAULT_MEMORY_MB,
|
|
851
|
+
};
|
|
852
|
+
const timeoutMs = clampInt(
|
|
853
|
+
caps.timeoutMs,
|
|
854
|
+
DEFAULT_TIMEOUT_MS,
|
|
855
|
+
1,
|
|
856
|
+
MAX_TIMEOUT_MS,
|
|
857
|
+
);
|
|
858
|
+
let deadlineTimedOut = false;
|
|
859
|
+
let parentCancelled = signal?.aborted ?? false;
|
|
860
|
+
const executionController = new AbortController();
|
|
861
|
+
const onParentAbort = () => {
|
|
862
|
+
parentCancelled = true;
|
|
863
|
+
executionController.abort();
|
|
864
|
+
};
|
|
865
|
+
if (signal?.aborted) executionController.abort();
|
|
866
|
+
else signal?.addEventListener("abort", onParentAbort, { once: true });
|
|
867
|
+
const absoluteTimer = setTimeout(() => {
|
|
868
|
+
deadlineTimedOut = true;
|
|
869
|
+
executionController.abort();
|
|
870
|
+
}, timeoutMs);
|
|
871
|
+
const executionSignal = executionController.signal;
|
|
872
|
+
// Best-effort teardown for per-run resources (work root, firewall ruleset).
|
|
873
|
+
const cleanup: Array<() => void> = [
|
|
874
|
+
() => clearTimeout(absoluteTimer),
|
|
875
|
+
() => signal?.removeEventListener("abort", onParentAbort),
|
|
876
|
+
];
|
|
437
877
|
const finish = (r: RunOutcome): SandboxRunResult => {
|
|
438
|
-
|
|
878
|
+
for (const dispose of cleanup.splice(0)) {
|
|
439
879
|
try {
|
|
440
|
-
|
|
880
|
+
dispose();
|
|
441
881
|
} catch {
|
|
442
882
|
/* best-effort cleanup */
|
|
443
883
|
}
|
|
444
|
-
onFinish = undefined;
|
|
445
884
|
}
|
|
446
885
|
return { ...r, ms: Math.round(performance.now() - started) };
|
|
447
886
|
};
|
|
887
|
+
const interrupted = (): SandboxRunResult =>
|
|
888
|
+
finish(
|
|
889
|
+
deadlineTimedOut
|
|
890
|
+
? {
|
|
891
|
+
ok: false,
|
|
892
|
+
timedOut: true,
|
|
893
|
+
error: `wall-time exceeded (${timeoutMs}ms)`,
|
|
894
|
+
logs: [],
|
|
895
|
+
}
|
|
896
|
+
: {
|
|
897
|
+
ok: false,
|
|
898
|
+
error: "sandbox request cancelled",
|
|
899
|
+
logs: [],
|
|
900
|
+
},
|
|
901
|
+
);
|
|
902
|
+
if (executionSignal.aborted) {
|
|
903
|
+
return interrupted();
|
|
904
|
+
}
|
|
448
905
|
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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);
|
|
906
|
+
const memoryMb = clampInt(
|
|
907
|
+
caps.memoryMb,
|
|
908
|
+
DEFAULT_MEMORY_MB,
|
|
909
|
+
MIN_MEMORY_MB,
|
|
910
|
+
MAX_MEMORY_MB,
|
|
911
|
+
);
|
|
457
912
|
const netHosts = Array.isArray(caps.net) ? caps.net : [];
|
|
458
913
|
const importHosts = Array.isArray(caps.import) ? caps.import : [];
|
|
459
914
|
|
|
460
915
|
// ── EGRESS VALIDATION (SSRF / private-IP rejection) — before spawn. ──
|
|
461
|
-
const egress = await validateEgressHosts([...netHosts, ...importHosts]
|
|
916
|
+
const egress = await validateEgressHosts([...netHosts, ...importHosts], {
|
|
917
|
+
signal: executionSignal,
|
|
918
|
+
timeoutMs,
|
|
919
|
+
concurrency: 4,
|
|
920
|
+
});
|
|
921
|
+
if (executionSignal.aborted) {
|
|
922
|
+
return interrupted();
|
|
923
|
+
}
|
|
462
924
|
if (!egress.ok) {
|
|
463
|
-
return finish({
|
|
925
|
+
return finish({
|
|
926
|
+
ok: false,
|
|
927
|
+
error: `egress blocked: ${egress.reason}`,
|
|
928
|
+
logs: [],
|
|
929
|
+
});
|
|
464
930
|
}
|
|
465
931
|
|
|
466
932
|
// BINDINGS URL VALIDATION (token-exfiltration defense) — before spawn.
|
|
@@ -480,19 +946,36 @@ async function runInSubprocess(req: SandboxRunRequest): Promise<SandboxRunResult
|
|
|
480
946
|
}
|
|
481
947
|
}
|
|
482
948
|
|
|
949
|
+
// Every subprocess receives a fresh host-private cwd. The guest has no file
|
|
950
|
+
// permissions for it and guest-entry exposes only the virtual `/work` name.
|
|
951
|
+
let guestWorkRoot: string;
|
|
952
|
+
try {
|
|
953
|
+
if (executionSignal.aborted) return interrupted();
|
|
954
|
+
guestWorkRoot = await Deno.makeTempDir({ prefix: "qp-sandbox-work-" });
|
|
955
|
+
cleanup.push(() => Deno.removeSync(guestWorkRoot, { recursive: true }));
|
|
956
|
+
if (executionSignal.aborted) return interrupted();
|
|
957
|
+
} catch {
|
|
958
|
+
return finish({
|
|
959
|
+
ok: false,
|
|
960
|
+
error: "failed to create sandbox work root",
|
|
961
|
+
logs: [],
|
|
962
|
+
});
|
|
963
|
+
}
|
|
483
964
|
// ── BUILD THE HARDENED SUBPROCESS COMMAND. ──
|
|
965
|
+
// Bindings guests keep their declared `net` allowlist on the trusted broker.
|
|
966
|
+
// Giving the subprocess the same `--allow-net` grant would let raw sockets
|
|
967
|
+
// bypass the resolve/validate/pin/redirect checks enforced by `http.fetch`.
|
|
968
|
+
const directNetHosts = req.bindings ? [] : netHosts;
|
|
484
969
|
const args = [
|
|
485
970
|
"run",
|
|
486
971
|
"--no-prompt",
|
|
487
972
|
`--v8-flags=--max-old-space-size=${memoryMb}`,
|
|
488
973
|
// net + import are INDEPENDENT axes from the manifest (do NOT alias import=net).
|
|
489
|
-
...netFlag(
|
|
974
|
+
...netFlag(directNetHosts),
|
|
490
975
|
...importFlags(importHosts),
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
-
`--allow-read=${GUEST_ENTRY_PATH}`,
|
|
495
|
-
GUEST_ENTRY_PATH,
|
|
976
|
+
// A self-contained data module needs neither a host path nor a socket, so it
|
|
977
|
+
// remains loadable after the Linux network namespace is created.
|
|
978
|
+
GUEST_RUNTIME_MODULE,
|
|
496
979
|
];
|
|
497
980
|
|
|
498
981
|
// ── DEFENSE-IN-DEPTH: wrap the spawn in a filtered netns on capable Linux. ──
|
|
@@ -503,16 +986,31 @@ async function runInSubprocess(req: SandboxRunRequest): Promise<SandboxRunResult
|
|
|
503
986
|
let spawnCmd = DENO_BIN;
|
|
504
987
|
let spawnArgs = args;
|
|
505
988
|
try {
|
|
506
|
-
const plan = await buildEgressFirewallPlan(
|
|
989
|
+
const plan = await buildEgressFirewallPlan(
|
|
990
|
+
directNetHosts,
|
|
991
|
+
importHosts,
|
|
992
|
+
executionSignal,
|
|
993
|
+
);
|
|
507
994
|
if (plan.applied) {
|
|
508
|
-
const
|
|
995
|
+
const canVirtualizeArgv0 = await supportsVirtualArgv0Env(executionSignal);
|
|
996
|
+
const wrapped = canVirtualizeArgv0
|
|
997
|
+
? await applyEgressFirewall(
|
|
998
|
+
["env", "--argv0=/runtime/deno", DENO_BIN, ...args],
|
|
999
|
+
plan.nftRuleset,
|
|
1000
|
+
executionSignal,
|
|
1001
|
+
)
|
|
1002
|
+
: null;
|
|
509
1003
|
if (wrapped) {
|
|
510
1004
|
spawnCmd = wrapped.cmd;
|
|
511
1005
|
spawnArgs = wrapped.args;
|
|
512
|
-
|
|
1006
|
+
cleanup.push(() => void wrapped.cleanup());
|
|
513
1007
|
console.log(
|
|
514
1008
|
`[sandbox] egress firewall: ACTIVE (netns + nftables, ${plan.allowCount} allow rule(s))`,
|
|
515
1009
|
);
|
|
1010
|
+
} else if (!canVirtualizeArgv0) {
|
|
1011
|
+
console.log(
|
|
1012
|
+
"[sandbox] egress firewall: skipped — env lacks argv0 virtualization; baseline isolation remains active",
|
|
1013
|
+
);
|
|
516
1014
|
}
|
|
517
1015
|
// wrapped === null → staging failed; fall through to the un-wrapped spawn.
|
|
518
1016
|
} else {
|
|
@@ -526,19 +1024,13 @@ async function runInSubprocess(req: SandboxRunRequest): Promise<SandboxRunResult
|
|
|
526
1024
|
`[sandbox] egress firewall: probe failed (${err instanceof Error ? err.message : String(err)}); spawning without netns`,
|
|
527
1025
|
);
|
|
528
1026
|
}
|
|
1027
|
+
if (executionSignal.aborted) {
|
|
1028
|
+
return interrupted();
|
|
1029
|
+
}
|
|
529
1030
|
|
|
530
|
-
let child:
|
|
1031
|
+
let child: SandboxChildProcess;
|
|
531
1032
|
try {
|
|
532
|
-
|
|
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();
|
|
1033
|
+
child = spawnSandboxChild(spawnCmd, spawnArgs, "/runtime/deno");
|
|
542
1034
|
} catch (err) {
|
|
543
1035
|
// Spawn-throw → structured error, never a bare 500.
|
|
544
1036
|
return finish({
|
|
@@ -548,35 +1040,81 @@ async function runInSubprocess(req: SandboxRunRequest): Promise<SandboxRunResult
|
|
|
548
1040
|
});
|
|
549
1041
|
}
|
|
550
1042
|
|
|
551
|
-
let
|
|
552
|
-
let
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
1043
|
+
let outputLimitExceeded = false;
|
|
1044
|
+
let cancelGraceTimer: number | undefined;
|
|
1045
|
+
const relayController = new AbortController();
|
|
1046
|
+
let terminationStarted = false;
|
|
1047
|
+
const terminateChild = () => {
|
|
1048
|
+
if (terminationStarted) return;
|
|
1049
|
+
terminationStarted = true;
|
|
1050
|
+
try {
|
|
1051
|
+
child.kill("SIGTERM");
|
|
1052
|
+
} catch {
|
|
1053
|
+
/* already gone */
|
|
1054
|
+
}
|
|
1055
|
+
cancelGraceTimer = setTimeout(() => {
|
|
560
1056
|
try {
|
|
561
|
-
child.kill("
|
|
1057
|
+
child.kill("SIGKILL");
|
|
562
1058
|
} catch {
|
|
563
1059
|
/* already gone */
|
|
564
1060
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
1061
|
+
}, KILL_GRACE_MS);
|
|
1062
|
+
};
|
|
1063
|
+
const onExecutionAbort = () => {
|
|
1064
|
+
relayController.abort();
|
|
1065
|
+
terminateChild();
|
|
1066
|
+
};
|
|
1067
|
+
const onOutputLimit = () => {
|
|
1068
|
+
if (outputLimitExceeded) return;
|
|
1069
|
+
outputLimitExceeded = true;
|
|
1070
|
+
relayController.abort();
|
|
1071
|
+
terminateChild();
|
|
1072
|
+
};
|
|
1073
|
+
if (executionSignal.aborted) onExecutionAbort();
|
|
1074
|
+
else
|
|
1075
|
+
executionSignal.addEventListener("abort", onExecutionAbort, { once: true });
|
|
1076
|
+
const outputBudget: OutputBudget = { total: 0 };
|
|
1077
|
+
const stderrPromise = readStream(
|
|
1078
|
+
child.stderr,
|
|
1079
|
+
outputBudget,
|
|
1080
|
+
MAX_STDERR_BYTES,
|
|
1081
|
+
onOutputLimit,
|
|
1082
|
+
);
|
|
1083
|
+
void stderrPromise.catch(() => {});
|
|
573
1084
|
|
|
1085
|
+
try {
|
|
574
1086
|
// ── BINDINGS PATH: framed stdio relay to the host broker. ──
|
|
575
1087
|
if (req.bindings) {
|
|
576
|
-
const outcome = await relayBindingsRun(
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
1088
|
+
const outcome = await relayBindingsRun(
|
|
1089
|
+
child,
|
|
1090
|
+
req,
|
|
1091
|
+
() => deadlineTimedOut,
|
|
1092
|
+
stderrPromise,
|
|
1093
|
+
outputBudget,
|
|
1094
|
+
onOutputLimit,
|
|
1095
|
+
() => {
|
|
1096
|
+
relayController.abort();
|
|
1097
|
+
terminateChild();
|
|
1098
|
+
},
|
|
1099
|
+
relayController.signal,
|
|
1100
|
+
);
|
|
1101
|
+
if (cancelGraceTimer !== undefined) clearTimeout(cancelGraceTimer);
|
|
1102
|
+
executionSignal.removeEventListener("abort", onExecutionAbort);
|
|
1103
|
+
if (parentCancelled) {
|
|
1104
|
+
return finish({
|
|
1105
|
+
ok: false,
|
|
1106
|
+
error: "sandbox request cancelled",
|
|
1107
|
+
logs: outcome?.logs ?? [],
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
if (outputLimitExceeded) {
|
|
1111
|
+
return finish({
|
|
1112
|
+
ok: false,
|
|
1113
|
+
error: "sandbox output limit exceeded",
|
|
1114
|
+
logs: [],
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
if (deadlineTimedOut) {
|
|
580
1118
|
return finish({
|
|
581
1119
|
ok: false,
|
|
582
1120
|
timedOut: true,
|
|
@@ -597,11 +1135,34 @@ async function runInSubprocess(req: SandboxRunRequest): Promise<SandboxRunResult
|
|
|
597
1135
|
await writer.write(new TextEncoder().encode(envelope));
|
|
598
1136
|
await writer.close();
|
|
599
1137
|
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
1138
|
+
const [stdout, stderr] = await Promise.all([
|
|
1139
|
+
readStream(
|
|
1140
|
+
child.stdout,
|
|
1141
|
+
outputBudget,
|
|
1142
|
+
BROKER_TOTAL_OUTPUT_CAP_BYTES,
|
|
1143
|
+
onOutputLimit,
|
|
1144
|
+
),
|
|
1145
|
+
stderrPromise,
|
|
1146
|
+
child.exited,
|
|
1147
|
+
]);
|
|
1148
|
+
if (cancelGraceTimer !== undefined) clearTimeout(cancelGraceTimer);
|
|
1149
|
+
executionSignal.removeEventListener("abort", onExecutionAbort);
|
|
603
1150
|
|
|
604
|
-
if (
|
|
1151
|
+
if (parentCancelled) {
|
|
1152
|
+
return finish({
|
|
1153
|
+
ok: false,
|
|
1154
|
+
error: "sandbox request cancelled",
|
|
1155
|
+
logs: [],
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
if (outputLimitExceeded) {
|
|
1159
|
+
return finish({
|
|
1160
|
+
ok: false,
|
|
1161
|
+
error: "sandbox output limit exceeded",
|
|
1162
|
+
logs: [],
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
if (deadlineTimedOut) {
|
|
605
1166
|
return finish({
|
|
606
1167
|
ok: false,
|
|
607
1168
|
timedOut: true,
|
|
@@ -626,16 +1187,20 @@ async function runInSubprocess(req: SandboxRunRequest): Promise<SandboxRunResult
|
|
|
626
1187
|
logs: [],
|
|
627
1188
|
});
|
|
628
1189
|
} catch (err) {
|
|
629
|
-
if (
|
|
630
|
-
|
|
1190
|
+
if (cancelGraceTimer !== undefined) clearTimeout(cancelGraceTimer);
|
|
1191
|
+
executionSignal.removeEventListener("abort", onExecutionAbort);
|
|
631
1192
|
try {
|
|
632
1193
|
child.kill("SIGKILL");
|
|
633
1194
|
} catch {
|
|
634
1195
|
/* noop */
|
|
635
1196
|
}
|
|
1197
|
+
if (parentCancelled || deadlineTimedOut) return interrupted();
|
|
636
1198
|
return finish({
|
|
637
1199
|
ok: false,
|
|
638
|
-
error:
|
|
1200
|
+
error:
|
|
1201
|
+
outputLimitExceeded || err instanceof SandboxOutputLimitError
|
|
1202
|
+
? "sandbox output limit exceeded"
|
|
1203
|
+
: `sandbox IO error: ${err instanceof Error ? err.message : String(err)}`,
|
|
639
1204
|
logs: [],
|
|
640
1205
|
});
|
|
641
1206
|
}
|
|
@@ -648,32 +1213,183 @@ function jsonResponse(body: unknown, status = 200): Response {
|
|
|
648
1213
|
});
|
|
649
1214
|
}
|
|
650
1215
|
|
|
1216
|
+
class RequestBodyTooLargeError extends Error {}
|
|
1217
|
+
|
|
1218
|
+
async function readBoundedRequestBody(request: Request): Promise<string> {
|
|
1219
|
+
const declared = request.headers.get("content-length");
|
|
1220
|
+
if (
|
|
1221
|
+
declared !== null &&
|
|
1222
|
+
Number.isFinite(Number(declared)) &&
|
|
1223
|
+
Number(declared) > MAX_REQUEST_BODY_BYTES
|
|
1224
|
+
) {
|
|
1225
|
+
throw new RequestBodyTooLargeError();
|
|
1226
|
+
}
|
|
1227
|
+
if (!request.body) return "";
|
|
1228
|
+
const reader = request.body.getReader();
|
|
1229
|
+
const chunks: Uint8Array[] = [];
|
|
1230
|
+
let total = 0;
|
|
1231
|
+
while (true) {
|
|
1232
|
+
const { value, done } = await reader.read();
|
|
1233
|
+
if (done) break;
|
|
1234
|
+
total += value.byteLength;
|
|
1235
|
+
if (total > MAX_REQUEST_BODY_BYTES) {
|
|
1236
|
+
await reader.cancel();
|
|
1237
|
+
throw new RequestBodyTooLargeError();
|
|
1238
|
+
}
|
|
1239
|
+
chunks.push(value);
|
|
1240
|
+
}
|
|
1241
|
+
const body = new Uint8Array(total);
|
|
1242
|
+
let offset = 0;
|
|
1243
|
+
for (const chunk of chunks) {
|
|
1244
|
+
body.set(chunk, offset);
|
|
1245
|
+
offset += chunk.byteLength;
|
|
1246
|
+
}
|
|
1247
|
+
return new TextDecoder().decode(body);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
651
1250
|
const port = Number(Deno.env.get("PORT") ?? 8787);
|
|
652
1251
|
|
|
653
1252
|
Deno.serve(
|
|
654
|
-
{
|
|
1253
|
+
{
|
|
1254
|
+
port,
|
|
1255
|
+
onListen: ({ port }) => {
|
|
1256
|
+
console.log(`sandbox-server listening on :${port}`);
|
|
1257
|
+
},
|
|
1258
|
+
},
|
|
655
1259
|
async (request) => {
|
|
656
1260
|
const url = new URL(request.url);
|
|
657
|
-
|
|
658
1261
|
if (request.method === "GET" && url.pathname === "/health") {
|
|
659
|
-
return jsonResponse({
|
|
1262
|
+
return jsonResponse({
|
|
1263
|
+
ok: true,
|
|
1264
|
+
runtime: "deno",
|
|
1265
|
+
version: Deno.version.deno,
|
|
1266
|
+
});
|
|
660
1267
|
}
|
|
661
1268
|
|
|
662
1269
|
if (request.method === "POST" && url.pathname === "/run") {
|
|
663
1270
|
let req: SandboxRunRequest;
|
|
1271
|
+
let requestBody: string;
|
|
664
1272
|
try {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
1273
|
+
requestBody = await readBoundedRequestBody(request);
|
|
1274
|
+
req = JSON.parse(requestBody) as SandboxRunRequest;
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
if (error instanceof RequestBodyTooLargeError) {
|
|
1277
|
+
return jsonResponse(
|
|
1278
|
+
{ ok: false, error: "request body too large", logs: [] },
|
|
1279
|
+
413,
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
return jsonResponse(
|
|
1283
|
+
{ ok: false, error: "invalid JSON body", logs: [] },
|
|
1284
|
+
400,
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
let workloadAdmission: SandboxWorkloadAdmissionClaims | null = null;
|
|
1288
|
+
let workloadDenialReason: WorkloadAdmissionReason = "missing";
|
|
1289
|
+
let workloadDenialClaims: SandboxWorkloadAdmissionClaims | undefined;
|
|
1290
|
+
if (
|
|
1291
|
+
req?.mode === "host" &&
|
|
1292
|
+
!authenticatesHost(request.headers.get(HOST_ADMISSION_HEADER))
|
|
1293
|
+
) {
|
|
1294
|
+
auditWorkloadAdmission("denied", "host_unauthorized");
|
|
1295
|
+
return jsonResponse(
|
|
1296
|
+
{
|
|
1297
|
+
ok: false,
|
|
1298
|
+
error: SANDBOX_WORKLOAD_DENIAL_MESSAGE,
|
|
1299
|
+
logs: [],
|
|
1300
|
+
},
|
|
1301
|
+
403,
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
if (req?.mode === "workload") {
|
|
1305
|
+
const envelope = request.headers.get(WORKLOAD_ADMISSION_HEADER);
|
|
1306
|
+
if (envelope && WORKLOAD_ADMISSION_SECRET) {
|
|
1307
|
+
try {
|
|
1308
|
+
const verification = await verifySandboxWorkloadAdmission(
|
|
1309
|
+
{
|
|
1310
|
+
keyId: WORKLOAD_ADMISSION_KEY_ID,
|
|
1311
|
+
secret: new TextEncoder().encode(WORKLOAD_ADMISSION_SECRET),
|
|
1312
|
+
instanceId: SANDBOX_INSTANCE_ID,
|
|
1313
|
+
},
|
|
1314
|
+
envelope,
|
|
1315
|
+
requestBody,
|
|
1316
|
+
);
|
|
1317
|
+
if (verification.ok) {
|
|
1318
|
+
workloadAdmission = verification.claims;
|
|
1319
|
+
} else {
|
|
1320
|
+
workloadDenialReason = verification.reason;
|
|
1321
|
+
workloadDenialClaims = verification.claims;
|
|
1322
|
+
}
|
|
1323
|
+
} catch {
|
|
1324
|
+
workloadAdmission = null;
|
|
1325
|
+
workloadDenialReason = "invalid";
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
668
1328
|
}
|
|
669
|
-
if (
|
|
1329
|
+
if (req?.mode === "workload" && !workloadAdmission) {
|
|
1330
|
+
auditWorkloadAdmission(
|
|
1331
|
+
"denied",
|
|
1332
|
+
workloadDenialReason,
|
|
1333
|
+
workloadDenialClaims,
|
|
1334
|
+
);
|
|
670
1335
|
return jsonResponse(
|
|
671
|
-
{
|
|
1336
|
+
{
|
|
1337
|
+
ok: false,
|
|
1338
|
+
error: SANDBOX_WORKLOAD_DENIAL_MESSAGE,
|
|
1339
|
+
logs: [],
|
|
1340
|
+
},
|
|
1341
|
+
403,
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
if (req?.mode !== "workload" && req?.mode !== "host") {
|
|
1345
|
+
auditWorkloadAdmission("denied", "unknown_mode");
|
|
1346
|
+
return jsonResponse(
|
|
1347
|
+
{
|
|
1348
|
+
ok: false,
|
|
1349
|
+
error: SANDBOX_WORKLOAD_DENIAL_MESSAGE,
|
|
1350
|
+
logs: [],
|
|
1351
|
+
},
|
|
1352
|
+
403,
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
const validatedRequest = parseSandboxRunRequest(req);
|
|
1356
|
+
if (!validatedRequest) {
|
|
1357
|
+
return jsonResponse(
|
|
1358
|
+
{ ok: false, error: "invalid sandbox request", logs: [] },
|
|
672
1359
|
400,
|
|
673
1360
|
);
|
|
674
1361
|
}
|
|
1362
|
+
req = validatedRequest;
|
|
1363
|
+
if (
|
|
1364
|
+
workloadAdmission &&
|
|
1365
|
+
!consumedWorkloadAdmissions.consume(workloadAdmission)
|
|
1366
|
+
) {
|
|
1367
|
+
auditWorkloadAdmission("denied", "replay", workloadAdmission);
|
|
1368
|
+
return jsonResponse(
|
|
1369
|
+
{
|
|
1370
|
+
ok: false,
|
|
1371
|
+
error: SANDBOX_WORKLOAD_DENIAL_MESSAGE,
|
|
1372
|
+
logs: [],
|
|
1373
|
+
},
|
|
1374
|
+
403,
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
if (workloadAdmission) {
|
|
1378
|
+
auditWorkloadAdmission("allowed", "authorized", workloadAdmission);
|
|
1379
|
+
}
|
|
675
1380
|
// runInSubprocess never throws — always a structured result.
|
|
676
|
-
const result = await runInSubprocess(req);
|
|
1381
|
+
const result = await runInSubprocess(req, request.signal);
|
|
1382
|
+
if (
|
|
1383
|
+
req.mode === "workload" &&
|
|
1384
|
+
!result.ok &&
|
|
1385
|
+
!result.timedOut &&
|
|
1386
|
+
result.error !== "sandbox request cancelled"
|
|
1387
|
+
) {
|
|
1388
|
+
return jsonResponse({
|
|
1389
|
+
...result,
|
|
1390
|
+
error: "sandbox execution failed",
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
677
1393
|
return jsonResponse(result);
|
|
678
1394
|
}
|
|
679
1395
|
|