diff --git a/src/node_worker.cc b/src/node_worker.cc index edc21e7e556..8b1bf92f828 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,284 @@ Worker::~Worker() { Debug(this, "Worker %llu destroyed", thread_id_.id); } + + + + +// Permission Model clamp for Worker explicit execArgv (including []). +// +// When the parent has --permission enabled, the worker must not receive a +// wider permission-related grant set than the parent. Implemented in C++ after +// options parse so NODE_OPTIONS and repeated --allow-* are already in +// EnvironmentOptions (no JS process.execArgv copying). +// +// - Worker did not configure permission flags → effective grants = parent +// - Worker configured permission flags → intersect with parent +// - Non-permission execArgv preserved; permission argv rewritten +// - Path lists: normalize via PathResolve when possible; case-insensitive +// prefix match on Windows; runtime FSPermission remains authoritative + +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; + } + if (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) { + return true; + } + return false; +} + +// True only for exact flag names or "flag=value". Does not match a longer +// distinct option that merely shares a prefix (e.g. --allow-fs-read-extra). +static bool IsExactPermissionFlag(const std::string& arg, const char* name) { + const size_t n = std::char_traits::length(name); + if (arg.size() < n) { + return false; + } + if (arg.compare(0, n, name) != 0) { + return false; + } + return arg.size() == n || arg[n] == '='; +} + +static bool IsPermissionArg(const std::string& arg) { + return IsExactPermissionFlag(arg, "--permission") || + IsExactPermissionFlag(arg, "--permission-audit") || + IsExactPermissionFlag(arg, "--allow-fs-read") || + IsExactPermissionFlag(arg, "--allow-fs-write") || + IsExactPermissionFlag(arg, "--allow-addons") || + IsExactPermissionFlag(arg, "--allow-inspector") || + IsExactPermissionFlag(arg, "--allow-child-process") || + IsExactPermissionFlag(arg, "--allow-net") || + IsExactPermissionFlag(arg, "--allow-wasi") || + IsExactPermissionFlag(arg, "--allow-ffi") || + IsExactPermissionFlag(arg, "--allow-openssl-store") || + IsExactPermissionFlag(arg, "--allow-worker"); +} + +// Flags that may take a separate following argv token (space form). +// Only the bare form (no =value) may be followed by a separate path token. +static bool PermissionArgTakesNext(const std::string& arg) { + return arg == "--allow-fs-read" || arg == "--allow-fs-write"; +} + +static void StripPermissionArgs(std::vector* argv) { + if (argv == nullptr) { + return; + } + std::vector out; + out.reserve(argv->size()); + for (size_t i = 0; i < argv->size(); i++) { + const std::string& a = (*argv)[i]; + if (IsPermissionArg(a)) { + if (PermissionArgTakesNext(a) && i + 1 < argv->size()) { + const std::string& next = (*argv)[i + 1]; + // Bare --allow-fs-read/--allow-fs-write may be followed by a path + // token (space-separated CLI form). Only skip one non-flag token. + if (!next.empty() && next[0] != '-') { + i++; + } + } + continue; + } + out.push_back(a); + } + *argv = std::move(out); +} + +static void AppendPermissionArgsFromOptions(std::vector* argv, + const EnvironmentOptions* o) { + if (argv == nullptr || o == nullptr || !o->permission) { + return; + } + argv->push_back("--permission"); + if (o->permission_audit) { + argv->push_back("--permission-audit"); + } + for (const std::string& p : o->allow_fs_read) { + argv->push_back("--allow-fs-read=" + p); + } + for (const std::string& p : o->allow_fs_write) { + argv->push_back("--allow-fs-write=" + p); + } + if (o->allow_addons) argv->push_back("--allow-addons"); + if (o->allow_inspector) argv->push_back("--allow-inspector"); + if (o->allow_child_process) argv->push_back("--allow-child-process"); + if (o->allow_net) argv->push_back("--allow-net"); + if (o->allow_wasi) argv->push_back("--allow-wasi"); + if (o->allow_ffi) argv->push_back("--allow-ffi"); + if (o->allow_openssl_store) argv->push_back("--allow-openssl-store"); + if (o->allow_worker_threads) argv->push_back("--allow-worker"); +} + +static void StripTrailingSeparators(std::string* s) { + while (s->size() > 1 && (s->back() == '/' || s->back() == '\\')) { + s->pop_back(); + } +} + +static std::string NormalizeListPath(Environment* env, const std::string& in) { + if (in.empty() || in == "*") { + return in; + } + // PathResolve handles relative segments using the environment cwd when + // possible. If resolution fails for any reason, fall back to the original. + std::string resolved = PathResolve(env, std::vector{in}); + if (resolved.empty()) { + resolved = in; + } + StripTrailingSeparators(&resolved); +#ifdef _WIN32 + for (char& c : resolved) { + if (c >= 'A' && c <= 'Z') { + c = static_cast(c - 'A' + 'a'); + } + if (c == '/') { + c = '\\'; + } + } +#endif + return resolved; +} + +static bool PathPrefixGranted(const std::string& parent, + const std::string& requested) { + if (parent == "*" || parent == requested) { + return true; + } + if (parent.empty() || requested.size() < parent.size()) { + return false; + } +#ifdef _WIN32 + // Case-insensitive prefix compare for Windows path grants. + for (size_t i = 0; i < parent.size(); i++) { + char a = parent[i]; + char b = requested[i]; + if (a >= 'A' && a <= 'Z') a = static_cast(a - 'A' + 'a'); + if (b >= 'A' && b <= 'Z') b = static_cast(b - 'A' + 'a'); + if (a == '/') a = '\\'; + if (b == '/') b = '\\'; + if (a != b) { + return false; + } + } +#else + if (requested.compare(0, parent.size(), parent) != 0) { + return false; + } +#endif + if (requested.size() == parent.size()) { + return true; + } + const char next = requested[parent.size()]; + return next == '/' || next == '\\'; +} + +static bool PathGrantedByParentList(Environment* env, + const std::vector& parent_paths, + const std::string& requested) { + if (parent_paths.empty()) { + return false; + } + const std::string req = NormalizeListPath(env, requested); + for (const std::string& raw_p : parent_paths) { + const std::string p = NormalizeListPath(env, raw_p); + if (PathPrefixGranted(p, req)) { + return true; + } + } + return false; +} + +static void IntersectPathList(Environment* env, + std::vector* worker, + const std::vector& parent) { + if (worker == nullptr || worker->empty()) { + return; + } + std::vector out; + out.reserve(worker->size()); + for (const std::string& wpath : *worker) { + if (wpath == "*") { + for (const std::string& p : parent) { + if (p == "*") { + out.push_back(wpath); + break; + } + } + continue; + } + if (PathGrantedByParentList(env, parent, wpath)) { + out.push_back(wpath); + } + } + *worker = std::move(out); +} + +static void CopyParentPermissionGrants(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 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; + IntersectPathList(env, &w->allow_fs_read, parent->allow_fs_read); + IntersectPathList(env, &w->allow_fs_write, parent->allow_fs_write); +} + +static void ClampWorkerPermissionToParent(Environment* env, + PerIsolateOptions* worker_opts, + std::vector* exec_argv) { + 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)) { + CopyParentPermissionGrants(w, parent); + } else { + IntersectPermissionGrants(env, w, parent); + } + + if (exec_argv != nullptr) { + StripPermissionArgs(exec_argv); + AppendPermissionArgsFromOptions(exec_argv, w); + } +} + void Worker::New(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); THROW_IF_INSUFFICIENT_PERMISSIONS( @@ -688,6 +967,14 @@ void Worker::New(const FunctionCallbackInfo& args) { // essential to load user codes and must not be blocked by the inspector // for internal scripts. // Still, `--inspect-node` can break on the first line of internal scripts. + + + + if (env->permission()->enabled() && per_isolate_opts) { + ClampWorkerPermissionToParent(env, per_isolate_opts.get(), + &exec_argv_out); + } + if (is_internal) { per_isolate_opts->per_env->get_debug_options() ->DisableWaitOrBreakFirstLine(); 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..0ff3c667661 --- /dev/null +++ b/test/parallel/test-permission-worker-empty-execargv.js @@ -0,0 +1,108 @@ +'use strict'; + +const common = require('../common'); +const { isMainThread } = require('worker_threads'); +if (!isMainThread) common.skip('main thread only'); + +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 allowed = tmpdir.path; +const allowedFile = path.join(allowed, 'ok.txt'); +const deniedFile = path.join(tmpdir.path, '..', 'permission-worker-denied-file'); +fs.writeFileSync(allowedFile, 'allowed\n'); +fs.writeFileSync(deniedFile, 'secret\n'); + +function runWorker(workerSource, execArgvFragment) { + const code = ` + const { Worker } = require('worker_threads'); + const w = new Worker(${JSON.stringify(workerSource)}, { + 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, [ + '--permission', + `--allow-fs-read=${allowed}`, + '--allow-worker', + '-e', + code, + ], { encoding: 'utf8', timeout: 20000 }); +} + +function lastMsg(r) { + assert.strictEqual(r.status, 0, r.stderr); + return JSON.parse(r.stdout.trim().split('\n').pop()); +} + +function srcRead(file) { + return ` + const { parentPort } = require('worker_threads'); + const fs = require('fs'); + try { + parentPort.postMessage({ + ok: true, + data: fs.readFileSync(${JSON.stringify(file)}, 'utf8'), + }); + } catch (err) { + parentPort.postMessage({ ok: false, code: err.code }); + } + `; +} + +// default: denied blocked +{ + const msg = lastMsg(runWorker(srcRead(deniedFile), '')); + assert.strictEqual(msg.ok, false); + assert.strictEqual(msg.code, 'ERR_ACCESS_DENIED'); +} + +// execArgv []: denied blocked, allowed readable +{ + const denied = lastMsg(runWorker(srcRead(deniedFile), 'execArgv: [],')); + assert.strictEqual(denied.ok, false, JSON.stringify(denied)); + assert.strictEqual(denied.code, 'ERR_ACCESS_DENIED'); + const ok = lastMsg(runWorker(srcRead(allowedFile), 'execArgv: [],')); + assert.strictEqual(ok.ok, true, JSON.stringify(ok)); +} + +// non-permission flag only: same boundary +{ + const denied = lastMsg(runWorker(srcRead(deniedFile), 'execArgv: ["--no-warnings"],')); + assert.strictEqual(denied.ok, false); + assert.strictEqual(denied.code, 'ERR_ACCESS_DENIED'); + const ok = lastMsg(runWorker(srcRead(allowedFile), 'execArgv: ["--no-warnings"],')); + assert.strictEqual(ok.ok, true, JSON.stringify(ok)); +} + +// wider than parent +{ + const frag = `execArgv: ${JSON.stringify([ + '--permission', '--allow-fs-read=*', '--allow-worker', + ])},`; + const msg = lastMsg(runWorker(srcRead(deniedFile), frag)); + assert.strictEqual(msg.ok, false, JSON.stringify(msg)); + assert.strictEqual(msg.code, 'ERR_ACCESS_DENIED'); +} + +// repeated allow flags in worker execArgv still cannot exceed parent +{ + const frag = `execArgv: ${JSON.stringify([ + '--permission', + `--allow-fs-read=${allowed}`, + `--allow-fs-read=${deniedFile}`, + '--allow-worker', + ])},`; + const msg = lastMsg(runWorker(srcRead(deniedFile), frag)); + assert.strictEqual(msg.ok, false, JSON.stringify(msg)); + assert.strictEqual(msg.code, 'ERR_ACCESS_DENIED'); +}