@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
|
+
}
|
package/dist/runtime-driver.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
187
|
-
|
|
188
|
-
return fsOps.writeFile(path,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
46
|
+
"@secure-exec/core": "0.1.1-rc.1"
|
|
42
47
|
},
|
|
43
48
|
"devDependencies": {
|
|
44
49
|
"@types/node": "^22.10.2",
|