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
247 changes: 247 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 @@ -503,6 +504,244 @@ 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 / *).
//
// Boolean --allow-* dimensions are listed once in PERMISSION_BOOL_FLAGS so
// ceiling / intersect / CLI token / rebuild cannot drift.

namespace {

// Single source of truth for boolean permission dimensions (not fs path
// lists, which are handled separately since they're not simple booleans).
#define PERMISSION_BOOL_FLAGS(V) \
V(allow_addons, "--allow-addons") \
V(allow_inspector, "--allow-inspector") \
V(allow_child_process, "--allow-child-process") \
V(allow_net, "--allow-net") \
V(allow_wasi, "--allow-wasi") \
V(allow_ffi, "--allow-ffi") \
V(allow_openssl_store, "--allow-openssl-store") \
V(allow_worker_threads, "--allow-worker")

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;
#define V(field, flag) || w->field
return false PERMISSION_BOOL_FLAGS(V);
#undef V
}

void ApplyParentPermissionCeiling(EnvironmentOptions* w,
const EnvironmentOptions* parent) {
w->permission = true;
w->permission_audit = parent->permission_audit;
#define V(field, flag) w->field = parent->field;
PERMISSION_BOOL_FLAGS(V)
#undef V
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;
}

bool ParentEntryCoversResolvedPath(Environment* env,
const std::string& parent_raw,
const std::string& resolved_requested) {
if (parent_raw == "*") return true;
const std::string parent = ResolveForCompare(env, parent_raw);
if (parent.empty()) return false;
if (resolved_requested == parent) return true;
if (resolved_requested.size() <= parent.size()) return false;
if (resolved_requested.compare(0, parent.size(), parent) != 0) return false;
const char next = resolved_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 listed no fs paths → keep empty (restrict).
if (worker->empty()) 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;
}
const std::string resolved_wpath = ResolveForCompare(env, wpath);
for (const std::string& entry : parent) {
if (ParentEntryCoversResolvedPath(env, entry, resolved_wpath)) {
out.push_back(resolved_wpath);
break;
}
}
}
// "*" alone or combined with concrete paths still means "everything the
// parent allows" here, not "just the concrete paths that also matched" —
// treating it as a subset would silently grant *less* than requesting "*"
// by itself, which is backwards. See PR discussion for why this needs to
// be unconditional on saw_star, not just "saw_star && out.empty()".
if (saw_star) {
*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;
#define V(field, flag) w->field = w->field && parent->field;
PERMISSION_BOOL_FLAGS(V)
#undef V
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);
}
}

bool IsPermissionCliToken(const std::string& a) {
if (a == "--permission" || a == "--permission-audit") return true;
if (a == "--allow-fs-read" || a == "--allow-fs-write") return true;
if (a.rfind("--allow-fs-read=", 0) == 0) return true;
if (a.rfind("--allow-fs-write=", 0) == 0) return true;
#define V(field, flag) \
if (a == flag) return true; \
{ \
const size_t n = sizeof(flag) - 1; \
if (a.size() > n && a.compare(0, n, flag) == 0 && a[n] == '=') \
return true; \
}
PERMISSION_BOOL_FLAGS(V)
#undef V
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)) {
// Space-form --allow-fs-read/--allow-fs-write always consume the next
// token as their argument, regardless of its first character — paths
// are legal starting with '-' on Unix, so gating on that would leave
// a stray token behind instead of consuming it as the flag's value.
if (PermissionFlagTakesNextArg(tok) && i + 1 < exec_argv_out->size()) {
++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");
#define V(field, flag) \
if (w->field) out.push_back(flag);
PERMISSION_BOOL_FLAGS(V)
#undef V
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);
}

#undef PERMISSION_BOOL_FLAGS

} // namespace

void Worker::New(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
THROW_IF_INSUFFICIENT_PERMISSIONS(
Expand Down Expand Up @@ -682,6 +921,14 @@ 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