Skip to content

Commit eae3d73

Browse files
committed
computer: validate and bound env, and guard stdin normalization
Caller-supplied env crossed the boundary unchecked and unbounded while source, input, and stdin all had a ceiling. Add assertEnv to reject a non-object env or a non-string value and to bound the total key and value bytes with a new maxEnvBytes option, defaulting to one mebibyte. Harden normalizeStdin to reject a value that is neither a string nor a Uint8Array rather than letting it slip past the stdin ceiling check.
1 parent 2ee8bd1 commit eae3d73

2 files changed

Lines changed: 53 additions & 1 deletion

File tree

packages/computer/src/backends/worker-javascript/worker-javascript.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,34 @@ describe("WorkerJavaScriptBackend", () => {
234234
expect(load).not.toHaveBeenCalled();
235235
});
236236

237+
it("rejects env larger than the configured ceiling", async () => {
238+
const load = vi.fn();
239+
const workspace = new Workspace({
240+
storage: new SQLiteTestStorage(),
241+
backends: [new WorkerJavaScriptBackend({ loader: { load }, maxEnvBytes: 8 })],
242+
});
243+
await workspace.fs.mkdir("/workspace", { recursive: true });
244+
await expect(
245+
workspace.runtime.exec("export default 1", { env: { KEY: "x".repeat(64) } }),
246+
).rejects.toThrow(/env exceeds 8 bytes/);
247+
expect(load).not.toHaveBeenCalled();
248+
});
249+
250+
it("rejects non-string env values", async () => {
251+
const load = vi.fn();
252+
const workspace = new Workspace({
253+
storage: new SQLiteTestStorage(),
254+
backends: [new WorkerJavaScriptBackend({ loader: { load } })],
255+
});
256+
await workspace.fs.mkdir("/workspace", { recursive: true });
257+
await expect(
258+
workspace.runtime.exec("export default 1", {
259+
env: { KEY: 42 as unknown as string },
260+
}),
261+
).rejects.toThrow(/env value for "KEY" must be a string/);
262+
expect(load).not.toHaveBeenCalled();
263+
});
264+
237265
it("checks limits against the complete loader map including the runtime runner", async () => {
238266
const load = vi.fn();
239267
const workspace = new Workspace({

packages/computer/src/backends/worker-javascript/worker-javascript.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface WorkerJavaScriptBackendOptions {
3030
maxSourceBytes?: number;
3131
maxInputBytes?: number;
3232
maxStdinBytes?: number;
33+
maxEnvBytes?: number;
3334
maxResultBytes?: number;
3435
maxLogBytes?: number;
3536
maxLogEvents?: number;
@@ -69,6 +70,7 @@ type ResolvedWorkerJavaScriptBackendOptions = Required<
6970
| "maxSourceBytes"
7071
| "maxInputBytes"
7172
| "maxStdinBytes"
73+
| "maxEnvBytes"
7274
| "maxResultBytes"
7375
| "maxLogBytes"
7476
| "maxLogEvents"
@@ -149,6 +151,7 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend {
149151
assertPositiveFinite(options.maxSourceBytes ?? 256 * 1024, "maxSourceBytes");
150152
assertPositiveFinite(options.maxInputBytes ?? 256 * 1024, "maxInputBytes");
151153
assertPositiveFinite(options.maxStdinBytes ?? 256 * 1024, "maxStdinBytes");
154+
assertPositiveFinite(options.maxEnvBytes ?? 1024 * 1024, "maxEnvBytes");
152155
assertPositiveFinite(options.maxResultBytes ?? 1024 * 1024, "maxResultBytes");
153156
assertPositiveFinite(options.maxLogBytes ?? 256 * 1024, "maxLogBytes");
154157
assertPositiveInteger(options.maxLogEvents ?? 1024, "maxLogEvents");
@@ -191,6 +194,7 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend {
191194
maxSourceBytes: options.maxSourceBytes ?? 256 * 1024,
192195
maxInputBytes: options.maxInputBytes ?? 256 * 1024,
193196
maxStdinBytes: options.maxStdinBytes ?? 256 * 1024,
197+
maxEnvBytes: options.maxEnvBytes ?? 1024 * 1024,
194198
maxResultBytes: options.maxResultBytes ?? 1024 * 1024,
195199
maxLogBytes: options.maxLogBytes ?? 256 * 1024,
196200
maxLogEvents: options.maxLogEvents ?? 1024,
@@ -318,6 +322,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle {
318322
if (stdinBytes.byteLength > this.#options.maxStdinBytes) {
319323
throw new Error(`Workspace runtime stdin exceeds ${this.#options.maxStdinBytes} bytes.`);
320324
}
325+
assertEnv(input.env, this.#options.maxEnvBytes);
321326
if (new TextEncoder().encode(input.source).byteLength > this.#options.maxSourceBytes) {
322327
throw new Error(`Workspace runtime source exceeds ${this.#options.maxSourceBytes} bytes.`);
323328
}
@@ -1170,7 +1175,26 @@ function assertEncodedSize(value: WorkspaceRuntimeValue, maxBytes: number, name:
11701175
function normalizeStdin(stdin: Uint8Array | string | undefined): Uint8Array {
11711176
if (stdin === undefined) return new Uint8Array(0);
11721177
if (typeof stdin === "string") return new TextEncoder().encode(stdin);
1173-
return stdin;
1178+
if (stdin instanceof Uint8Array) return stdin;
1179+
throw new Error("Workspace runtime stdin must be a string or Uint8Array.");
1180+
}
1181+
1182+
function assertEnv(env: Record<string, string> | undefined, maxBytes: number): void {
1183+
if (env === undefined) return;
1184+
if (typeof env !== "object" || env === null || Array.isArray(env)) {
1185+
throw new Error("Workspace runtime env must be a string-to-string record.");
1186+
}
1187+
let bytes = 0;
1188+
const encoder = new TextEncoder();
1189+
for (const [key, value] of Object.entries(env)) {
1190+
if (typeof value !== "string") {
1191+
throw new Error(`Workspace runtime env value for ${JSON.stringify(key)} must be a string.`);
1192+
}
1193+
bytes += encoder.encode(key).byteLength + encoder.encode(value).byteLength;
1194+
}
1195+
if (bytes > maxBytes) {
1196+
throw new Error(`Workspace runtime env exceeds ${maxBytes} bytes.`);
1197+
}
11741198
}
11751199

11761200
function assertPositiveFinite(value: number, name: string) {

0 commit comments

Comments
 (0)