Skip to content

Commit d0bf0a2

Browse files
committed
permission: clamp Worker grants to parent for explicit execArgv
SEMVER-MAJOR: when the parent has the Permission Model enabled, a Worker with explicit execArgv (including []) cannot obtain wider permission-related grants than the parent. Expanded tests cover empty execArgv, intersection, wildcards, fs write, permission-audit, and permission CLI rebuild flags. Refs: #65359 Signed-off-by: yunshingng <yunshingng25@gmail.com>
1 parent 76bb3f7 commit d0bf0a2

4 files changed

Lines changed: 788 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: 267 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,264 @@ Worker::~Worker() {
504505
Debug(this, "Worker %llu destroyed", thread_id_.id);
505506
}
506507

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

945+
// Only explicit execArgv (including []): clamp options + rebuild argv.
946+
// Default Worker path (clone parent) is left unchanged.
947+
if (env->permission()->enabled() && per_isolate_opts && args[2]->IsArray()) {
948+
ClampWorkerPermissionToParent(env, per_isolate_opts.get());
949+
RebuildExecArgvOutFromPermissionOptions(per_isolate_opts.get(),
950+
&exec_argv_out);
951+
}
952+
686953
// Internal workers should not wait for inspector frontend to connect or
687954
// break on the first line of internal scripts. Module loader threads are
688955
// essential to load user codes and must not be blocked by the inspector

0 commit comments

Comments
 (0)