Skip to content

Commit d07766c

Browse files
committed
permission: clamp Worker grants for explicit execArgv (SEMVER-MAJOR)
When the parent has the Permission Model enabled, an explicit Worker execArgv (including []) cannot obtain a wider permission-related grant set than the parent. Default Worker (no execArgv) is unchanged. C++ ceiling/intersection after options parse; single PERMISSION_BOOL_FLAGS table; resolved FS paths in filter; space-form --allow-fs-* consumes next arg. Signed-off-by: yunshingng <yunshingng25@gmail.com>
1 parent 2247054 commit d07766c

4 files changed

Lines changed: 662 additions & 4 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: 252 additions & 4 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"
@@ -190,6 +191,8 @@ class WorkerThreadData {
190191
return;
191192
}
192193

194+
SetIsolateUpForNode(isolate);
195+
193196
// Be sure it's called before Environment::InitializeDiagnostics()
194197
// so that this callback stays when the callback of
195198
// --heapsnapshot-near-heap-limit gets is popped.
@@ -278,7 +281,6 @@ size_t Worker::NearHeapLimit(void* data, size_t current_heap_limit,
278281
Environment* env = worker->env();
279282
if (env != nullptr) {
280283
DCHECK(!env->is_in_heapsnapshot_heap_limit_callback());
281-
DCHECK(!env->is_in_heap_profile_near_heap_limit_callback());
282284
Debug(env,
283285
DebugCategory::DIAGNOSTICS,
284286
"Throwing ERR_WORKER_OUT_OF_MEMORY, "
@@ -503,6 +505,245 @@ Worker::~Worker() {
503505
Debug(this, "Worker %llu destroyed", thread_id_.id);
504506
}
505507

508+
509+
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+
// Boolean --allow-* dimensions are listed once in PERMISSION_BOOL_FLAGS so
518+
// ceiling / intersect / CLI token / rebuild cannot drift.
519+
520+
namespace {
521+
522+
// Single source of truth for boolean permission dimensions (not fs path lists).
523+
#define PERMISSION_BOOL_FLAGS(V) \
524+
V(allow_addons, "--allow-addons") \
525+
V(allow_inspector, "--allow-inspector") \
526+
V(allow_child_process, "--allow-child-process") \
527+
V(allow_net, "--allow-net") \
528+
V(allow_wasi, "--allow-wasi") \
529+
V(allow_ffi, "--allow-ffi") \
530+
V(allow_openssl_store, "--allow-openssl-store") \
531+
V(allow_worker_threads, "--allow-worker")
532+
533+
bool WorkerConfiguredPermission(const EnvironmentOptions* w) {
534+
if (w == nullptr) return false;
535+
if (w->permission || w->permission_audit) return true;
536+
if (!w->allow_fs_read.empty() || !w->allow_fs_write.empty()) return true;
537+
#define V(field, flag) || w->field
538+
return false PERMISSION_BOOL_FLAGS(V);
539+
#undef V
540+
}
541+
542+
void ApplyParentPermissionCeiling(EnvironmentOptions* w,
543+
const EnvironmentOptions* parent) {
544+
w->permission = true;
545+
w->permission_audit = parent->permission_audit;
546+
#define V(field, flag) w->field = parent->field;
547+
PERMISSION_BOOL_FLAGS(V)
548+
#undef V
549+
w->allow_fs_read = parent->allow_fs_read;
550+
w->allow_fs_write = parent->allow_fs_write;
551+
}
552+
553+
void NormalizePathForCompare(std::string* s) {
554+
while (s->size() > 1 &&
555+
(s->back() == '/' || s->back() == static_cast<char>(92))) {
556+
s->pop_back();
557+
}
558+
#ifdef _WIN32
559+
for (char& c : *s) {
560+
if (c >= 'A' && c <= 'Z') {
561+
c = static_cast<char>(c - 'A' + 'a');
562+
}
563+
if (c == '/') c = static_cast<char>(92);
564+
}
565+
#endif
566+
}
567+
568+
std::string ResolveForCompare(Environment* env, const std::string& in) {
569+
if (in.empty() || in == "*") return in;
570+
std::string resolved =
571+
PathResolve(env, std::vector<std::string_view>{std::string_view(in)});
572+
if (resolved.empty()) resolved = in;
573+
NormalizePathForCompare(&resolved);
574+
return resolved;
575+
}
576+
577+
// parent_raw may be raw; resolved_requested is already ResolveForCompare'd.
578+
bool ParentEntryCoversResolvedPath(Environment* env,
579+
const std::string& parent_raw,
580+
const std::string& resolved_requested) {
581+
if (parent_raw == "*") return true;
582+
const std::string parent = ResolveForCompare(env, parent_raw);
583+
if (parent.empty()) return false;
584+
if (resolved_requested == parent) return true;
585+
if (resolved_requested.size() <= parent.size()) return false;
586+
if (resolved_requested.compare(0, parent.size(), parent) != 0) return false;
587+
const char next = resolved_requested[parent.size()];
588+
return next == '/' || next == static_cast<char>(92);
589+
}
590+
591+
bool ParentListHasWildcard(const std::vector<std::string>& parent) {
592+
for (const std::string& entry : parent) {
593+
if (entry == "*") return true;
594+
}
595+
return false;
596+
}
597+
598+
void FilterPathListToParentSubset(Environment* env,
599+
EnvironmentOptions* w,
600+
std::vector<std::string>* worker,
601+
const std::vector<std::string>& parent) {
602+
if (worker == nullptr) return;
603+
604+
// Worker listed no fs paths → keep empty (restrict).
605+
// Callers set w->permission = true before this runs.
606+
if (worker->empty()) {
607+
return;
608+
}
609+
610+
// Parent "*" → FS already unrestricted; worker paths cannot exceed parent.
611+
if (ParentListHasWildcard(parent)) return;
612+
613+
std::vector<std::string> out;
614+
out.reserve(worker->size());
615+
bool saw_star = false;
616+
for (const std::string& wpath : *worker) {
617+
if (wpath == "*") {
618+
saw_star = true;
619+
continue;
620+
}
621+
const std::string resolved_wpath = ResolveForCompare(env, wpath);
622+
for (const std::string& entry : parent) {
623+
if (ParentEntryCoversResolvedPath(env, entry, resolved_wpath)) {
624+
out.push_back(resolved_wpath);
625+
break;
626+
}
627+
}
628+
}
629+
// "*" alone or with concrete paths → full parent list (not a subset).
630+
if (saw_star) {
631+
*worker = parent;
632+
return;
633+
}
634+
*worker = std::move(out);
635+
}
636+
637+
void IntersectPermissionGrants(Environment* env,
638+
EnvironmentOptions* w,
639+
const EnvironmentOptions* parent) {
640+
w->permission = true;
641+
w->permission_audit = w->permission_audit || parent->permission_audit;
642+
#define V(field, flag) w->field = w->field && parent->field;
643+
PERMISSION_BOOL_FLAGS(V)
644+
#undef V
645+
FilterPathListToParentSubset(env, w, &w->allow_fs_read, parent->allow_fs_read);
646+
FilterPathListToParentSubset(
647+
env, w, &w->allow_fs_write, parent->allow_fs_write);
648+
}
649+
650+
void ClampWorkerPermissionToParent(Environment* env,
651+
PerIsolateOptions* worker_opts) {
652+
if (worker_opts == nullptr || env == nullptr ||
653+
!env->permission()->enabled()) {
654+
return;
655+
}
656+
EnvironmentOptions* parent =
657+
env->isolate_data()->options()->get_per_env_options();
658+
EnvironmentOptions* w = worker_opts->get_per_env_options();
659+
if (parent == nullptr || w == nullptr) return;
660+
661+
if (!WorkerConfiguredPermission(w)) {
662+
ApplyParentPermissionCeiling(w, parent);
663+
} else {
664+
IntersectPermissionGrants(env, w, parent);
665+
}
666+
}
667+
668+
bool IsPermissionCliToken(const std::string& a) {
669+
if (a == "--permission" || a == "--permission-audit") return true;
670+
if (a == "--allow-fs-read" || a == "--allow-fs-write") return true;
671+
if (a.rfind("--allow-fs-read=", 0) == 0) return true;
672+
if (a.rfind("--allow-fs-write=", 0) == 0) return true;
673+
#define V(field, flag) \
674+
if (a == flag) return true; \
675+
{ \
676+
const size_t n = sizeof(flag) - 1; \
677+
if (a.size() > n && a.compare(0, n, flag) == 0 && a[n] == '=') \
678+
return true; \
679+
}
680+
PERMISSION_BOOL_FLAGS(V)
681+
#undef V
682+
return false;
683+
}
684+
685+
bool PermissionFlagTakesNextArg(const std::string& a) {
686+
return a == "--allow-fs-read" || a == "--allow-fs-write";
687+
}
688+
689+
bool PathSafeForAllowFlag(const std::string& path) {
690+
if (path.empty()) return false;
691+
for (unsigned char c : path) {
692+
if (c == 0 || c == 10 || c == 13) return false;
693+
}
694+
return true;
695+
}
696+
697+
void RebuildExecArgvOutFromPermissionOptions(
698+
PerIsolateOptions* worker_opts, std::vector<std::string>* exec_argv_out) {
699+
if (worker_opts == nullptr || exec_argv_out == nullptr) return;
700+
EnvironmentOptions* w = worker_opts->get_per_env_options();
701+
if (w == nullptr || !w->permission) return;
702+
703+
std::vector<std::string> kept;
704+
kept.reserve(exec_argv_out->size());
705+
for (size_t i = 0; i < exec_argv_out->size(); ++i) {
706+
const std::string& tok = (*exec_argv_out)[i];
707+
if (tok.empty()) continue;
708+
if (IsPermissionCliToken(tok)) {
709+
// Space-form --allow-fs-read/--allow-fs-write always consume next token
710+
// (paths may start with '-').
711+
if (PermissionFlagTakesNextArg(tok) && i + 1 < exec_argv_out->size()) {
712+
++i;
713+
}
714+
continue;
715+
}
716+
kept.push_back(tok);
717+
}
718+
719+
std::vector<std::string> out;
720+
out.reserve(kept.size() + 16 + w->allow_fs_read.size() +
721+
w->allow_fs_write.size());
722+
for (const std::string& tok : kept) out.push_back(tok);
723+
724+
out.push_back("--permission");
725+
if (w->permission_audit) out.push_back("--permission-audit");
726+
#define V(field, flag) \
727+
if (w->field) out.push_back(flag);
728+
PERMISSION_BOOL_FLAGS(V)
729+
#undef V
730+
for (const std::string& p : w->allow_fs_read) {
731+
if (!PathSafeForAllowFlag(p)) continue;
732+
out.push_back("--allow-fs-read=" + p);
733+
}
734+
for (const std::string& p : w->allow_fs_write) {
735+
if (!PathSafeForAllowFlag(p)) continue;
736+
out.push_back("--allow-fs-write=" + p);
737+
}
738+
*exec_argv_out = std::move(out);
739+
}
740+
741+
#undef PERMISSION_BOOL_FLAGS
742+
743+
} // namespace
744+
745+
746+
506747
void Worker::New(const FunctionCallbackInfo<Value>& args) {
507748
Environment* env = Environment::GetCurrent(args);
508749
THROW_IF_INSUFFICIENT_PERMISSIONS(
@@ -682,6 +923,16 @@ void Worker::New(const FunctionCallbackInfo<Value>& args) {
682923
per_isolate_opts = env->isolate_data()->options()->Clone();
683924
}
684925

926+
//
927+
928+
// Explicit execArgv only (including []). Default Worker path unchanged.
929+
if (env->permission()->enabled() && per_isolate_opts &&
930+
args[2]->IsArray()) {
931+
ClampWorkerPermissionToParent(env, per_isolate_opts.get());
932+
RebuildExecArgvOutFromPermissionOptions(per_isolate_opts.get(),
933+
&exec_argv_out);
934+
}
935+
685936
// Internal workers should not wait for inspector frontend to connect or
686937
// break on the first line of internal scripts. Module loader threads are
687938
// essential to load user codes and must not be blocked by the inspector
@@ -1108,9 +1359,6 @@ void Worker::StopHeapProfile(const FunctionCallbackInfo<Value>& args) {
11081359
std::ostringstream out_stream;
11091360
bool success =
11101361
node::SerializeHeapProfile(worker_env->isolate(), out_stream);
1111-
if (success) {
1112-
worker_env->isolate()->GetHeapProfiler()->StopSamplingHeapProfiler();
1113-
}
11141362
env->SetImmediateThreadsafe(
11151363
[taker = std::move(taker),
11161364
out_stream = std::move(out_stream),

0 commit comments

Comments
 (0)