Skip to content

Commit b6c034b

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. Signed-off-by: yunshingng <yunshingng25@gmail.com>
1 parent 1309975 commit b6c034b

4 files changed

Lines changed: 651 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: 227 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"
@@ -503,6 +504,224 @@ Worker::~Worker() {
503504
Debug(this, "Worker %llu destroyed", thread_id_.id);
504505
}
505506

507+
508+
509+
// SEMVER-MAJOR: Permission ceiling for Worker when execArgv is explicit
510+
// (including []). Default Worker (no execArgv) is unchanged.
511+
512+
namespace {
513+
514+
#define PERMISSION_BOOL_FLAGS(V) \
515+
V(allow_addons, "--allow-addons") \
516+
V(allow_inspector, "--allow-inspector") \
517+
V(allow_child_process, "--allow-child-process") \
518+
V(allow_net, "--allow-net") \
519+
V(allow_wasi, "--allow-wasi") \
520+
V(allow_ffi, "--allow-ffi") \
521+
V(allow_openssl_store, "--allow-openssl-store") \
522+
V(allow_worker_threads, "--allow-worker")
523+
524+
bool WorkerConfiguredPermission(const EnvironmentOptions* w) {
525+
if (w == nullptr) return false;
526+
if (w->permission || w->permission_audit) return true;
527+
if (!w->allow_fs_read.empty() || !w->allow_fs_write.empty()) return true;
528+
#define V(field, flag) || w->field
529+
return false PERMISSION_BOOL_FLAGS(V);
530+
#undef V
531+
}
532+
533+
void ApplyParentPermissionCeiling(EnvironmentOptions* w,
534+
const EnvironmentOptions* parent) {
535+
w->permission = true;
536+
w->permission_audit = parent->permission_audit;
537+
#define V(field, flag) w->field = parent->field;
538+
PERMISSION_BOOL_FLAGS(V)
539+
#undef V
540+
w->allow_fs_read = parent->allow_fs_read;
541+
w->allow_fs_write = parent->allow_fs_write;
542+
}
543+
544+
void NormalizePathForCompare(std::string* s) {
545+
while (s->size() > 1 &&
546+
(s->back() == '/' || s->back() == static_cast<char>(92))) {
547+
s->pop_back();
548+
}
549+
#ifdef _WIN32
550+
for (char& c : *s) {
551+
if (c >= 'A' && c <= 'Z') {
552+
c = static_cast<char>(c - 'A' + 'a');
553+
}
554+
if (c == '/') c = static_cast<char>(92);
555+
}
556+
#endif
557+
}
558+
559+
std::string ResolveForCompare(Environment* env, const std::string& in) {
560+
if (in.empty() || in == "*") return in;
561+
std::string resolved =
562+
PathResolve(env, std::vector<std::string_view>{std::string_view(in)});
563+
if (resolved.empty()) resolved = in;
564+
NormalizePathForCompare(&resolved);
565+
return resolved;
566+
}
567+
568+
bool ParentEntryCoversResolvedPath(Environment* env,
569+
const std::string& parent_raw,
570+
const std::string& resolved_requested) {
571+
if (parent_raw == "*") return true;
572+
const std::string parent = ResolveForCompare(env, parent_raw);
573+
if (parent.empty()) return false;
574+
if (resolved_requested == parent) return true;
575+
if (resolved_requested.size() <= parent.size()) return false;
576+
if (resolved_requested.compare(0, parent.size(), parent) != 0) return false;
577+
const char next = resolved_requested[parent.size()];
578+
return next == '/' || next == static_cast<char>(92);
579+
}
580+
581+
bool ParentListHasWildcard(const std::vector<std::string>& parent) {
582+
for (const std::string& entry : parent) {
583+
if (entry == "*") return true;
584+
}
585+
return false;
586+
}
587+
588+
void FilterPathListToParentSubset(Environment* env,
589+
EnvironmentOptions* w,
590+
std::vector<std::string>* worker,
591+
const std::vector<std::string>& parent) {
592+
if (worker == nullptr) return;
593+
if (worker->empty()) return;
594+
if (ParentListHasWildcard(parent)) return;
595+
596+
std::vector<std::string> out;
597+
out.reserve(worker->size());
598+
bool saw_star = false;
599+
for (const std::string& wpath : *worker) {
600+
if (wpath == "*") {
601+
saw_star = true;
602+
continue;
603+
}
604+
const std::string resolved_wpath = ResolveForCompare(env, wpath);
605+
for (const std::string& entry : parent) {
606+
if (ParentEntryCoversResolvedPath(env, entry, resolved_wpath)) {
607+
out.push_back(resolved_wpath);
608+
break;
609+
}
610+
}
611+
}
612+
if (saw_star) {
613+
*worker = parent;
614+
return;
615+
}
616+
*worker = std::move(out);
617+
}
618+
619+
void IntersectPermissionGrants(Environment* env,
620+
EnvironmentOptions* w,
621+
const EnvironmentOptions* parent) {
622+
w->permission = true;
623+
w->permission_audit = w->permission_audit || parent->permission_audit;
624+
#define V(field, flag) w->field = w->field && parent->field;
625+
PERMISSION_BOOL_FLAGS(V)
626+
#undef V
627+
FilterPathListToParentSubset(env, w, &w->allow_fs_read, parent->allow_fs_read);
628+
FilterPathListToParentSubset(
629+
env, w, &w->allow_fs_write, parent->allow_fs_write);
630+
}
631+
632+
void ClampWorkerPermissionToParent(Environment* env,
633+
PerIsolateOptions* worker_opts) {
634+
if (worker_opts == nullptr || env == nullptr ||
635+
!env->permission()->enabled()) {
636+
return;
637+
}
638+
EnvironmentOptions* parent =
639+
env->isolate_data()->options()->get_per_env_options();
640+
EnvironmentOptions* w = worker_opts->get_per_env_options();
641+
if (parent == nullptr || w == nullptr) return;
642+
643+
if (!WorkerConfiguredPermission(w)) {
644+
ApplyParentPermissionCeiling(w, parent);
645+
} else {
646+
IntersectPermissionGrants(env, w, parent);
647+
}
648+
}
649+
650+
bool IsPermissionCliToken(const std::string& a) {
651+
if (a == "--permission" || a == "--permission-audit") return true;
652+
if (a == "--allow-fs-read" || a == "--allow-fs-write") return true;
653+
if (a.rfind("--allow-fs-read=", 0) == 0) return true;
654+
if (a.rfind("--allow-fs-write=", 0) == 0) return true;
655+
#define V(field, flag) \
656+
if (a == flag) return true; \
657+
{ \
658+
const size_t n = sizeof(flag) - 1; \
659+
if (a.size() > n && a.compare(0, n, flag) == 0 && a[n] == '=') \
660+
return true; \
661+
}
662+
PERMISSION_BOOL_FLAGS(V)
663+
#undef V
664+
return false;
665+
}
666+
667+
bool PermissionFlagTakesNextArg(const std::string& a) {
668+
return a == "--allow-fs-read" || a == "--allow-fs-write";
669+
}
670+
671+
bool PathSafeForAllowFlag(const std::string& path) {
672+
if (path.empty()) return false;
673+
for (unsigned char c : path) {
674+
if (c == 0 || c == 10 || c == 13) return false;
675+
}
676+
return true;
677+
}
678+
679+
void RebuildExecArgvOutFromPermissionOptions(
680+
PerIsolateOptions* worker_opts, std::vector<std::string>* exec_argv_out) {
681+
if (worker_opts == nullptr || exec_argv_out == nullptr) return;
682+
EnvironmentOptions* w = worker_opts->get_per_env_options();
683+
if (w == nullptr || !w->permission) return;
684+
685+
std::vector<std::string> kept;
686+
kept.reserve(exec_argv_out->size());
687+
for (size_t i = 0; i < exec_argv_out->size(); ++i) {
688+
const std::string& tok = (*exec_argv_out)[i];
689+
if (tok.empty()) continue;
690+
if (IsPermissionCliToken(tok)) {
691+
if (PermissionFlagTakesNextArg(tok) && i + 1 < exec_argv_out->size()) {
692+
++i;
693+
}
694+
continue;
695+
}
696+
kept.push_back(tok);
697+
}
698+
699+
std::vector<std::string> out;
700+
out.reserve(kept.size() + 16 + w->allow_fs_read.size() +
701+
w->allow_fs_write.size());
702+
for (const std::string& tok : kept) out.push_back(tok);
703+
704+
out.push_back("--permission");
705+
if (w->permission_audit) out.push_back("--permission-audit");
706+
#define V(field, flag) \
707+
if (w->field) out.push_back(flag);
708+
PERMISSION_BOOL_FLAGS(V)
709+
#undef V
710+
for (const std::string& p : w->allow_fs_read) {
711+
if (!PathSafeForAllowFlag(p)) continue;
712+
out.push_back("--allow-fs-read=" + p);
713+
}
714+
for (const std::string& p : w->allow_fs_write) {
715+
if (!PathSafeForAllowFlag(p)) continue;
716+
out.push_back("--allow-fs-write=" + p);
717+
}
718+
*exec_argv_out = std::move(out);
719+
}
720+
721+
#undef PERMISSION_BOOL_FLAGS
722+
723+
} // namespace
724+
506725
void Worker::New(const FunctionCallbackInfo<Value>& args) {
507726
Environment* env = Environment::GetCurrent(args);
508727
THROW_IF_INSUFFICIENT_PERMISSIONS(
@@ -682,6 +901,14 @@ void Worker::New(const FunctionCallbackInfo<Value>& args) {
682901
per_isolate_opts = env->isolate_data()->options()->Clone();
683902
}
684903

904+
// Explicit execArgv only (including []). Default Worker path unchanged.
905+
if (env->permission()->enabled() && per_isolate_opts &&
906+
args[2]->IsArray()) {
907+
ClampWorkerPermissionToParent(env, per_isolate_opts.get());
908+
RebuildExecArgvOutFromPermissionOptions(per_isolate_opts.get(),
909+
&exec_argv_out);
910+
}
911+
685912
// Internal workers should not wait for inspector frontend to connect or
686913
// break on the first line of internal scripts. Module loader threads are
687914
// essential to load user codes and must not be blocked by the inspector

0 commit comments

Comments
 (0)