diff --git a/doc/api/permissions.md b/doc/api/permissions.md index 0b46f42d982..0017d1a1728 100644 --- a/doc/api/permissions.md +++ b/doc/api/permissions.md @@ -38,6 +38,15 @@ changes: description: This feature is no longer experimental. --> + +When the Permission Model is enabled in the parent process, creating a +`worker_threads.Worker` with an explicit `execArgv` option (including an empty +array) no longer allows the worker to obtain a wider permission-related grant +set than the parent. Non-permission `execArgv` flags are unaffected. This is a +breaking change relative to earlier releases where `execArgv: []` could drop +the parent's Permission Model grants. + + > Stability: 2 - Stable The Node.js Permission Model is a mechanism for restricting access to specific diff --git a/doc/api/worker_threads.md b/doc/api/worker_threads.md index e7b6b19b355..f5ce5b979ea 100644 --- a/doc/api/worker_threads.md +++ b/doc/api/worker_threads.md @@ -1605,6 +1605,13 @@ changes: description: The `resourceLimits` option was introduced. --> + +**Permission Model (breaking):** If the parent process runs with the +Permission Model enabled, an explicit `execArgv` (including `[]`) does not +disable or exceed the parent's permission-related grants. See the +[Permission Model](permissions.md#permission-model) documentation. + + * `filename` {string|URL} The path to the Worker's main script or module. Must be either an absolute path or a relative path (i.e. relative to the current working directory) starting with `./` or `../`, or a WHATWG `URL` diff --git a/src/node_worker.cc b/src/node_worker.cc index edc21e7e556..05388a6929e 100644 --- a/src/node_worker.cc +++ b/src/node_worker.cc @@ -11,6 +11,7 @@ #include "node_profiling.h" #include "node_snapshot_builder.h" #include "permission/permission.h" +#include "path.h" #include "util-inl.h" #include "v8-cppgc.h" #include "v8-profiler.h" @@ -504,6 +505,257 @@ Worker::~Worker() { Debug(this, "Worker %llu destroyed", thread_id_.id); } + +// SEMVER-MAJOR: Permission ceiling for Worker explicit execArgv. +static bool WorkerConfiguredPermission(const EnvironmentOptions* w) { + if (w->permission || w->permission_audit) { + return true; + } + if (!w->allow_fs_read.empty() || !w->allow_fs_write.empty()) { + return true; + } + return w->allow_addons || w->allow_inspector || w->allow_child_process || + w->allow_net || w->allow_wasi || w->allow_ffi || + w->allow_openssl_store || w->allow_worker_threads; +} + +static void ApplyParentPermissionCeiling(EnvironmentOptions* w, + const EnvironmentOptions* parent) { + w->permission = true; + w->permission_audit = parent->permission_audit; + w->allow_addons = parent->allow_addons; + w->allow_inspector = parent->allow_inspector; + w->allow_child_process = parent->allow_child_process; + w->allow_net = parent->allow_net; + w->allow_wasi = parent->allow_wasi; + w->allow_ffi = parent->allow_ffi; + w->allow_openssl_store = parent->allow_openssl_store; + w->allow_worker_threads = parent->allow_worker_threads; + w->allow_fs_read = parent->allow_fs_read; + w->allow_fs_write = parent->allow_fs_write; +} + +static void NormalizePathForCompare(std::string* s) { + while (s->size() > 1 && (s->back() == '/' || s->back() == '\\')) { + s->pop_back(); + } +#ifdef _WIN32 + for (char& c : *s) { + if (c >= 'A' && c <= 'Z') { + c = static_cast(c - 'A' + 'a'); + } + if (c == '/') { + c = '\\'; + } + } +#endif +} + +static std::string ResolveForCompare(Environment* env, const std::string& in) { + if (in.empty() || in == "*") { + return in; + } + std::string resolved = + PathResolve(env, std::vector{std::string_view(in)}); + if (resolved.empty()) { + resolved = in; + } + NormalizePathForCompare(&resolved); + return resolved; +} + +static bool PathCoveredByParentEntry(Environment* env, + const std::string& parent_raw, + const std::string& requested_raw) { + if (parent_raw == "*") { + return true; + } + const std::string parent = ResolveForCompare(env, parent_raw); + const std::string requested = ResolveForCompare(env, requested_raw); + if (parent.empty()) { + return false; + } + if (requested == parent) { + return true; + } + if (requested.size() <= parent.size()) { + return false; + } + if (requested.compare(0, parent.size(), parent) != 0) { + return false; + } + const char next = requested[parent.size()]; + return next == '/' || next == '\\'; +} + +static bool ParentListHasWildcard(const std::vector& parent) { + for (const std::string& entry : parent) { + if (entry == "*") { + return true; + } + } + return false; +} + +static void FilterPathListToParentSubset( + Environment* env, + EnvironmentOptions* w, + std::vector* worker, + const std::vector& parent) { + if (worker == nullptr) { + return; + } + if (worker->empty()) { + if (w->permission || w->permission_audit) { + return; + } + *worker = parent; + return; + } + if (ParentListHasWildcard(parent)) { + return; + } + std::vector out; + out.reserve(worker->size()); + for (const std::string& wpath : *worker) { + if (wpath == "*") { + continue; + } + for (const std::string& entry : parent) { + if (PathCoveredByParentEntry(env, entry, wpath)) { + out.push_back(wpath); + break; + } + } + } + *worker = std::move(out); +} + +static void IntersectPermissionGrants(Environment* env, + EnvironmentOptions* w, + const EnvironmentOptions* parent) { + w->permission = true; + w->permission_audit = w->permission_audit || parent->permission_audit; + w->allow_addons = w->allow_addons && parent->allow_addons; + w->allow_inspector = w->allow_inspector && parent->allow_inspector; + w->allow_child_process = + w->allow_child_process && parent->allow_child_process; + w->allow_net = w->allow_net && parent->allow_net; + w->allow_wasi = w->allow_wasi && parent->allow_wasi; + w->allow_ffi = w->allow_ffi && parent->allow_ffi; + w->allow_openssl_store = + w->allow_openssl_store && parent->allow_openssl_store; + w->allow_worker_threads = + w->allow_worker_threads && parent->allow_worker_threads; + FilterPathListToParentSubset(env, w, &w->allow_fs_read, parent->allow_fs_read); + FilterPathListToParentSubset( + env, w, &w->allow_fs_write, parent->allow_fs_write); +} + +static void ClampWorkerPermissionToParent(Environment* env, + PerIsolateOptions* worker_opts) { + if (worker_opts == nullptr || !env->permission()->enabled()) { + return; + } + EnvironmentOptions* parent = env->isolate_data()->options()->get_per_env_options(); + EnvironmentOptions* w = worker_opts->get_per_env_options(); + if (parent == nullptr || w == nullptr) { + return; + } + if (!WorkerConfiguredPermission(w)) { + ApplyParentPermissionCeiling(w, parent); + } else { + IntersectPermissionGrants(env, w, parent); + } +} + +static bool IsPermissionCliToken(const std::string& a) { + if (a == "--permission" || a == "--permission-audit") { + return true; + } + static const char* kFlags[] = { + "--allow-fs-read", + "--allow-fs-write", + "--allow-addons", + "--allow-inspector", + "--allow-child-process", + "--allow-net", + "--allow-wasi", + "--allow-ffi", + "--allow-openssl-store", + "--allow-worker", + }; + for (const char* flag : kFlags) { + const size_t n = std::char_traits::length(flag); + if (a == flag) { + return true; + } + if (a.size() > n && a.compare(0, n, flag) == 0 && a[n] == '=') { + return true; + } + } + return false; +} + +// Rebuild argv from clamped options. Do not assume argv[0] layout from Parse. +static void RebuildExecArgvOutFromPermissionOptions( + PerIsolateOptions* worker_opts, std::vector* exec_argv_out) { + if (worker_opts == nullptr || exec_argv_out == nullptr) { + return; + } + EnvironmentOptions* w = worker_opts->get_per_env_options(); + if (w == nullptr || !w->permission) { + return; + } + + std::vector out; + out.emplace_back(""); // program-name placeholder for parsers that expect it + for (const std::string& tok : *exec_argv_out) { + if (tok.empty()) { + continue; + } + if (!IsPermissionCliToken(tok)) { + out.push_back(tok); + } + } + out.push_back("--permission"); + if (w->permission_audit) { + out.push_back("--permission-audit"); + } + if (w->allow_addons) { + out.push_back("--allow-addons"); + } + if (w->allow_inspector) { + out.push_back("--allow-inspector"); + } + if (w->allow_child_process) { + out.push_back("--allow-child-process"); + } + if (w->allow_net) { + out.push_back("--allow-net"); + } + if (w->allow_wasi) { + out.push_back("--allow-wasi"); + } + if (w->allow_ffi) { + out.push_back("--allow-ffi"); + } + if (w->allow_openssl_store) { + out.push_back("--allow-openssl-store"); + } + if (w->allow_worker_threads) { + out.push_back("--allow-worker"); + } + for (const std::string& path : w->allow_fs_read) { + out.push_back("--allow-fs-read=" + path); + } + for (const std::string& path : w->allow_fs_write) { + out.push_back("--allow-fs-write=" + path); + } + *exec_argv_out = std::move(out); +} + + void Worker::New(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); THROW_IF_INSUFFICIENT_PERMISSIONS( @@ -683,6 +935,14 @@ void Worker::New(const FunctionCallbackInfo& args) { per_isolate_opts = env->isolate_data()->options()->Clone(); } + // Only explicit execArgv (including []): clamp options + rebuild argv. + // Default Worker path (clone parent) is left unchanged. + if (env->permission()->enabled() && per_isolate_opts && args[2]->IsArray()) { + ClampWorkerPermissionToParent(env, per_isolate_opts.get()); + RebuildExecArgvOutFromPermissionOptions(per_isolate_opts.get(), + &exec_argv_out); + } + // Internal workers should not wait for inspector frontend to connect or // break on the first line of internal scripts. Module loader threads are // essential to load user codes and must not be blocked by the inspector diff --git a/test/parallel/test-permission-worker-empty-execargv.js b/test/parallel/test-permission-worker-empty-execargv.js new file mode 100644 index 00000000000..19947aa52ae --- /dev/null +++ b/test/parallel/test-permission-worker-empty-execargv.js @@ -0,0 +1,291 @@ +'use strict'; +const common = require('../common'); +const { isMainThread } = require('worker_threads'); +if (!isMainThread) common.skip('This test only works on a main thread'); +if (!common.hasCrypto) common.skip('no crypto'); + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const tmpdir = require('../common/tmpdir'); + +tmpdir.refresh(); +const allowedDir = tmpdir.path; +const allowedFile = path.join(allowedDir, 'ok.txt'); +const subDir = path.join(allowedDir, 'sub'); +const subFile = path.join(subDir, 'nested.txt'); +fs.mkdirSync(subDir, { recursive: true }); +const deniedDir = fs.mkdtempSync(path.join(path.dirname(tmpdir.path), 'perm-deny-')); +const deniedFile = path.join(deniedDir, 'secret.txt'); +fs.writeFileSync(allowedFile, 'allowed\n'); +fs.writeFileSync(subFile, 'nested\n'); +fs.writeFileSync(deniedFile, 'secret\n'); + +// Trailing-slash allow path (NormalizePathForCompare) +const allowedDirSlash = allowedDir.endsWith(path.sep) + ? allowedDir + : allowedDir + path.sep; + +function runWithParent(parentArgs, workerBody, execArgvFragment) { + const code = ` + const { Worker } = require('worker_threads'); + const w = new Worker(${JSON.stringify(workerBody)}, { + eval: true, + ${execArgvFragment} + }); + w.on('message', (msg) => { + process.stdout.write(JSON.stringify(msg) + '\\n'); + process.exit(0); + }); + w.on('error', (err) => { console.error(err); process.exit(1); }); + `; + return spawnSync( + process.execPath, + parentArgs.concat(['-e', code]), + { encoding: 'utf8', timeout: 30000, env: { ...process.env } }, + ); +} + +function baseParent(extra = []) { + return [ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + ...extra, + ]; +} + +function parseLastJsonLine(stdout) { + const lines = stdout.trim().split('\n').filter(Boolean); + assert.ok(lines.length > 0, `expected JSON, got: ${stdout}`); + return JSON.parse(lines[lines.length - 1]); +} + +function workerRead(filePath) { + return ` + const { parentPort } = require('worker_threads'); + const fs = require('fs'); + try { + parentPort.postMessage({ + ok: true, + data: fs.readFileSync(${JSON.stringify(filePath)}, 'utf8'), + }); + } catch (err) { + parentPort.postMessage({ ok: false, code: err.code }); + } + `; +} + +function workerWrite(filePath) { + return ` + const { parentPort } = require('worker_threads'); + const fs = require('fs'); + try { + fs.writeFileSync(${JSON.stringify(filePath)}, 'w'); + parentPort.postMessage({ ok: true }); + } catch (err) { + parentPort.postMessage({ ok: false, code: err.code }); + } + `; +} + +function assertAccessDenied(msg) { + assert.strictEqual(msg.ok, false, JSON.stringify(msg)); + assert.strictEqual(msg.code, 'ERR_ACCESS_DENIED'); +} + +function assertOk(msg) { + assert.strictEqual(msg.ok, true, JSON.stringify(msg)); +} + +try { + // default Worker inherits + { + const r = runWithParent(baseParent(), workerRead(deniedFile), ''); + assert.strictEqual(r.status, 0, r.stderr); + assertAccessDenied(parseLastJsonLine(r.stdout)); + } + + // execArgv: [] + { + const denied = runWithParent(baseParent(), workerRead(deniedFile), 'execArgv: [],'); + assert.strictEqual(denied.status, 0, denied.stderr); + assertAccessDenied(parseLastJsonLine(denied.stdout)); + const allowed = runWithParent(baseParent(), workerRead(allowedFile), 'execArgv: [],'); + assert.strictEqual(allowed.status, 0, allowed.stderr); + assertOk(parseLastJsonLine(allowed.stdout)); + const nested = runWithParent(baseParent(), workerRead(subFile), 'execArgv: [],'); + assert.strictEqual(nested.status, 0, nested.stderr); + assertOk(parseLastJsonLine(nested.stdout)); + } + + // non-permission flag preserved + ceiling + { + const denied = runWithParent( + baseParent(), + workerRead(deniedFile), + 'execArgv: ["--no-warnings"],', + ); + assert.strictEqual(denied.status, 0, denied.stderr); + assertAccessDenied(parseLastJsonLine(denied.stdout)); + const allowed = runWithParent( + baseParent(), + workerRead(allowedFile), + 'execArgv: ["--no-warnings"],', + ); + assert.strictEqual(allowed.status, 0, allowed.stderr); + assertOk(parseLastJsonLine(allowed.stdout)); + } + + // worker permission on, no fs-read → empty grants + { + const frag = `execArgv: ${JSON.stringify(['--permission', '--allow-worker'])},`; + const d = runWithParent(baseParent(), workerRead(deniedFile), frag); + assert.strictEqual(d.status, 0, d.stderr); + assertAccessDenied(parseLastJsonLine(d.stdout)); + const a = runWithParent(baseParent(), workerRead(allowedFile), frag); + assert.strictEqual(a.status, 0, a.stderr); + assertAccessDenied(parseLastJsonLine(a.stdout)); + } + + // escalate * clamped + { + const frag = `execArgv: ${JSON.stringify([ + '--permission', '--allow-fs-read=*', '--allow-worker', + ])},`; + const r = runWithParent(baseParent(), workerRead(deniedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertAccessDenied(parseLastJsonLine(r.stdout)); + } + + // repeated allow flags cannot exceed parent + { + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${allowedDir}`, + `--allow-fs-read=${deniedFile}`, + '--allow-worker', + ])},`; + const r = runWithParent(baseParent(), workerRead(deniedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertAccessDenied(parseLastJsonLine(r.stdout)); + } + + // subset path under parent dir + { + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${subFile}`, + '--allow-worker', + ])},`; + const r = runWithParent(baseParent(), workerRead(subFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // trailing slash parent allow (NormalizePathForCompare) + { + const parent = [ + '--permission', + `--allow-fs-read=${allowedDirSlash}`, + '--allow-worker', + ]; + const r = runWithParent(parent, workerRead(allowedFile), 'execArgv: [],'); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // parent has wildcard * → worker * / paths not stripped by Filter + { + const parent = ['--permission', '--allow-fs-read=*', '--allow-worker']; + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${deniedFile}`, + '--allow-worker', + ])},`; + const r = runWithParent(parent, workerRead(deniedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // permission-audit on parent + empty execArgv + { + const parent = [ + '--permission', + '--permission-audit', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + ]; + const r = runWithParent(parent, workerRead(allowedFile), 'execArgv: [],'); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // fs-write: parent no write → worker cannot escalate write + { + const writeTarget = path.join(allowedDir, 'w.txt'); + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-write=${allowedDir}`, + '--allow-worker', + ])},`; + const r = runWithParent(baseParent(), workerWrite(writeTarget), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertAccessDenied(parseLastJsonLine(r.stdout)); + } + + // fs-write allowed when parent grants write + { + const writeTarget = path.join(allowedDir, 'w2.txt'); + const parent = [ + '--permission', + `--allow-fs-read=${allowedDir}`, + `--allow-fs-write=${allowedDir}`, + '--allow-worker', + ]; + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-write=${allowedDir}`, + '--allow-worker', + ])},`; + const r = runWithParent(parent, workerWrite(writeTarget), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } + + // boolean allow intersection: worker asks allow-net, parent does not + { + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-net', + '--allow-worker', + ])},`; + // still cannot read denied; net flag just exercises Intersect path + const r = runWithParent(baseParent(), workerRead(deniedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertAccessDenied(parseLastJsonLine(r.stdout)); + } + + // multiple boolean flags in rebuild path + { + const parent = [ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + '--allow-addons', + ]; + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${allowedDir}`, + '--allow-worker', + '--allow-addons', + ])},`; + const r = runWithParent(parent, workerRead(allowedFile), frag); + assert.strictEqual(r.status, 0, r.stderr); + assertOk(parseLastJsonLine(r.stdout)); + } +} finally { + fs.rmSync(deniedDir, { recursive: true, force: true }); +}