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
259 changes: 259 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,254 @@ Worker::~Worker() {
Debug(this, "Worker %llu destroyed", thread_id_.id);
}


// SEMVER-MAJOR: Permission ceiling for Worker when execArgv is explicit
// (including []). Default Worker (no execArgv) is unchanged.
//
// After options parse, NODE_OPTIONS and repeated --allow-* are already in
// EnvironmentOptions. Runtime FSPermission remains authoritative for FS checks;
// path filtering here is create-time only (prefix / exact / *).
//
// Parent allow-list containing "*" already means unrestricted FS for that
// dimension; FilterPathList early-return keeps worker paths (cannot exceed *).

namespace {

bool WorkerConfiguredPermission(const EnvironmentOptions* w) {
if (w == nullptr) return false;
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;
}

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;
}

void NormalizePathForCompare(std::string* s) {
while (s->size() > 1 &&
(s->back() == '/' || s->back() == static_cast<char>(92))) {
s->pop_back();
}
#ifdef _WIN32
for (char& c : *s) {
if (c >= 'A' && c <= 'Z') {
c = static_cast<char>(c - 'A' + 'a');
}
if (c == '/') c = static_cast<char>(92);
}
#endif
}

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;
}

// Create-time filter only.
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_cast<char>(92);
}

bool ParentListHasWildcard(const std::vector<std::string>& parent) {
for (const std::string& entry : parent) {
if (entry == "*") return true;
}
return false;
}

void FilterPathListToParentSubset(Environment* env,
EnvironmentOptions* w,
std::vector<std::string>* worker,
const std::vector<std::string>& parent) {
if (worker == nullptr) return;

// Worker enabled permission but listed no paths → keep empty (restrict).
if (worker->empty()) {
if (w->permission || w->permission_audit) return;
*worker = parent;
return;
}

// Parent "*" → FS already unrestricted; worker paths cannot exceed parent.
if (ParentListHasWildcard(parent)) return;

std::vector<std::string> out;
out.reserve(worker->size());
bool saw_star = false;
for (const std::string& wpath : *worker) {
if (wpath == "*") {
saw_star = true;
continue;
}
for (const std::string& entry : parent) {
if (PathCoveredByParentEntry(env, entry, wpath)) {
out.push_back(wpath);
break;
}
}
}
if (saw_star && out.empty()) {
*worker = parent;
return;
}
*worker = std::move(out);
}

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);
}

void ClampWorkerPermissionToParent(Environment* env,
PerIsolateOptions* worker_opts) {
if (worker_opts == nullptr || env == 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);
}
}

// Exact flag name or flag=value (not a longer unrelated prefix).
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;
}

bool PermissionFlagTakesNextArg(const std::string& a) {
return a == "--allow-fs-read" || a == "--allow-fs-write";
}

bool PathSafeForAllowFlag(const std::string& path) {
if (path.empty()) return false;
for (unsigned char c : path) {
if (c == 0 || c == 10 || c == 13) return false;
}
return true;
}

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> kept;
kept.reserve(exec_argv_out->size());
for (size_t i = 0; i < exec_argv_out->size(); ++i) {
const std::string& tok = (*exec_argv_out)[i];
if (tok.empty()) continue;
if (IsPermissionCliToken(tok)) {
if (PermissionFlagTakesNextArg(tok) && i + 1 < exec_argv_out->size()) {
const std::string& next = (*exec_argv_out)[i + 1];
if (!next.empty() && next[0] != '-') ++i;
}
continue;
}
kept.push_back(tok);
}

std::vector<std::string> out;
out.reserve(kept.size() + 16 + w->allow_fs_read.size() +
w->allow_fs_write.size());
for (const std::string& tok : kept) 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& p : w->allow_fs_read) {
if (!PathSafeForAllowFlag(p)) continue;
out.push_back("--allow-fs-read=" + p);
}
for (const std::string& p : w->allow_fs_write) {
if (!PathSafeForAllowFlag(p)) continue;
out.push_back("--allow-fs-write=" + p);
}
*exec_argv_out = std::move(out);
}

} // namespace


void Worker::New(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
THROW_IF_INSUFFICIENT_PERMISSIONS(
Expand Down Expand Up @@ -683,6 +932,16 @@ void Worker::New(const FunctionCallbackInfo<Value>& args) {
per_isolate_opts = env->isolate_data()->options()->Clone();
}

//

// Explicit execArgv only (including []). Default Worker path 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