@secure-exec/browser 0.1.0 → 0.1.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Validate permission callback source strings before revival via new Function().
3
+ *
4
+ * Permission callbacks are serialized with fn.toString() on the host and revived
5
+ * in the Web Worker. Because revival uses new Function(), the source must be
6
+ * validated to prevent code injection.
7
+ */
8
+ /**
9
+ * Validate that a permission callback source string is safe to revive.
10
+ *
11
+ * Returns true if the source appears to be a safe function expression.
12
+ * Returns false if the source contains blocked patterns that could indicate
13
+ * code injection.
14
+ */
15
+ export declare function validatePermissionSource(source: string): boolean;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Validate permission callback source strings before revival via new Function().
3
+ *
4
+ * Permission callbacks are serialized with fn.toString() on the host and revived
5
+ * in the Web Worker. Because revival uses new Function(), the source must be
6
+ * validated to prevent code injection.
7
+ */
8
+ /**
9
+ * Dangerous patterns that should never appear in a permission callback.
10
+ * These could be used to escape the sandbox or access host resources.
11
+ */
12
+ const BLOCKED_PATTERNS = [
13
+ // Code execution / eval
14
+ /\beval\s*\(/,
15
+ /\bFunction\s*\(/,
16
+ /\bnew\s+Function\b/,
17
+ // Module loading
18
+ /\bimport\s*\(/,
19
+ /\bimportScripts\s*\(/,
20
+ /\brequire\s*\(/,
21
+ // Global object access
22
+ /\bglobalThis\b/,
23
+ /\bself\b/,
24
+ /\bwindow\b/,
25
+ // Process/system access
26
+ /\bprocess\s*\.\s*(?:exit|kill|binding|_linkedBinding|env)\b/,
27
+ // Network / IO escape
28
+ /\bXMLHttpRequest\b/,
29
+ /\bWebSocket\b/,
30
+ /\bfetch\s*\(/,
31
+ // Prototype pollution / constructor abuse
32
+ /\bconstructor\s*\[/,
33
+ /\b__proto__\b/,
34
+ /Object\s*\.\s*(?:defineProperty|setPrototypeOf|assign)\b/,
35
+ // Dynamic property access on dangerous objects
36
+ /\bpostMessage\b/,
37
+ ];
38
+ /**
39
+ * Validate that a permission callback source string is safe to revive.
40
+ *
41
+ * Returns true if the source appears to be a safe function expression.
42
+ * Returns false if the source contains blocked patterns that could indicate
43
+ * code injection.
44
+ */
45
+ export function validatePermissionSource(source) {
46
+ if (!source || typeof source !== "string")
47
+ return false;
48
+ const trimmed = source.trim();
49
+ // Must look like a function expression (arrow function or function keyword)
50
+ const startsLikeFunction = trimmed.startsWith("function") ||
51
+ trimmed.startsWith("(") ||
52
+ // Single-param arrow functions: x => ...
53
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=>/.test(trimmed);
54
+ if (!startsLikeFunction)
55
+ return false;
56
+ // Check for blocked patterns
57
+ for (const pattern of BLOCKED_PATTERNS) {
58
+ if (pattern.test(source))
59
+ return false;
60
+ }
61
+ return true;
62
+ }
@@ -13,14 +13,6 @@ const BROWSER_OPTION_VALIDATORS = [
13
13
  label: "timingMitigation",
14
14
  hasValue: (options) => options.timingMitigation !== undefined,
15
15
  },
16
- {
17
- label: "payloadLimits.base64TransferBytes",
18
- hasValue: (options) => options.payloadLimits?.base64TransferBytes !== undefined,
19
- },
20
- {
21
- label: "payloadLimits.jsonPayloadBytes",
22
- hasValue: (options) => options.payloadLimits?.jsonPayloadBytes !== undefined,
23
- },
24
16
  ];
25
17
  function serializePermissions(permissions) {
26
18
  if (!permissions) {
@@ -104,6 +96,7 @@ export class BrowserRuntimeDriver {
104
96
  permissions: serializePermissions(options.system.permissions),
105
97
  filesystem: browserSystemOptions.filesystem,
106
98
  networkEnabled: browserSystemOptions.networkEnabled,
99
+ payloadLimits: options.payloadLimits,
107
100
  };
108
101
  this.ready = this.callWorker("init", initPayload).then(() => undefined);
109
102
  this.ready.catch(() => undefined);
@@ -17,6 +17,10 @@ export type BrowserWorkerInitPayload = {
17
17
  permissions?: SerializedPermissions;
18
18
  filesystem?: "opfs" | "memory";
19
19
  networkEnabled?: boolean;
20
+ payloadLimits?: {
21
+ base64TransferBytes?: number;
22
+ jsonPayloadBytes?: number;
23
+ };
20
24
  };
21
25
  export type BrowserWorkerRequestMessage = {
22
26
  id: number;
package/dist/worker.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { transform } from "sucrase";
2
2
  import { getRequireSetupCode, createCommandExecutorStub, createFsStub, createNetworkStub, filterEnv, wrapFileSystem, wrapNetworkAdapter, createInMemoryFileSystem, isESM, transformDynamicImport, getIsolateRuntimeSource, POLYFILL_CODE_MAP, loadFile, resolveModule, mkdir, exposeCustomGlobal, exposeMutableRuntimeStateGlobal, } from "@secure-exec/core";
3
3
  import { createBrowserNetworkAdapter, createOpfsFileSystem, } from "./driver.js";
4
+ import { validatePermissionSource } from "./permission-validation.js";
4
5
  let filesystem = null;
5
6
  let networkAdapter = null;
6
7
  let commandExecutor = null;
@@ -12,6 +13,26 @@ const MAX_STDIO_MESSAGE_CHARS = 8192;
12
13
  const MAX_STDIO_DEPTH = 6;
13
14
  const MAX_STDIO_OBJECT_KEYS = 60;
14
15
  const MAX_STDIO_ARRAY_ITEMS = 120;
16
+ // Payload size defaults matching the Node runtime path
17
+ const DEFAULT_BASE64_TRANSFER_BYTES = 16 * 1024 * 1024;
18
+ const DEFAULT_JSON_PAYLOAD_BYTES = 4 * 1024 * 1024;
19
+ const PAYLOAD_LIMIT_ERROR_CODE = "ERR_SANDBOX_PAYLOAD_TOO_LARGE";
20
+ let base64TransferLimitBytes = DEFAULT_BASE64_TRANSFER_BYTES;
21
+ let jsonPayloadLimitBytes = DEFAULT_JSON_PAYLOAD_BYTES;
22
+ const encoder = new TextEncoder();
23
+ function getUtf8ByteLength(text) {
24
+ return encoder.encode(text).byteLength;
25
+ }
26
+ function assertPayloadByteLength(payloadLabel, actualBytes, maxBytes) {
27
+ if (actualBytes <= maxBytes)
28
+ return;
29
+ const error = new Error(`[${PAYLOAD_LIMIT_ERROR_CODE}] ${payloadLabel}: payload is ${actualBytes} bytes, limit is ${maxBytes} bytes`);
30
+ error.code = PAYLOAD_LIMIT_ERROR_CODE;
31
+ throw error;
32
+ }
33
+ function assertTextPayloadSize(payloadLabel, text, maxBytes) {
34
+ assertPayloadByteLength(payloadLabel, getUtf8ByteLength(text), maxBytes);
35
+ }
15
36
  const dynamicImportModule = new Function("specifier", "return import(specifier);");
16
37
  function boundErrorMessage(message) {
17
38
  if (message.length <= MAX_ERROR_MESSAGE_CHARS) {
@@ -28,6 +49,9 @@ function boundStdioMessage(message) {
28
49
  function revivePermission(source) {
29
50
  if (!source)
30
51
  return undefined;
52
+ // Validate source before eval to prevent code injection
53
+ if (!validatePermissionSource(source))
54
+ return undefined;
31
55
  try {
32
56
  const fn = new Function(`return (${source});`)();
33
57
  if (typeof fn === "function")
@@ -74,8 +98,10 @@ function makeApplyPromise(fn) {
74
98
  },
75
99
  };
76
100
  }
101
+ // Save real postMessage before sandbox code can replace it
102
+ const _realPostMessage = self.postMessage.bind(self);
77
103
  function postResponse(message) {
78
- self.postMessage(message);
104
+ _realPostMessage(message);
79
105
  }
80
106
  function postStdio(requestId, channel, message) {
81
107
  const payload = {
@@ -84,7 +110,7 @@ function postStdio(requestId, channel, message) {
84
110
  channel,
85
111
  message,
86
112
  };
87
- self.postMessage(payload);
113
+ _realPostMessage(payload);
88
114
  }
89
115
  function formatConsoleValue(value, seen = new WeakSet(), depth = 0) {
90
116
  if (value === null) {
@@ -156,6 +182,9 @@ async function initRuntime(payload) {
156
182
  if (initialized)
157
183
  return;
158
184
  permissions = revivePermissions(payload.permissions);
185
+ // Apply payload limits (use defaults if not configured)
186
+ base64TransferLimitBytes = payload.payloadLimits?.base64TransferBytes ?? DEFAULT_BASE64_TRANSFER_BYTES;
187
+ jsonPayloadLimitBytes = payload.payloadLimits?.jsonPayloadBytes ?? DEFAULT_JSON_PAYLOAD_BYTES;
159
188
  const baseFs = payload.filesystem === "memory"
160
189
  ? createInMemoryFileSystem()
161
190
  : await createOpfsFileSystem();
@@ -174,22 +203,27 @@ async function initRuntime(payload) {
174
203
  exposeCustomGlobal("_osConfig", payload.osConfig ?? {});
175
204
  // Set up filesystem bridge globals before loading runtime shims.
176
205
  const readFileRef = makeApplySyncPromise(async (path) => {
177
- return fsOps.readTextFile(path);
206
+ const text = await fsOps.readTextFile(path);
207
+ assertTextPayloadSize(`fs.readFile ${path}`, text, jsonPayloadLimitBytes);
208
+ return text;
178
209
  });
179
210
  const writeFileRef = makeApplySyncPromise(async (path, content) => {
180
211
  return fsOps.writeFile(path, content);
181
212
  });
182
213
  const readFileBinaryRef = makeApplySyncPromise(async (path) => {
183
214
  const data = await fsOps.readFile(path);
184
- return btoa(String.fromCharCode(...data));
215
+ assertPayloadByteLength(`fs.readFileBinary ${path}`, data.byteLength, base64TransferLimitBytes);
216
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
185
217
  });
186
- const writeFileBinaryRef = makeApplySyncPromise(async (path, base64) => {
187
- const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
188
- return fsOps.writeFile(path, bytes);
218
+ const writeFileBinaryRef = makeApplySyncPromise(async (path, binaryContent) => {
219
+ assertPayloadByteLength(`fs.writeFileBinary ${path}`, binaryContent.byteLength, base64TransferLimitBytes);
220
+ return fsOps.writeFile(path, binaryContent);
189
221
  });
190
222
  const readDirRef = makeApplySyncPromise(async (path) => {
191
223
  const entries = await fsOps.readDirWithTypes(path);
192
- return JSON.stringify(entries);
224
+ const json = JSON.stringify(entries);
225
+ assertTextPayloadSize(`fs.readDir ${path}`, json, jsonPayloadLimitBytes);
226
+ return json;
193
227
  });
194
228
  const mkdirRef = makeApplySyncPromise(async (path) => {
195
229
  return mkdir(fsOps, path);
@@ -358,6 +392,43 @@ async function initRuntime(payload) {
358
392
  exposeMutableRuntimeStateGlobal("_pendingModules", {});
359
393
  exposeMutableRuntimeStateGlobal("_currentModule", { dirname: "/" });
360
394
  eval(getRequireSetupCode());
395
+ // Block dangerous Web APIs that bypass bridge permission checks
396
+ const dangerousApis = [
397
+ "XMLHttpRequest",
398
+ "WebSocket",
399
+ "importScripts",
400
+ "indexedDB",
401
+ "caches",
402
+ "BroadcastChannel",
403
+ ];
404
+ for (const api of dangerousApis) {
405
+ try {
406
+ delete self[api];
407
+ }
408
+ catch {
409
+ // May not exist or may be non-configurable
410
+ }
411
+ Object.defineProperty(self, api, {
412
+ get() {
413
+ throw new ReferenceError(`${api} is not available in sandbox`);
414
+ },
415
+ configurable: false,
416
+ });
417
+ }
418
+ // Lock down self.onmessage so sandbox code cannot hijack the control channel
419
+ const currentHandler = self.onmessage;
420
+ Object.defineProperty(self, "onmessage", {
421
+ value: currentHandler,
422
+ writable: false,
423
+ configurable: false,
424
+ });
425
+ // Block self.postMessage so sandbox code cannot forge responses to host
426
+ Object.defineProperty(self, "postMessage", {
427
+ get() {
428
+ throw new TypeError("postMessage is not available in sandbox");
429
+ },
430
+ configurable: false,
431
+ });
361
432
  initialized = true;
362
433
  }
363
434
  function resetModuleState(cwd) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@secure-exec/browser",
3
- "version": "0.1.0",
3
+ "version": "0.1.1-rc.1",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "main": "./dist/index.js",
@@ -34,11 +34,16 @@
34
34
  "types": "./dist/worker-protocol.d.ts",
35
35
  "import": "./dist/worker-protocol.js",
36
36
  "default": "./dist/worker-protocol.js"
37
+ },
38
+ "./internal/permission-validation": {
39
+ "types": "./dist/permission-validation.d.ts",
40
+ "import": "./dist/permission-validation.js",
41
+ "default": "./dist/permission-validation.js"
37
42
  }
38
43
  },
39
44
  "dependencies": {
40
45
  "sucrase": "^3.35.0",
41
- "@secure-exec/core": "0.1.0"
46
+ "@secure-exec/core": "0.1.1-rc.1"
42
47
  },
43
48
  "devDependencies": {
44
49
  "@types/node": "^22.10.2",