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
12 changes: 11 additions & 1 deletion doc/api/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ If you find a potential security vulnerability, please refer to our
<!-- YAML
added: v20.0.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/65359
description: >-
When the Permission Model is enabled in the parent, an explicit
`worker_threads.Worker` `execArgv` (including `[]`) cannot obtain a
wider permission-related grant set than the parent.
- version:
- v23.5.0
- v22.13.0
Expand Down Expand Up @@ -338,7 +344,11 @@ easy to configure permissions as needed when using `npx`.

There are constraints you need to know before using this system:

* The model does not inherit to a worker thread.
* By default the model does not inherit to a worker thread. When the parent
process has the Permission Model enabled, an explicit `worker_threads.Worker`
`execArgv` (including an empty array) is clamped so the worker cannot obtain
a wider permission-related grant set than the parent. Omitting `execArgv` is
unchanged. Non-permission `execArgv` flags are unaffected.
* When using the Permission Model the following features will be restricted:
* Native modules
* Network
Expand Down
11 changes: 10 additions & 1 deletion doc/api/worker_threads.md
Original file line number Diff line number Diff line change
Expand Up @@ -1564,6 +1564,12 @@ if (isMainThread) {
<!-- YAML
added: v10.5.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/65359
description: >-
When the parent has the Permission Model enabled, an explicit
`execArgv` (including `[]`) cannot obtain a wider permission-related
grant set than the parent.
- version:
- v19.8.0
- v18.16.0
Expand Down Expand Up @@ -1630,7 +1636,10 @@ changes:
V8 options (such as `--max-old-space-size`) and options that affect the
process (such as `--title`) are not supported. If set, this is provided
as [`process.execArgv`][] inside the worker. By default, options are
inherited from the parent thread.
inherited from the parent thread. When the parent runs with the
[Permission Model](permissions.md#permission-model) enabled,
permission-related grants for an explicit `execArgv` (including `[]`)
cannot exceed the parent's.
* `stdin` {boolean} If this is set to `true`, then `worker.stdin`
provides a writable stream whose contents appear as `process.stdin`
inside the Worker. By default, no data is provided.
Expand Down
4 changes: 3 additions & 1 deletion src/env.cc
Original file line number Diff line number Diff line change
Expand Up @@ -982,7 +982,9 @@ Environment::Environment(IsolateData* isolate_data,
if (options_->permission || options_->permission_audit) {
permission()->EnablePermissions();
static const std::array args = {std::string("*")};
if (options_->permission_audit) {
// Docs: when both --permission and --permission-audit are set,
// --permission takes precedence (enforce mode, not warning-only).
if (options_->permission_audit && !options_->permission) {
permission()->EnableWarningOnly();
}
// The process shouldn't be able to neither
Expand Down
307 changes: 307 additions & 0 deletions src/node_worker.cc
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include "node_perf.h"
#include "node_profiling.h"
#include "node_snapshot_builder.h"
#include "path.h"
#include "permission/permission.h"
#include "util-inl.h"
#include "v8-cppgc.h"
Expand Down Expand Up @@ -503,6 +504,263 @@ 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_fs_vfs, "--allow-fs-vfs") \
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,
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)) {
// Keep the worker's original grant string so the list stays
// consistent with parent raw entries (e.g. when copying parent
// for "*") and with CLI rebuild.
out.push_back(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->allow_fs_read, parent->allow_fs_read);
FilterPathListToParentSubset(env, &w->allow_fs_write, parent->allow_fs_write);
}

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 ExecArgvHasPermissionToken(const std::vector<std::string>& argv) {
for (const std::string& tok : argv) {
if (IsPermissionCliToken(tok)) return true;
}
return false;
}

void ClampWorkerPermissionToParent(
Environment* env,
PerIsolateOptions* worker_opts,
const std::vector<std::string>& exec_argv_out) {
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;

// Ceiling (inherit parent grants) only when the worker did not ask for any
// permission-related configuration. If execArgv contains permission tokens
// (or options already reflect them), intersect so an explicit
// `--permission` without fs grants stays restrictive instead of being
// widened back to the parent allowlist.
const bool permission_requested = WorkerConfiguredPermission(w) ||
ExecArgvHasPermissionToken(exec_argv_out);
if (!permission_requested) {
ApplyParentPermissionCeiling(w, parent);
} else {
if (!w->permission && !w->permission_audit) {
w->permission = true;
}
IntersectPermissionGrants(env, w, parent);
}
}

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 @@ -559,6 +817,11 @@ void Worker::New(const FunctionCallbackInfo<Value>& args) {
THROW_ERR_OPERATION_FAILED(env, "Failed to copy environment variables");
}

// Keep the main-branch gate so custom env / NODE_OPTIONS and execArgv
// (including []) still go through fresh option parsing. Empty execArgv
// must not skip that path — only the permission ceiling below differs.
const bool explicit_exec_argv = args[2]->IsArray();

if (args[1]->IsObject() || args[2]->IsArray()) {
per_isolate_opts.reset(new PerIsolateOptions());

Expand Down Expand Up @@ -682,6 +945,50 @@ void Worker::New(const FunctionCallbackInfo<Value>& args) {
per_isolate_opts = env->isolate_data()->options()->Clone();
}

// Any explicit execArgv (including []): clamp permission grants to the
// parent. [] stays on the fresh-parse path above so NODE_OPTIONS still
// applies; the ceiling then re-attaches parent permission grants.
if (env->permission()->enabled() && per_isolate_opts && explicit_exec_argv) {
// Prefer the original JS execArgv strings for "did the caller ask for
// permission flags?" — Parse may consume known tokens out of
// exec_argv_out, which would otherwise make a restrictive
// `--permission` (no fs grants) look unconfigured and hit the ceiling.
std::vector<std::string> permission_argv_probe = exec_argv_out;
if (args[2]->IsArray()) {
Local<Array> array = args[2].As<Array>();
uint32_t length = array->Length();
for (uint32_t i = 0; i < length; i++) {
Local<Value> arg;
if (!array->Get(env->context(), i).ToLocal(&arg)) {
return;
}
Local<String> arg_v8;
if (!arg->ToString(env->context()).ToLocal(&arg_v8)) {
return;
}
Utf8Value arg_utf8_value(args.GetIsolate(), arg_v8);
permission_argv_probe.emplace_back(arg_utf8_value.out(),
arg_utf8_value.length());
}
}
ClampWorkerPermissionToParent(
env, per_isolate_opts.get(), permission_argv_probe);
// Match permissions.md: --permission wins over --permission-audit.
EnvironmentOptions* clamped =
per_isolate_opts->get_per_env_options();
if (clamped != nullptr && clamped->permission) {
clamped->permission_audit = false;
}
RebuildExecArgvOutFromPermissionOptions(per_isolate_opts.get(),
&exec_argv_out);
// Workers load via LOAD_SCRIPT after Environment construction, so
// argv_ has no script path yet. Without this, Environment's implicit
// entrypoint grant pushes empty argv[1], PathResolve turns that into
// cwd, and a restrictive `--permission` (no fs grants) still allows
// reading anything under cwd — which broke the empty-grant cases.
per_isolate_opts->get_per_env_options()->has_eval_string = true;
}

// 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