Skip to content

Commit 3458f30

Browse files
committed
permission: clamp Worker grants for explicit execArgv (SEMVER-MAJOR)
When the parent runs with the Permission Model, an explicit Worker execArgv (including []) cannot drop or exceed the parent's permission-related grants. - No worker permission flags configured → apply parent grant ceiling - Worker permission flags set → intersect (restrict OK, escalate no) - Rebuild permission argv from clamped options; drop space-form path tokens - Default Worker (omit execArgv) unchanged Signed-off-by: yunshingng <yunshingng25@gmail.com>
1 parent 9f1e44c commit 3458f30

4 files changed

Lines changed: 870 additions & 0 deletions

File tree

‎doc/api/permissions.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ changes:
3838
description: This feature is no longer experimental.
3939
-->
4040

41+
<!-- worker-execargv-permission-ceiling -->
42+
When the Permission Model is enabled in the parent process, creating a
43+
`worker_threads.Worker` with an explicit `execArgv` option (including an empty
44+
array) no longer allows the worker to obtain a wider permission-related grant
45+
set than the parent. Non-permission `execArgv` flags are unaffected. This is a
46+
breaking change relative to earlier releases where `execArgv: []` could drop
47+
the parent's Permission Model grants.
48+
49+
4150
> Stability: 2 - Stable
4251
4352
The Node.js Permission Model is a mechanism for restricting access to specific

‎doc/api/worker_threads.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1605,6 +1605,13 @@ changes:
16051605
description: The `resourceLimits` option was introduced.
16061606
-->
16071607
1608+
<!-- worker-execargv-permission-ceiling -->
1609+
**Permission Model (breaking):** If the parent process runs with the
1610+
Permission Model enabled, an explicit `execArgv` (including `[]`) does not
1611+
disable or exceed the parent's permission-related grants. See the
1612+
[Permission Model](permissions.md#permission-model) documentation.
1613+
1614+
16081615
* `filename` {string|URL} The path to the Worker's main script or module. Must
16091616
be either an absolute path or a relative path (i.e. relative to the
16101617
current working directory) starting with `./` or `../`, or a WHATWG `URL`

‎src/node_worker.cc‎

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include "node_profiling.h"
1212
#include "node_snapshot_builder.h"
1313
#include "permission/permission.h"
14+
#include "path.h"
1415
#include "util-inl.h"
1516
#include "v8-cppgc.h"
1617
#include "v8-profiler.h"
@@ -504,6 +505,256 @@ Worker::~Worker() {
504505
Debug(this, "Worker %llu destroyed", thread_id_.id);
505506
}
506507

508+
509+
// SEMVER-MAJOR: Permission ceiling for Worker explicit execArgv.
510+
// SEMVER-MAJOR: Permission ceiling for Worker when execArgv is explicit
511+
// (including []). Default Worker (no execArgv) is unchanged.
512+
//
513+
// After options parse, NODE_OPTIONS and repeated --allow-* are already in
514+
// EnvironmentOptions. Runtime FSPermission remains authoritative for FS checks;
515+
// path filtering here is create-time only (prefix / exact / *).
516+
//
517+
// Parent allow-list containing "*" already means unrestricted FS for that
518+
// dimension; FilterPathList early-return keeps worker paths (cannot exceed *).
519+
520+
namespace {
521+
522+
bool WorkerConfiguredPermission(const EnvironmentOptions* w) {
523+
if (w == nullptr) return false;
524+
if (w->permission || w->permission_audit) return true;
525+
if (!w->allow_fs_read.empty() || !w->allow_fs_write.empty()) return true;
526+
return w->allow_addons || w->allow_inspector || w->allow_child_process ||
527+
w->allow_net || w->allow_wasi || w->allow_ffi ||
528+
w->allow_openssl_store || w->allow_worker_threads;
529+
}
530+
531+
void ApplyParentPermissionCeiling(EnvironmentOptions* w,
532+
const EnvironmentOptions* parent) {
533+
w->permission = true;
534+
w->permission_audit = parent->permission_audit;
535+
w->allow_addons = parent->allow_addons;
536+
w->allow_inspector = parent->allow_inspector;
537+
w->allow_child_process = parent->allow_child_process;
538+
w->allow_net = parent->allow_net;
539+
w->allow_wasi = parent->allow_wasi;
540+
w->allow_ffi = parent->allow_ffi;
541+
w->allow_openssl_store = parent->allow_openssl_store;
542+
w->allow_worker_threads = parent->allow_worker_threads;
543+
w->allow_fs_read = parent->allow_fs_read;
544+
w->allow_fs_write = parent->allow_fs_write;
545+
}
546+
547+
void NormalizePathForCompare(std::string* s) {
548+
while (s->size() > 1 &&
549+
(s->back() == '/' || s->back() == static_cast<char>(92))) {
550+
s->pop_back();
551+
}
552+
#ifdef _WIN32
553+
for (char& c : *s) {
554+
if (c >= 'A' && c <= 'Z') {
555+
c = static_cast<char>(c - 'A' + 'a');
556+
}
557+
if (c == '/') c = static_cast<char>(92);
558+
}
559+
#endif
560+
}
561+
562+
std::string ResolveForCompare(Environment* env, const std::string& in) {
563+
if (in.empty() || in == "*") return in;
564+
std::string resolved =
565+
PathResolve(env, std::vector<std::string_view>{std::string_view(in)});
566+
if (resolved.empty()) resolved = in;
567+
NormalizePathForCompare(&resolved);
568+
return resolved;
569+
}
570+
571+
// Create-time filter only.
572+
bool PathCoveredByParentEntry(Environment* env,
573+
const std::string& parent_raw,
574+
const std::string& requested_raw) {
575+
if (parent_raw == "*") return true;
576+
const std::string parent = ResolveForCompare(env, parent_raw);
577+
const std::string requested = ResolveForCompare(env, requested_raw);
578+
if (parent.empty()) return false;
579+
if (requested == parent) return true;
580+
if (requested.size() <= parent.size()) return false;
581+
if (requested.compare(0, parent.size(), parent) != 0) return false;
582+
const char next = requested[parent.size()];
583+
return next == '/' || next == static_cast<char>(92);
584+
}
585+
586+
bool ParentListHasWildcard(const std::vector<std::string>& parent) {
587+
for (const std::string& entry : parent) {
588+
if (entry == "*") return true;
589+
}
590+
return false;
591+
}
592+
593+
void FilterPathListToParentSubset(Environment* env,
594+
EnvironmentOptions* w,
595+
std::vector<std::string>* worker,
596+
const std::vector<std::string>& parent) {
597+
if (worker == nullptr) return;
598+
599+
// Worker enabled permission but listed no paths → keep empty (restrict).
600+
if (worker->empty()) {
601+
if (w->permission || w->permission_audit) return;
602+
*worker = parent;
603+
return;
604+
}
605+
606+
// Parent "*" → FS already unrestricted; worker paths cannot exceed parent.
607+
if (ParentListHasWildcard(parent)) return;
608+
609+
std::vector<std::string> out;
610+
out.reserve(worker->size());
611+
bool saw_star = false;
612+
for (const std::string& wpath : *worker) {
613+
if (wpath == "*") {
614+
saw_star = true;
615+
continue;
616+
}
617+
for (const std::string& entry : parent) {
618+
if (PathCoveredByParentEntry(env, entry, wpath)) {
619+
out.push_back(wpath);
620+
break;
621+
}
622+
}
623+
}
624+
if (saw_star && out.empty()) {
625+
*worker = parent;
626+
return;
627+
}
628+
*worker = std::move(out);
629+
}
630+
631+
void IntersectPermissionGrants(Environment* env,
632+
EnvironmentOptions* w,
633+
const EnvironmentOptions* parent) {
634+
w->permission = true;
635+
w->permission_audit = w->permission_audit || parent->permission_audit;
636+
637+
w->allow_addons = w->allow_addons && parent->allow_addons;
638+
w->allow_inspector = w->allow_inspector && parent->allow_inspector;
639+
w->allow_child_process =
640+
w->allow_child_process && parent->allow_child_process;
641+
w->allow_net = w->allow_net && parent->allow_net;
642+
w->allow_wasi = w->allow_wasi && parent->allow_wasi;
643+
w->allow_ffi = w->allow_ffi && parent->allow_ffi;
644+
w->allow_openssl_store =
645+
w->allow_openssl_store && parent->allow_openssl_store;
646+
w->allow_worker_threads =
647+
w->allow_worker_threads && parent->allow_worker_threads;
648+
649+
FilterPathListToParentSubset(env, w, &w->allow_fs_read, parent->allow_fs_read);
650+
FilterPathListToParentSubset(
651+
env, w, &w->allow_fs_write, parent->allow_fs_write);
652+
}
653+
654+
void ClampWorkerPermissionToParent(Environment* env,
655+
PerIsolateOptions* worker_opts) {
656+
if (worker_opts == nullptr || env == nullptr ||
657+
!env->permission()->enabled()) {
658+
return;
659+
}
660+
EnvironmentOptions* parent =
661+
env->isolate_data()->options()->get_per_env_options();
662+
EnvironmentOptions* w = worker_opts->get_per_env_options();
663+
if (parent == nullptr || w == nullptr) return;
664+
665+
if (!WorkerConfiguredPermission(w)) {
666+
ApplyParentPermissionCeiling(w, parent);
667+
} else {
668+
IntersectPermissionGrants(env, w, parent);
669+
}
670+
}
671+
672+
// Exact flag name or flag=value (not a longer unrelated prefix).
673+
bool IsPermissionCliToken(const std::string& a) {
674+
if (a == "--permission" || a == "--permission-audit") return true;
675+
static const char* kFlags[] = {
676+
"--allow-fs-read",
677+
"--allow-fs-write",
678+
"--allow-addons",
679+
"--allow-inspector",
680+
"--allow-child-process",
681+
"--allow-net",
682+
"--allow-wasi",
683+
"--allow-ffi",
684+
"--allow-openssl-store",
685+
"--allow-worker",
686+
};
687+
for (const char* flag : kFlags) {
688+
const size_t n = std::char_traits<char>::length(flag);
689+
if (a == flag) return true;
690+
if (a.size() > n && a.compare(0, n, flag) == 0 && a[n] == '=') return true;
691+
}
692+
return false;
693+
}
694+
695+
bool PermissionFlagTakesNextArg(const std::string& a) {
696+
return a == "--allow-fs-read" || a == "--allow-fs-write";
697+
}
698+
699+
bool PathSafeForAllowFlag(const std::string& path) {
700+
if (path.empty()) return false;
701+
for (unsigned char c : path) {
702+
if (c == 0 || c == 10 || c == 13) return false;
703+
}
704+
return true;
705+
}
706+
707+
void RebuildExecArgvOutFromPermissionOptions(
708+
PerIsolateOptions* worker_opts, std::vector<std::string>* exec_argv_out) {
709+
if (worker_opts == nullptr || exec_argv_out == nullptr) return;
710+
EnvironmentOptions* w = worker_opts->get_per_env_options();
711+
if (w == nullptr || !w->permission) return;
712+
713+
std::vector<std::string> kept;
714+
kept.reserve(exec_argv_out->size());
715+
for (size_t i = 0; i < exec_argv_out->size(); ++i) {
716+
const std::string& tok = (*exec_argv_out)[i];
717+
if (tok.empty()) continue;
718+
if (IsPermissionCliToken(tok)) {
719+
if (PermissionFlagTakesNextArg(tok) && i + 1 < exec_argv_out->size()) {
720+
const std::string& next = (*exec_argv_out)[i + 1];
721+
if (!next.empty() && next[0] != '-') ++i;
722+
}
723+
continue;
724+
}
725+
kept.push_back(tok);
726+
}
727+
728+
std::vector<std::string> out;
729+
out.reserve(kept.size() + 16 + w->allow_fs_read.size() +
730+
w->allow_fs_write.size());
731+
out.emplace_back("");
732+
for (const std::string& tok : kept) out.push_back(tok);
733+
734+
out.push_back("--permission");
735+
if (w->permission_audit) out.push_back("--permission-audit");
736+
if (w->allow_addons) out.push_back("--allow-addons");
737+
if (w->allow_inspector) out.push_back("--allow-inspector");
738+
if (w->allow_child_process) out.push_back("--allow-child-process");
739+
if (w->allow_net) out.push_back("--allow-net");
740+
if (w->allow_wasi) out.push_back("--allow-wasi");
741+
if (w->allow_ffi) out.push_back("--allow-ffi");
742+
if (w->allow_openssl_store) out.push_back("--allow-openssl-store");
743+
if (w->allow_worker_threads) out.push_back("--allow-worker");
744+
for (const std::string& p : w->allow_fs_read) {
745+
if (!PathSafeForAllowFlag(p)) continue;
746+
out.push_back("--allow-fs-read=" + p);
747+
}
748+
for (const std::string& p : w->allow_fs_write) {
749+
if (!PathSafeForAllowFlag(p)) continue;
750+
out.push_back("--allow-fs-write=" + p);
751+
}
752+
*exec_argv_out = std::move(out);
753+
}
754+
755+
} // namespace
756+
757+
507758
void Worker::New(const FunctionCallbackInfo<Value>& args) {
508759
Environment* env = Environment::GetCurrent(args);
509760
THROW_IF_INSUFFICIENT_PERMISSIONS(
@@ -683,6 +934,16 @@ void Worker::New(const FunctionCallbackInfo<Value>& args) {
683934
per_isolate_opts = env->isolate_data()->options()->Clone();
684935
}
685936

937+
//
938+
939+
// Explicit execArgv only (including []). Default Worker path unchanged.
940+
if (env->permission()->enabled() && per_isolate_opts &&
941+
args[2]->IsArray()) {
942+
ClampWorkerPermissionToParent(env, per_isolate_opts.get());
943+
RebuildExecArgvOutFromPermissionOptions(per_isolate_opts.get(),
944+
&exec_argv_out);
945+
}
946+
686947
// Internal workers should not wait for inspector frontend to connect or
687948
// break on the first line of internal scripts. Module loader threads are
688949
// essential to load user codes and must not be blocked by the inspector

0 commit comments

Comments
 (0)