diff --git a/cli/dts/lib.deno.shared_globals.d.ts b/cli/dts/lib.deno.shared_globals.d.ts index e4d763ec0f..74abbc95b6 100644 --- a/cli/dts/lib.deno.shared_globals.d.ts +++ b/cli/dts/lib.deno.shared_globals.d.ts @@ -662,24 +662,33 @@ declare class Worker extends EventTarget { options?: { type?: "classic" | "module"; name?: string; - /** UNSTABLE: New API. Expect many changes; most likely this - * field will be made into an object for more granular - * configuration of worker thread (permissions, import map, etc.). + /** UNSTABLE: New API. * - * Set to `true` to make `Deno` namespace and all of its methods - * available to worker thread. - * - * Currently worker inherits permissions from main thread (permissions - * given using `--allow-*` flags). - * Configurable permissions are on the roadmap to be implemented. + * Set deno.namespace to `true` to make `Deno` namespace and all of its methods + * available to worker thread. The namespace is disabled by default. + * + * Configure deno.permissions options to change the level of access the worker will + * have. By default it will inherit the permissions of its parent thread. The permissions + * of a worker can't be extended beyond its parent's permissions reach. + * - "inherit" will take the permissions of the thread the worker is created in + * - You can disable/enable permissions all together by passing a boolean + * - You can provide a list of routes relative to the file the worker + * is created in to limit the access of the worker (read/write permissions only) * * Example: * * ```ts * // mod.ts * const worker = new Worker( - * new URL("deno_worker.ts", import.meta.url).href, - * { type: "module", deno: true } + * new URL("deno_worker.ts", import.meta.url).href, { + * type: "module", + * deno: { + * namespace: true, + * permissions: { + * read: true, + * }, + * }, + * } * ); * worker.postMessage({ cmd: "readFile", fileName: "./log.txt" }); * @@ -707,7 +716,30 @@ declare class Worker extends EventTarget { * hello world2 * */ - deno?: boolean; + // TODO(Soremwar) + // `deno: true` is kept for backwards compatibility with the previous worker + // options implementation. Remove for 2.0 + deno?: true | { + namespace?: boolean; + /** Set to false to disable all the permissions in the worker */ + permissions?: "inherit" | false | { + env?: "inherit" | boolean; + hrtime?: "inherit" | boolean; + /** + * The format of the net access list must be `hostname[:port]` + * in order to be resolved + * + * ``` + * net: ["https://deno.land", "localhost:8080"], + * ``` + * */ + net?: "inherit" | boolean | string[]; + plugin?: "inherit" | boolean; + read?: "inherit" | boolean | Array; + run?: "inherit" | boolean; + write?: "inherit" | boolean | Array; + }; + }; }, ); postMessage(message: any, transfer: ArrayBuffer[]): void; diff --git a/cli/main.rs b/cli/main.rs index b622cccf3b..a247389d9e 100644 --- a/cli/main.rs +++ b/cli/main.rs @@ -100,7 +100,10 @@ fn create_web_worker_callback( || program_state.coverage_dir.is_some(); let maybe_inspector_server = program_state.maybe_inspector_server.clone(); - let module_loader = CliModuleLoader::new_for_worker(program_state.clone()); + let module_loader = CliModuleLoader::new_for_worker( + program_state.clone(), + args.parent_permissions.clone(), + ); let create_web_worker_cb = create_web_worker_callback(program_state.clone()); diff --git a/cli/module_loader.rs b/cli/module_loader.rs index aab951c4a5..c91a2336b8 100644 --- a/cli/module_loader.rs +++ b/cli/module_loader.rs @@ -22,6 +22,10 @@ pub struct CliModuleLoader { /// import map file will be resolved and set. pub import_map: Option, pub lib: TypeLib, + /// The initial set of permissions used to resolve the imports in the worker. + /// They are decoupled from the worker permissions since read access errors + /// must be raised based on the parent thread permissions + pub initial_permissions: Rc>>, pub program_state: Arc, } @@ -38,11 +42,15 @@ impl CliModuleLoader { Rc::new(CliModuleLoader { import_map, lib, + initial_permissions: Rc::new(RefCell::new(None)), program_state, }) } - pub fn new_for_worker(program_state: Arc) -> Rc { + pub fn new_for_worker( + program_state: Arc, + permissions: Permissions, + ) -> Rc { let lib = if program_state.flags.unstable { TypeLib::UnstableDenoWorker } else { @@ -52,6 +60,7 @@ impl CliModuleLoader { Rc::new(CliModuleLoader { import_map: None, lib, + initial_permissions: Rc::new(RefCell::new(Some(permissions))), program_state, }) } @@ -118,7 +127,16 @@ impl ModuleLoader for CliModuleLoader { let state = op_state.borrow(); // The permissions that should be applied to any dynamically imported module - let dynamic_permissions = state.borrow::().clone(); + let dynamic_permissions = + // If there are initial permissions assigned to the loader take them + // and use only once for top level module load. + // Otherwise use permissions assigned to the current worker. + if let Some(permissions) = self.initial_permissions.borrow_mut().take() { + permissions + } else { + state.borrow::().clone() + }; + let lib = self.lib.clone(); drop(state); diff --git a/cli/tests/unstable_worker.ts b/cli/tests/unstable_worker.ts index 6b5304edf1..429754dfed 100644 --- a/cli/tests/unstable_worker.ts +++ b/cli/tests/unstable_worker.ts @@ -1,8 +1,10 @@ const w = new Worker( - new URL("subdir/worker_unstable.ts", import.meta.url).href, + new URL("workers/worker_unstable.ts", import.meta.url).href, { type: "module", - deno: true, + deno: { + namespace: true, + }, name: "Unstable Worker", }, ); diff --git a/cli/tests/subdir/bench_worker.ts b/cli/tests/workers/bench_worker.ts similarity index 100% rename from cli/tests/subdir/bench_worker.ts rename to cli/tests/workers/bench_worker.ts diff --git a/cli/tests/subdir/busy_worker.js b/cli/tests/workers/busy_worker.js similarity index 100% rename from cli/tests/subdir/busy_worker.js rename to cli/tests/workers/busy_worker.js diff --git a/cli/tests/subdir/deno_worker.ts b/cli/tests/workers/deno_worker.ts similarity index 100% rename from cli/tests/subdir/deno_worker.ts rename to cli/tests/workers/deno_worker.ts diff --git a/cli/tests/subdir/event_worker.js b/cli/tests/workers/event_worker.js similarity index 100% rename from cli/tests/subdir/event_worker.js rename to cli/tests/workers/event_worker.js diff --git a/cli/tests/subdir/event_worker_scope.js b/cli/tests/workers/event_worker_scope.js similarity index 100% rename from cli/tests/subdir/event_worker_scope.js rename to cli/tests/workers/event_worker_scope.js diff --git a/cli/tests/subdir/fetching_worker.js b/cli/tests/workers/fetching_worker.js similarity index 53% rename from cli/tests/subdir/fetching_worker.js rename to cli/tests/workers/fetching_worker.js index 3e33d1c9e7..e1bcdf9113 100644 --- a/cli/tests/subdir/fetching_worker.js +++ b/cli/tests/workers/fetching_worker.js @@ -1,5 +1,5 @@ const r = await fetch( - "http://localhost:4545/cli/tests/subdir/fetching_worker.js", + "http://localhost:4545/cli/tests/workers/fetching_worker.js", ); await r.text(); postMessage("Done!"); diff --git a/cli/tests/immediately_close_worker.js b/cli/tests/workers/immediately_close_worker.js similarity index 100% rename from cli/tests/immediately_close_worker.js rename to cli/tests/workers/immediately_close_worker.js diff --git a/cli/tests/subdir/nested_worker.js b/cli/tests/workers/nested_worker.js similarity index 100% rename from cli/tests/subdir/nested_worker.js rename to cli/tests/workers/nested_worker.js diff --git a/cli/tests/workers/no_permissions_worker.js b/cli/tests/workers/no_permissions_worker.js new file mode 100644 index 0000000000..8a4f79d57f --- /dev/null +++ b/cli/tests/workers/no_permissions_worker.js @@ -0,0 +1,17 @@ +self.onmessage = async () => { + const hrtime = await Deno.permissions.query({ name: "hrtime" }); + const net = await Deno.permissions.query({ name: "net" }); + const plugin = await Deno.permissions.query({ name: "plugin" }); + const read = await Deno.permissions.query({ name: "read" }); + const run = await Deno.permissions.query({ name: "run" }); + const write = await Deno.permissions.query({ name: "write" }); + self.postMessage( + hrtime.state === "denied" && + net.state === "denied" && + plugin.state === "denied" && + read.state === "denied" && + run.state === "denied" && + write.state === "denied", + ); + self.close(); +}; diff --git a/cli/tests/subdir/non_deno_worker.js b/cli/tests/workers/non_deno_worker.js similarity index 100% rename from cli/tests/subdir/non_deno_worker.js rename to cli/tests/workers/non_deno_worker.js diff --git a/cli/tests/workers/parent_read_check_granular_worker.js b/cli/tests/workers/parent_read_check_granular_worker.js new file mode 100644 index 0000000000..1a7182e17b --- /dev/null +++ b/cli/tests/workers/parent_read_check_granular_worker.js @@ -0,0 +1,43 @@ +import { fromFileUrl } from "../../../std/path/mod.ts"; + +const worker = new Worker( + new URL("./read_check_granular_worker.js", import.meta.url).href, + { + type: "module", + deno: { + namespace: true, + permissions: { + read: [], + }, + }, + }, +); + +let received = 0; +const messages = []; + +worker.onmessage = ({ data: childResponse }) => { + received++; + postMessage({ + childHasPermission: childResponse.hasPermission, + index: childResponse.index, + parentHasPermission: messages[childResponse.index], + }); + if (received === messages.length) { + worker.terminate(); + } +}; + +onmessage = async ({ data }) => { + const { state } = await Deno.permissions.query({ + name: "read", + path: fromFileUrl(new URL(data.route, import.meta.url)), + }); + + messages[data.index] = state === "granted"; + + worker.postMessage({ + index: data.index, + route: data.route, + }); +}; diff --git a/cli/tests/workers/parent_read_check_worker.js b/cli/tests/workers/parent_read_check_worker.js new file mode 100644 index 0000000000..ec92cca3fb --- /dev/null +++ b/cli/tests/workers/parent_read_check_worker.js @@ -0,0 +1,27 @@ +onmessage = async () => { + const { state } = await Deno.permissions.query({ + name: "read", + }); + + const worker = new Worker( + new URL("./read_check_worker.js", import.meta.url).href, + { + type: "module", + deno: { + namespace: true, + permissions: { + read: false, + }, + }, + }, + ); + + worker.onmessage = ({ data: childHasPermission }) => { + postMessage({ + parentHasPermission: state === "granted", + childHasPermission, + }); + close(); + }; + worker.postMessage(null); +}; diff --git a/cli/tests/subdir/racy_worker.js b/cli/tests/workers/racy_worker.js similarity index 100% rename from cli/tests/subdir/racy_worker.js rename to cli/tests/workers/racy_worker.js diff --git a/cli/tests/workers/read_check_granular_worker.js b/cli/tests/workers/read_check_granular_worker.js new file mode 100644 index 0000000000..4eddb7a758 --- /dev/null +++ b/cli/tests/workers/read_check_granular_worker.js @@ -0,0 +1,13 @@ +import { fromFileUrl } from "../../../std/path/mod.ts"; + +onmessage = async ({ data }) => { + const { state } = await Deno.permissions.query({ + name: "read", + path: fromFileUrl(new URL(data.route, import.meta.url)), + }); + + postMessage({ + hasPermission: state === "granted", + index: data.index, + }); +}; diff --git a/cli/tests/workers/read_check_worker.js b/cli/tests/workers/read_check_worker.js new file mode 100644 index 0000000000..2ad01bf5b5 --- /dev/null +++ b/cli/tests/workers/read_check_worker.js @@ -0,0 +1,7 @@ +onmessage = async () => { + const { state } = await Deno.permissions.query({ + name: "read", + }); + postMessage(state === "granted"); + close(); +}; diff --git a/cli/tests/subdir/sibling_worker.js b/cli/tests/workers/sibling_worker.js similarity index 100% rename from cli/tests/subdir/sibling_worker.js rename to cli/tests/workers/sibling_worker.js diff --git a/cli/tests/subdir/test_worker.js b/cli/tests/workers/test_worker.js similarity index 100% rename from cli/tests/subdir/test_worker.js rename to cli/tests/workers/test_worker.js diff --git a/cli/tests/subdir/test_worker.ts b/cli/tests/workers/test_worker.ts similarity index 100% rename from cli/tests/subdir/test_worker.ts rename to cli/tests/workers/test_worker.ts diff --git a/cli/tests/subdir/throwing_worker.js b/cli/tests/workers/throwing_worker.js similarity index 100% rename from cli/tests/subdir/throwing_worker.js rename to cli/tests/workers/throwing_worker.js diff --git a/cli/tests/subdir/worker_crypto.js b/cli/tests/workers/worker_crypto.js similarity index 100% rename from cli/tests/subdir/worker_crypto.js rename to cli/tests/workers/worker_crypto.js diff --git a/cli/tests/subdir/worker_globals.ts b/cli/tests/workers/worker_globals.ts similarity index 100% rename from cli/tests/subdir/worker_globals.ts rename to cli/tests/workers/worker_globals.ts diff --git a/cli/tests/subdir/worker_unstable.ts b/cli/tests/workers/worker_unstable.ts similarity index 100% rename from cli/tests/subdir/worker_unstable.ts rename to cli/tests/workers/worker_unstable.ts diff --git a/cli/tests/workers_round_robin_bench.ts b/cli/tests/workers_round_robin_bench.ts index 8467480b86..461e86f915 100644 --- a/cli/tests/workers_round_robin_bench.ts +++ b/cli/tests/workers_round_robin_bench.ts @@ -22,7 +22,7 @@ async function main(): Promise { const workers: Array<[Map>, Worker]> = []; for (let i = 1; i <= workerCount; ++i) { const worker = new Worker( - new URL("subdir/bench_worker.ts", import.meta.url).href, + new URL("workers/bench_worker.ts", import.meta.url).href, { type: "module" }, ); const promise = deferred(); diff --git a/cli/tests/workers_startup_bench.ts b/cli/tests/workers_startup_bench.ts index 0d58b7912f..f85bd13a17 100644 --- a/cli/tests/workers_startup_bench.ts +++ b/cli/tests/workers_startup_bench.ts @@ -5,7 +5,7 @@ async function bench(): Promise { const workers: Worker[] = []; for (let i = 1; i <= workerCount; ++i) { const worker = new Worker( - new URL("subdir/bench_worker.ts", import.meta.url).href, + new URL("workers/bench_worker.ts", import.meta.url).href, { type: "module" }, ); const promise = new Promise((resolve): void => { diff --git a/cli/tests/workers_test.ts b/cli/tests/workers_test.ts index 3bfe0181a9..2dfc7e26b0 100644 --- a/cli/tests/workers_test.ts +++ b/cli/tests/workers_test.ts @@ -2,12 +2,12 @@ // Requires to be run with `--allow-net` flag -// FIXME(bartlomieju): this file is an integration test only because -// workers are leaking ops at the moment - `worker.terminate()` is not -// yet implemented. Once it gets implemented this file should be -// again moved to `cli/js/` as an unit test file. - -import { assert, assertEquals } from "../../std/testing/asserts.ts"; +import { + assert, + assertEquals, + assertThrows, + fail, +} from "../../std/testing/asserts.ts"; import { deferred } from "../../std/async/deferred.ts"; Deno.test({ @@ -16,11 +16,11 @@ Deno.test({ const promise = deferred(); const jsWorker = new Worker( - new URL("subdir/test_worker.js", import.meta.url).href, + new URL("workers/test_worker.js", import.meta.url).href, { type: "module" }, ); const tsWorker = new Worker( - new URL("subdir/test_worker.ts", import.meta.url).href, + new URL("workers/test_worker.ts", import.meta.url).href, { type: "module", name: "tsWorker" }, ); @@ -73,7 +73,7 @@ Deno.test({ const promise = deferred(); const nestedWorker = new Worker( - new URL("subdir/nested_worker.js", import.meta.url).href, + new URL("workers/nested_worker.js", import.meta.url).href, { type: "module", name: "nested" }, ); @@ -93,7 +93,7 @@ Deno.test({ fn: async function (): Promise { const promise = deferred(); const throwingWorker = new Worker( - new URL("subdir/throwing_worker.js", import.meta.url).href, + new URL("workers/throwing_worker.js", import.meta.url).href, { type: "module" }, ); @@ -114,7 +114,7 @@ Deno.test({ fn: async function (): Promise { const promise = deferred(); const w = new Worker( - new URL("subdir/worker_globals.ts", import.meta.url).href, + new URL("workers/worker_globals.ts", import.meta.url).href, { type: "module" }, ); w.onmessage = (e): void => { @@ -133,7 +133,7 @@ Deno.test({ const promise = deferred(); const fetchingWorker = new Worker( - new URL("subdir/fetching_worker.js", import.meta.url).href, + new URL("workers/fetching_worker.js", import.meta.url).href, { type: "module" }, ); @@ -160,7 +160,7 @@ Deno.test({ const promise = deferred(); const busyWorker = new Worker( - new URL("subdir/busy_worker.js", import.meta.url).href, + new URL("workers/busy_worker.js", import.meta.url).href, { type: "module" }, ); @@ -193,7 +193,7 @@ Deno.test({ const promise = deferred(); const racyWorker = new Worker( - new URL("subdir/racy_worker.js", import.meta.url).href, + new URL("workers/racy_worker.js", import.meta.url).href, { type: "module" }, ); @@ -221,7 +221,7 @@ Deno.test({ const promise2 = deferred(); const worker = new Worker( - new URL("subdir/event_worker.js", import.meta.url).href, + new URL("workers/event_worker.js", import.meta.url).href, { type: "module" }, ); @@ -265,7 +265,7 @@ Deno.test({ const promise1 = deferred(); const worker = new Worker( - new URL("subdir/event_worker_scope.js", import.meta.url).href, + new URL("workers/event_worker_scope.js", import.meta.url).href, { type: "module" }, ); @@ -294,12 +294,18 @@ Deno.test({ const promise2 = deferred(); const regularWorker = new Worker( - new URL("subdir/non_deno_worker.js", import.meta.url).href, + new URL("workers/non_deno_worker.js", import.meta.url).href, { type: "module" }, ); const denoWorker = new Worker( - new URL("subdir/deno_worker.ts", import.meta.url).href, - { type: "module", deno: true }, + new URL("workers/deno_worker.ts", import.meta.url).href, + { + type: "module", + deno: { + namespace: true, + permissions: "inherit", + }, + }, ); regularWorker.onmessage = (e): void => { @@ -326,7 +332,7 @@ Deno.test({ fn: async function (): Promise { const promise = deferred(); const w = new Worker( - new URL("subdir/worker_crypto.js", import.meta.url).href, + new URL("workers/worker_crypto.js", import.meta.url).href, { type: "module" }, ); w.onmessage = (e): void => { @@ -344,7 +350,7 @@ Deno.test({ fn: async function (): Promise { const promise = deferred(); const w = new Worker( - new URL("subdir/test_worker.ts", import.meta.url).href, + new URL("workers/test_worker.ts", import.meta.url).href, { type: "module", name: "tsWorker" }, ); const arr: number[] = []; @@ -368,7 +374,7 @@ Deno.test({ fn: async function (): Promise { const promise = deferred(); const w = new Worker( - new URL("./immediately_close_worker.js", import.meta.url).href, + new URL("./workers/immediately_close_worker.js", import.meta.url).href, { type: "module" }, ); setTimeout(() => { @@ -378,3 +384,233 @@ Deno.test({ w.terminate(); }, }); + +Deno.test("Worker inherits permissions", async function () { + const promise = deferred(); + const worker = new Worker( + new URL("./workers/read_check_worker.js", import.meta.url).href, + { + type: "module", + deno: { + namespace: true, + permissions: "inherit", + }, + }, + ); + + worker.onmessage = ({ data: hasPermission }) => { + assert(hasPermission); + promise.resolve(); + }; + + worker.postMessage(null); + + await promise; + worker.terminate(); +}); + +Deno.test("Worker limit children permissions", async function () { + const promise = deferred(); + const worker = new Worker( + new URL("./workers/read_check_worker.js", import.meta.url).href, + { + type: "module", + deno: { + namespace: true, + permissions: { + read: false, + }, + }, + }, + ); + + worker.onmessage = ({ data: hasPermission }) => { + assert(!hasPermission); + promise.resolve(); + }; + + worker.postMessage(null); + + await promise; + worker.terminate(); +}); + +Deno.test("Worker limit children permissions granularly", async function () { + const promise = deferred(); + const worker = new Worker( + new URL("./workers/read_check_granular_worker.js", import.meta.url).href, + { + type: "module", + deno: { + namespace: true, + permissions: { + read: [ + new URL("./workers/read_check_worker.js", import.meta.url), + ], + }, + }, + }, + ); + + //Routes are relative to the spawned worker location + const routes = [ + { permission: false, route: "read_check_granular_worker.js" }, + { permission: true, route: "read_check_worker.js" }, + ]; + + let checked = 0; + worker.onmessage = ({ data }) => { + checked++; + assertEquals(data.hasPermission, routes[data.index].permission); + routes.shift(); + if (checked === routes.length) { + promise.resolve(); + } + }; + + routes.forEach(({ route }, index) => + worker.postMessage({ + index, + route, + }) + ); + + await promise; + worker.terminate(); +}); + +Deno.test("Nested worker limit children permissions", async function () { + const promise = deferred(); + + /** This worker has read permissions but doesn't grant them to its children */ + const worker = new Worker( + new URL("./workers/parent_read_check_worker.js", import.meta.url).href, + { + type: "module", + deno: { + namespace: true, + permissions: "inherit", + }, + }, + ); + + worker.onmessage = ({ data }) => { + assert(data.parentHasPermission); + assert(!data.childHasPermission); + promise.resolve(); + }; + + worker.postMessage(null); + + await promise; + worker.terminate(); +}); + +Deno.test("Nested worker limit children permissions granularly", async function () { + const promise = deferred(); + + /** This worker has read permissions but doesn't grant them to its children */ + const worker = new Worker( + new URL("./workers/parent_read_check_granular_worker.js", import.meta.url) + .href, + { + type: "module", + deno: { + namespace: true, + permissions: { + read: [ + new URL("./workers/read_check_granular_worker.js", import.meta.url), + ], + }, + }, + }, + ); + + //Routes are relative to the spawned worker location + const routes = [ + { + childHasPermission: false, + parentHasPermission: true, + route: "read_check_granular_worker.js", + }, + { + childHasPermission: false, + parentHasPermission: false, + route: "read_check_worker.js", + }, + ]; + + let checked = 0; + worker.onmessage = ({ data }) => { + checked++; + assertEquals( + data.childHasPermission, + routes[data.index].childHasPermission, + ); + assertEquals( + data.parentHasPermission, + routes[data.index].parentHasPermission, + ); + if (checked === routes.length) { + promise.resolve(); + } + }; + + // Index needed cause requests will be handled asynchronously + routes.forEach(({ route }, index) => + worker.postMessage({ + index, + route, + }) + ); + + await promise; + worker.terminate(); +}); + +// This test relies on env permissions not being granted on main thread +Deno.test("Worker initialization throws on worker permissions greater than parent thread permissions", function () { + assertThrows( + () => { + const worker = new Worker( + new URL("./workers/deno_worker.ts", import.meta.url).href, + { + type: "module", + deno: { + namespace: true, + permissions: { + env: true, + }, + }, + }, + ); + worker.terminate(); + }, + Deno.errors.PermissionDenied, + "Can't escalate parent thread permissions", + ); +}); + +Deno.test("Worker with disabled permissions", async function () { + const promise = deferred(); + + const worker = new Worker( + new URL("./workers/no_permissions_worker.js", import.meta.url).href, + { + type: "module", + deno: { + namespace: true, + permissions: false, + }, + }, + ); + + worker.onmessage = ({ data: sandboxed }) => { + assert(sandboxed); + promise.resolve(); + }; + + worker.postMessage(null); + await promise; + worker.terminate(); +}); diff --git a/docs/runtime/workers.md b/docs/runtime/workers.md index 5dcf059155..caed638b0e 100644 --- a/docs/runtime/workers.md +++ b/docs/runtime/workers.md @@ -16,15 +16,15 @@ specifier for some nearby script. ```ts // Good -new Worker(new URL("worker.js", import.meta.url).href, { type: "module" }); +new Worker(new URL("./worker.js", import.meta.url).href, { type: "module" }); // Bad -new Worker(new URL("worker.js", import.meta.url).href); -new Worker(new URL("worker.js", import.meta.url).href, { type: "classic" }); +new Worker(new URL("./worker.js", import.meta.url).href); +new Worker(new URL("./worker.js", import.meta.url).href, { type: "classic" }); new Worker("./worker.js", { type: "module" }); ``` -### Permissions +### Instantiation permissions Creating a new `Worker` instance is similar to a dynamic import; therefore Deno requires appropriate permission for this action. @@ -34,7 +34,7 @@ For workers using local modules; `--allow-read` permission is required: **main.ts** ```ts -new Worker(new URL("worker.ts", import.meta.url).href, { type: "module" }); +new Worker(new URL("./worker.ts", import.meta.url).href, { type: "module" }); ``` **worker.ts** @@ -82,14 +82,17 @@ hello world By default the `Deno` namespace is not available in worker scope. -To add the `Deno` namespace pass `deno: true` option when creating new worker: +To enable the `Deno` namespace pass `deno.namespace = true` option when creating +new worker: **main.js** ```ts -const worker = new Worker(new URL("worker.js", import.meta.url).href, { +const worker = new Worker(new URL("./worker.js", import.meta.url).href, { type: "module", - deno: true, + deno: { + namespace: true, + }, }); worker.postMessage({ filename: "./log.txt" }); ``` @@ -116,7 +119,129 @@ $ deno run --allow-read --unstable main.js hello world ``` -When the `Deno` namespace is available in worker scope, the worker inherits its -parent process' permissions (the ones specified using `--allow-*` flags). +### Specifying worker permissions -We intend to make permissions configurable for workers. +> This is an unstable Deno feature. Learn more about +> [unstable features](./stability.md). + +The permissions available for the worker are analogous to the CLI permission +flags, meaning every permission enabled there can be disabled at the level of +the Worker API. You can find a more detailed description of each of the +permission options [here](../getting_started/permissions.md). + +By default a worker will inherit permissions from the thread it was created in, +however in order to allow users to limit the access of this worker we provide +the `deno.permissions` option in the worker API. + +- For permissions that support granular access you can pass in a list of the + desired resources the worker will have access to, and for those who only have + the on/off option you can pass true/false respectively. + + ```ts + const worker = new Worker(new URL("./worker.js", import.meta.url).href, { + type: "module", + deno: { + namespace: true, + permissions: [ + net: [ + "https://deno.land/", + ], + read: [ + new URL("./file_1.txt", import.meta.url), + new URL("./file_2.txt", import.meta.url), + ], + write: false, + ], + }, + }); + ``` + +- Granular access permissions receive both absolute and relative routes as + arguments, however take into account that relative routes will be resolved + relative to the file the worker is instantiated in, not the path the worker + file is currently in + + ```ts + const worker = new Worker(new URL("./worker/worker.js", import.meta.url).href, { + type: "module", + deno: { + namespace: true, + permissions: [ + read: [ + "/home/user/Documents/deno/worker/file_1.txt", + "./worker/file_2.txt", + ], + ], + }, + }); + ``` + +- Both `deno.permissions` and its children support the option `"inherit"`, which + implies it will borrow its parent permissions. + + ```ts + // This worker will inherit its parent permissions + const worker = new Worker(new URL("./worker.js", import.meta.url).href, { + type: "module", + deno: { + namespace: true, + permissions: "inherit", + }, + }); + ``` + + ```ts + // This worker will inherit only the net permissions of its parent + const worker = new Worker(new URL("./worker.js", import.meta.url).href, { + type: "module", + deno: { + namespace: true, + permissions: { + env: false, + hrtime: false, + net: "inherit", + plugin: false, + read: false, + run: false, + write: false, + }, + }, + }); + ``` + +- Not specifying the `deno.permissions` option or one of its children will cause + the worker to inherit by default. + + ```ts + // This worker will inherit its parent permissions + const worker = new Worker(new URL("./worker.js", import.meta.url).href, { + type: "module", + }); + ``` + + ```ts + // This worker will inherit all the permissions of its parent BUT net + const worker = new Worker(new URL("./worker.js", import.meta.url).href, { + type: "module", + deno: { + namespace: true, + permissions: { + net: false, + }, + }, + }); + ``` + +- You can disable the permissions of the worker all together by passing false to + the `deno.permissions` option. + + ```ts + // This worker will not have any permissions enabled + const worker = new Worker(new URL("./worker.js", import.meta.url).href, { + type: "module", + deno: { + namespace: true, + permissions: false, + }, + }); + ``` diff --git a/runtime/js/11_workers.js b/runtime/js/11_workers.js index 62210dfaed..db0bbc3c72 100644 --- a/runtime/js/11_workers.js +++ b/runtime/js/11_workers.js @@ -3,21 +3,24 @@ ((window) => { const core = window.Deno.core; const { Window } = window.__bootstrap.globalInterfaces; - const { log } = window.__bootstrap.util; + const { log, pathFromURL } = window.__bootstrap.util; const { defineEventHandler } = window.__bootstrap.webUtil; + const build = window.__bootstrap.build.build; function createWorker( specifier, hasSourceCode, sourceCode, useDenoNamespace, + permissions, name, ) { return core.jsonOpSync("op_create_worker", { - specifier, hasSourceCode, - sourceCode, name, + permissions, + sourceCode, + specifier, useDenoNamespace, }); } @@ -47,14 +50,122 @@ return JSON.parse(dataJson); } + /** + * @param {string} permission + * @return {boolean} + */ + function parseBooleanPermission( + value, + permission, + ) { + if (value !== "inherit" && typeof value !== "boolean") { + throw new Error( + `Expected 'boolean' for ${permission} permission, ${typeof value} received`, + ); + } + return value === "inherit" ? undefined : value; + } + + /** + * @param {string} permission + * @return {(boolean | string[])} + * */ + function parseArrayPermission( + value, + permission, + ) { + if (typeof value === "string") { + if (value !== "inherit") { + throw new Error( + `Expected 'array' or 'boolean' for ${permission} permission, "${value}" received`, + ); + } + } else if (!Array.isArray(value) && typeof value !== "boolean") { + throw new Error( + `Expected 'array' or 'boolean' for ${permission} permission, ${typeof value} received`, + ); + //Casts URLs to absolute routes + } else if (Array.isArray(value)) { + value = value.map((route) => { + if (route instanceof URL) { + route = pathFromURL(route); + } + return route; + }); + } + + return value === "inherit" ? undefined : value; + } + + /** + * Normalizes data, runs checks on parameters and deletes inherited permissions + */ + function parsePermissions({ + env = "inherit", + hrtime = "inherit", + net = "inherit", + plugin = "inherit", + read = "inherit", + run = "inherit", + write = "inherit", + }) { + return { + env: parseBooleanPermission(env, "env"), + hrtime: parseBooleanPermission(hrtime, "hrtime"), + net: parseArrayPermission(net, "net"), + plugin: parseBooleanPermission(plugin, "plugin"), + read: parseArrayPermission(read, "read"), + run: parseBooleanPermission(run, "run"), + write: parseArrayPermission(write, "write"), + }; + } + class Worker extends EventTarget { #id = 0; #name = ""; #terminated = false; - constructor(specifier, options) { + constructor(specifier, options = {}) { super(); - const { type = "classic", name = "unknown" } = options ?? {}; + const { + deno = {}, + name = "unknown", + type = "classic", + } = options; + + // TODO(Soremwar) + // `deno: true` is kept for backwards compatibility with the previous worker + // options implementation. Remove for 2.0 + let workerDenoAttributes; + if (deno === true) { + workerDenoAttributes = { + // Change this to enable the Deno namespace by default + namespace: deno, + permissions: null, + }; + } else { + workerDenoAttributes = { + // Change this to enable the Deno namespace by default + namespace: !!(deno?.namespace ?? false), + permissions: (deno?.permissions ?? "inherit") === "inherit" + ? null + : deno?.permissions, + }; + + // If the permission option is set to false, all permissions + // must be removed from the worker + if (workerDenoAttributes.permissions === false) { + workerDenoAttributes.permissions = { + env: false, + hrtime: false, + net: false, + plugin: false, + read: false, + run: false, + write: false, + }; + } + } if (type !== "module") { throw new Error( @@ -66,13 +177,14 @@ const hasSourceCode = false; const sourceCode = decoder.decode(new Uint8Array()); - const useDenoNamespace = options ? !!options.deno : false; - const { id } = createWorker( specifier, hasSourceCode, sourceCode, - useDenoNamespace, + workerDenoAttributes.namespace, + workerDenoAttributes.permissions === null + ? null + : parsePermissions(workerDenoAttributes.permissions), options?.name, ); this.#id = id; diff --git a/runtime/ops/worker_host.rs b/runtime/ops/worker_host.rs index 871e4b9fe4..da00c6e6e5 100644 --- a/runtime/ops/worker_host.rs +++ b/runtime/ops/worker_host.rs @@ -1,14 +1,22 @@ // Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. +use crate::permissions::resolve_fs_allowlist; +use crate::permissions::PermissionState; use crate::permissions::Permissions; +use crate::permissions::UnaryPermission; use crate::web_worker::run_web_worker; use crate::web_worker::WebWorker; use crate::web_worker::WebWorkerHandle; use crate::web_worker::WorkerEvent; +use deno_core::error::custom_error; use deno_core::error::generic_error; use deno_core::error::AnyError; use deno_core::error::JsError; use deno_core::futures::channel::mpsc; +use deno_core::serde::de; +use deno_core::serde::de::SeqAccess; +use deno_core::serde::Deserialize; +use deno_core::serde::Deserializer; use deno_core::serde_json; use deno_core::serde_json::json; use deno_core::serde_json::Value; @@ -16,10 +24,12 @@ use deno_core::BufVec; use deno_core::ModuleSpecifier; use deno_core::OpState; use deno_core::ZeroCopyBuf; -use serde::Deserialize; use std::cell::RefCell; use std::collections::HashMap; +use std::collections::HashSet; use std::convert::From; +use std::fmt; +use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; use std::thread::JoinHandle; @@ -27,6 +37,7 @@ use std::thread::JoinHandle; pub struct CreateWebWorkerArgs { pub name: String, pub worker_id: u32, + pub parent_permissions: Permissions, pub permissions: Permissions, pub main_module: ModuleSpecifier, pub use_deno_namespace: bool, @@ -47,6 +58,14 @@ struct HostUnhandledErrorArgs { message: String, } +pub struct WorkerThread { + join_handle: JoinHandle>, + worker_handle: WebWorkerHandle, +} + +pub type WorkersTable = HashMap; +pub type WorkerId = u32; + pub fn init( rt: &mut deno_core::JsRuntime, sender: Option>, @@ -86,21 +105,348 @@ pub fn init( ); } -pub struct WorkerThread { - join_handle: JoinHandle>, - worker_handle: WebWorkerHandle, +fn merge_permission_state( + target: &PermissionState, + incoming: Option, +) -> Result { + match target { + PermissionState::Granted => match incoming { + Some(x) => Ok(x), + None => Ok(*target), + }, + _ => match incoming { + Some(x) => match x { + PermissionState::Denied => Ok(x), + _ => Err(custom_error( + "PermissionDenied", + "Can't escalate parent thread permissions", + )), + }, + None => Ok(*target), + }, + } } -pub type WorkersTable = HashMap; -pub type WorkerId = u32; +fn check_net_permission_contains( + a: &HashSet, + b: &HashSet, +) -> bool { + b.iter().all(|x| a.contains(x)) +} + +fn merge_net_permissions( + target: &UnaryPermission, + incoming: Option>, +) -> Result, AnyError> { + if incoming.is_none() { + return Ok(target.clone()); + }; + + let new_permissions = incoming.unwrap(); + match &target.global_state { + PermissionState::Granted => Ok(UnaryPermission:: { + global_state: new_permissions.global_state, + granted_list: new_permissions.granted_list, + denied_list: new_permissions.denied_list, + }), + PermissionState::Prompt => match new_permissions.global_state { + //Throw + PermissionState::Granted => Err(custom_error( + "PermissionDenied", + "Can't escalate parent thread permissions", + )), + //Merge + PermissionState::Prompt => { + if check_net_permission_contains( + &target.granted_list, + &new_permissions.granted_list, + ) { + Ok(UnaryPermission:: { + global_state: new_permissions.global_state, + granted_list: new_permissions.granted_list, + denied_list: target.denied_list.clone(), + }) + } else { + Err(custom_error( + "PermissionDenied", + "Can't escalate parent thread permissions", + )) + } + } + //Copy + PermissionState::Denied => Ok(UnaryPermission:: { + global_state: new_permissions.global_state, + granted_list: new_permissions.granted_list, + denied_list: new_permissions.denied_list, + }), + }, + PermissionState::Denied => match new_permissions.global_state { + PermissionState::Denied => Ok(UnaryPermission:: { + global_state: new_permissions.global_state, + granted_list: new_permissions.granted_list, + denied_list: new_permissions.denied_list, + }), + _ => Err(custom_error( + "PermissionDenied", + "Can't escalate parent thread permissions", + )), + }, + } +} + +enum WorkerPermissionType { + READ, + WRITE, +} + +fn check_read_permissions( + allow_list: &HashSet, + current_permissions: &Permissions, +) -> bool { + allow_list + .iter() + .all(|x| current_permissions.check_read(&x).is_ok()) +} + +fn check_write_permissions( + allow_list: &HashSet, + current_permissions: &Permissions, +) -> bool { + allow_list + .iter() + .all(|x| current_permissions.check_write(&x).is_ok()) +} + +fn merge_read_write_permissions( + permission_type: WorkerPermissionType, + target: &UnaryPermission, + incoming: Option>, + current_permissions: &Permissions, +) -> Result, AnyError> { + if incoming.is_none() { + return Ok(target.clone()); + }; + + let new_permissions = incoming.unwrap(); + match &target.global_state { + PermissionState::Granted => Ok(UnaryPermission:: { + global_state: new_permissions.global_state, + granted_list: new_permissions.granted_list, + denied_list: new_permissions.denied_list, + }), + PermissionState::Prompt => match new_permissions.global_state { + //Throw + PermissionState::Granted => Err(custom_error( + "PermissionDenied", + "Can't escalate parent thread permissions", + )), + //Merge + PermissionState::Prompt => { + if match permission_type { + WorkerPermissionType::READ => check_read_permissions( + &new_permissions.granted_list, + current_permissions, + ), + WorkerPermissionType::WRITE => check_write_permissions( + &new_permissions.granted_list, + current_permissions, + ), + } { + Ok(UnaryPermission:: { + global_state: new_permissions.global_state, + granted_list: new_permissions.granted_list, + denied_list: target.denied_list.clone(), + }) + } else { + Err(custom_error( + "PermissionDenied", + "Can't escalate parent thread permissions", + )) + } + } + //Copy + PermissionState::Denied => Ok(UnaryPermission:: { + global_state: new_permissions.global_state, + granted_list: new_permissions.granted_list, + denied_list: new_permissions.denied_list, + }), + }, + PermissionState::Denied => match new_permissions.global_state { + PermissionState::Denied => Ok(UnaryPermission:: { + global_state: new_permissions.global_state, + granted_list: new_permissions.granted_list, + denied_list: new_permissions.denied_list, + }), + _ => Err(custom_error( + "PermissionDenied", + "Can't escalate parent thread permissions", + )), + }, + } +} + +fn create_worker_permissions( + main_thread_permissions: &Permissions, + permission_args: PermissionsArg, +) -> Result { + Ok(Permissions { + env: merge_permission_state( + &main_thread_permissions.env, + permission_args.env, + )?, + hrtime: merge_permission_state( + &main_thread_permissions.hrtime, + permission_args.hrtime, + )?, + net: merge_net_permissions( + &main_thread_permissions.net, + permission_args.net, + )?, + plugin: merge_permission_state( + &main_thread_permissions.plugin, + permission_args.plugin, + )?, + read: merge_read_write_permissions( + WorkerPermissionType::READ, + &main_thread_permissions.read, + permission_args.read, + &main_thread_permissions, + )?, + run: merge_permission_state( + &main_thread_permissions.run, + permission_args.run, + )?, + write: merge_read_write_permissions( + WorkerPermissionType::WRITE, + &main_thread_permissions.write, + permission_args.write, + &main_thread_permissions, + )?, + }) +} + +#[derive(Debug, Deserialize)] +struct PermissionsArg { + #[serde(default, deserialize_with = "as_permission_state")] + env: Option, + #[serde(default, deserialize_with = "as_permission_state")] + hrtime: Option, + #[serde(default, deserialize_with = "as_unary_string_permission")] + net: Option>, + #[serde(default, deserialize_with = "as_permission_state")] + plugin: Option, + #[serde(default, deserialize_with = "as_unary_path_permission")] + read: Option>, + #[serde(default, deserialize_with = "as_permission_state")] + run: Option, + #[serde(default, deserialize_with = "as_unary_path_permission")] + write: Option>, +} + +fn as_permission_state<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value: bool = Deserialize::deserialize(deserializer)?; + + match value { + true => Ok(Some(PermissionState::Granted)), + false => Ok(Some(PermissionState::Denied)), + } +} + +struct UnaryPermissionBase { + global_state: PermissionState, + paths: Vec, +} + +struct ParseBooleanOrStringVec; + +impl<'de> de::Visitor<'de> for ParseBooleanOrStringVec { + type Value = UnaryPermissionBase; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a vector of strings or a boolean") + } + + fn visit_bool(self, v: bool) -> Result + where + E: de::Error, + { + Ok(UnaryPermissionBase { + global_state: match v { + true => PermissionState::Granted, + false => PermissionState::Denied, + }, + paths: Vec::new(), + }) + } + + fn visit_seq(self, mut visitor: V) -> Result + where + V: SeqAccess<'de>, + { + let mut vec: Vec = Vec::new(); + + let mut value = visitor.next_element::()?; + while value.is_some() { + vec.push(value.unwrap()); + value = visitor.next_element()?; + } + Ok(UnaryPermissionBase { + global_state: PermissionState::Prompt, + paths: vec, + }) + } +} + +fn as_unary_string_permission<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let value: UnaryPermissionBase = + deserializer.deserialize_any(ParseBooleanOrStringVec)?; + + let allowed: HashSet = value.paths.into_iter().collect(); + + Ok(Some(UnaryPermission:: { + global_state: value.global_state, + granted_list: allowed, + ..Default::default() + })) +} + +fn as_unary_path_permission<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let value: UnaryPermissionBase = + deserializer.deserialize_any(ParseBooleanOrStringVec)?; + + let paths: Vec = + value.paths.into_iter().map(PathBuf::from).collect(); + + Ok(Some(UnaryPermission:: { + global_state: value.global_state, + granted_list: resolve_fs_allowlist(&Some(paths)), + ..Default::default() + })) +} #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CreateWorkerArgs { - name: Option, - specifier: String, has_source_code: bool, + name: Option, + permissions: Option, source_code: String, + specifier: String, use_deno_namespace: bool, } @@ -121,9 +467,16 @@ fn op_create_worker( let args_name = args.name; let use_deno_namespace = args.use_deno_namespace; if use_deno_namespace { - super::check_unstable(state, "Worker.deno"); + super::check_unstable(state, "Worker.deno.namespace"); } - let permissions = state.borrow::().clone(); + let parent_permissions = state.borrow::().clone(); + let worker_permissions = if let Some(permissions) = args.permissions { + super::check_unstable(state, "Worker.deno.permissions"); + create_worker_permissions(&parent_permissions, permissions)? + } else { + parent_permissions.clone() + }; + let worker_id = state.take::(); let create_module_loader = state.take::(); state.put::(create_module_loader.clone()); @@ -149,7 +502,8 @@ fn op_create_worker( let worker = (create_module_loader.0)(CreateWebWorkerArgs { name: worker_name, worker_id, - permissions, + parent_permissions, + permissions: worker_permissions, main_module: module_specifier.clone(), use_deno_namespace, }); diff --git a/runtime/permissions.rs b/runtime/permissions.rs index c50783f9d0..16a6116903 100644 --- a/runtime/permissions.rs +++ b/runtime/permissions.rs @@ -78,7 +78,7 @@ pub struct Permissions { pub hrtime: PermissionState, } -fn resolve_fs_allowlist(allow: &Option>) -> HashSet { +pub fn resolve_fs_allowlist(allow: &Option>) -> HashSet { if let Some(v) = allow { v.iter() .map(|raw_path| resolve_from_cwd(Path::new(&raw_path)).unwrap())