Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions doc/api/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ changes:
description: This feature is no longer experimental.
-->

<!-- worker-execargv-permission-ceiling -->
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
Expand Down
7 changes: 7 additions & 0 deletions doc/api/worker_threads.md
Original file line number Diff line number Diff line change
Expand Up @@ -1605,6 +1605,13 @@ changes:
description: The `resourceLimits` option was introduced.
-->

<!-- worker-execargv-permission-ceiling -->
**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`
Expand Down
260 changes: 260 additions & 0 deletions src/node_worker.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<char>(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>{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<std::string>& parent) {
for (const std::string& entry : parent) {
if (entry == "*") {
return true;
}
}
return false;
}

static void FilterPathListToParentSubset(
Environment* env,
EnvironmentOptions* w,
std::vector<std::string>* worker,
const std::vector<std::string>& parent) {
if (worker == nullptr) {
return;
}
if (worker->empty()) {
if (w->permission || w->permission_audit) {
return;
}
*worker = parent;
return;
}
if (ParentListHasWildcard(parent)) {
return;
}
std::vector<std::string> 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<char>::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<std::string>* 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<std::string> 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<Value>& args) {
Environment* env = Environment::GetCurrent(args);
THROW_IF_INSUFFICIENT_PERMISSIONS(
Expand Down Expand Up @@ -683,6 +935,14 @@ void Worker::New(const FunctionCallbackInfo<Value>& 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
Expand Down
Loading