|
10 | 10 | #include "node_perf.h" |
11 | 11 | #include "node_profiling.h" |
12 | 12 | #include "node_snapshot_builder.h" |
| 13 | +#include "path.h" |
13 | 14 | #include "permission/permission.h" |
14 | 15 | #include "util-inl.h" |
15 | 16 | #include "v8-cppgc.h" |
@@ -503,6 +504,263 @@ Worker::~Worker() { |
503 | 504 | Debug(this, "Worker %llu destroyed", thread_id_.id); |
504 | 505 | } |
505 | 506 |
|
| 507 | +// SEMVER-MAJOR: Permission ceiling for Worker when execArgv is explicit |
| 508 | +// (including []). Default Worker (no execArgv) is unchanged. |
| 509 | +// |
| 510 | +// After options parse, NODE_OPTIONS and repeated --allow-* are already in |
| 511 | +// EnvironmentOptions. Runtime FSPermission remains authoritative for FS |
| 512 | +// checks; path filtering here is create-time only (prefix / exact / *). |
| 513 | +// |
| 514 | +// Boolean --allow-* dimensions are listed once in PERMISSION_BOOL_FLAGS so |
| 515 | +// ceiling / intersect / CLI token / rebuild cannot drift. |
| 516 | + |
| 517 | +namespace { |
| 518 | + |
| 519 | +// Single source of truth for boolean permission dimensions (not fs path |
| 520 | +// lists, which are handled separately since they're not simple booleans). |
| 521 | +#define PERMISSION_BOOL_FLAGS(V) \ |
| 522 | + V(allow_fs_vfs, "--allow-fs-vfs") \ |
| 523 | + V(allow_addons, "--allow-addons") \ |
| 524 | + V(allow_inspector, "--allow-inspector") \ |
| 525 | + V(allow_child_process, "--allow-child-process") \ |
| 526 | + V(allow_net, "--allow-net") \ |
| 527 | + V(allow_wasi, "--allow-wasi") \ |
| 528 | + V(allow_ffi, "--allow-ffi") \ |
| 529 | + V(allow_openssl_store, "--allow-openssl-store") \ |
| 530 | + V(allow_worker_threads, "--allow-worker") |
| 531 | + |
| 532 | +bool WorkerConfiguredPermission(const EnvironmentOptions* w) { |
| 533 | + if (w == nullptr) return false; |
| 534 | + if (w->permission || w->permission_audit) return true; |
| 535 | + if (!w->allow_fs_read.empty() || !w->allow_fs_write.empty()) return true; |
| 536 | +#define V(field, flag) || w->field |
| 537 | + return false PERMISSION_BOOL_FLAGS(V); |
| 538 | +#undef V |
| 539 | +} |
| 540 | + |
| 541 | +void ApplyParentPermissionCeiling(EnvironmentOptions* w, |
| 542 | + const EnvironmentOptions* parent) { |
| 543 | + w->permission = true; |
| 544 | + w->permission_audit = parent->permission_audit; |
| 545 | +#define V(field, flag) w->field = parent->field; |
| 546 | + PERMISSION_BOOL_FLAGS(V) |
| 547 | +#undef V |
| 548 | + w->allow_fs_read = parent->allow_fs_read; |
| 549 | + w->allow_fs_write = parent->allow_fs_write; |
| 550 | +} |
| 551 | + |
| 552 | +void NormalizePathForCompare(std::string* s) { |
| 553 | + while (s->size() > 1 && |
| 554 | + (s->back() == '/' || s->back() == static_cast<char>(92))) { |
| 555 | + s->pop_back(); |
| 556 | + } |
| 557 | +#ifdef _WIN32 |
| 558 | + for (char& c : *s) { |
| 559 | + if (c >= 'A' && c <= 'Z') { |
| 560 | + c = static_cast<char>(c - 'A' + 'a'); |
| 561 | + } |
| 562 | + if (c == '/') c = static_cast<char>(92); |
| 563 | + } |
| 564 | +#endif |
| 565 | +} |
| 566 | + |
| 567 | +std::string ResolveForCompare(Environment* env, const std::string& in) { |
| 568 | + if (in.empty() || in == "*") return in; |
| 569 | + std::string resolved = |
| 570 | + PathResolve(env, std::vector<std::string_view>{std::string_view(in)}); |
| 571 | + if (resolved.empty()) resolved = in; |
| 572 | + NormalizePathForCompare(&resolved); |
| 573 | + return resolved; |
| 574 | +} |
| 575 | + |
| 576 | +bool ParentEntryCoversResolvedPath(Environment* env, |
| 577 | + const std::string& parent_raw, |
| 578 | + const std::string& resolved_requested) { |
| 579 | + if (parent_raw == "*") return true; |
| 580 | + const std::string parent = ResolveForCompare(env, parent_raw); |
| 581 | + if (parent.empty()) return false; |
| 582 | + if (resolved_requested == parent) return true; |
| 583 | + if (resolved_requested.size() <= parent.size()) return false; |
| 584 | + if (resolved_requested.compare(0, parent.size(), parent) != 0) return false; |
| 585 | + const char next = resolved_requested[parent.size()]; |
| 586 | + return next == '/' || next == static_cast<char>(92); |
| 587 | +} |
| 588 | + |
| 589 | +bool ParentListHasWildcard(const std::vector<std::string>& parent) { |
| 590 | + for (const std::string& entry : parent) { |
| 591 | + if (entry == "*") return true; |
| 592 | + } |
| 593 | + return false; |
| 594 | +} |
| 595 | + |
| 596 | +void FilterPathListToParentSubset(Environment* env, |
| 597 | + std::vector<std::string>* worker, |
| 598 | + const std::vector<std::string>& parent) { |
| 599 | + if (worker == nullptr) return; |
| 600 | + // Worker listed no fs paths → keep empty (restrict). |
| 601 | + if (worker->empty()) return; |
| 602 | + // Parent "*" → FS already unrestricted; worker paths cannot exceed parent. |
| 603 | + if (ParentListHasWildcard(parent)) return; |
| 604 | + |
| 605 | + std::vector<std::string> out; |
| 606 | + out.reserve(worker->size()); |
| 607 | + bool saw_star = false; |
| 608 | + for (const std::string& wpath : *worker) { |
| 609 | + if (wpath == "*") { |
| 610 | + saw_star = true; |
| 611 | + continue; |
| 612 | + } |
| 613 | + const std::string resolved_wpath = ResolveForCompare(env, wpath); |
| 614 | + for (const std::string& entry : parent) { |
| 615 | + if (ParentEntryCoversResolvedPath(env, entry, resolved_wpath)) { |
| 616 | + // Keep the worker's original grant string so the list stays |
| 617 | + // consistent with parent raw entries (e.g. when copying parent |
| 618 | + // for "*") and with CLI rebuild. |
| 619 | + out.push_back(wpath); |
| 620 | + break; |
| 621 | + } |
| 622 | + } |
| 623 | + } |
| 624 | + // "*" alone or combined with concrete paths still means "everything the |
| 625 | + // parent allows" here, not "just the concrete paths that also matched" — |
| 626 | + // treating it as a subset would silently grant *less* than requesting "*" |
| 627 | + // by itself, which is backwards. See PR discussion for why this needs to |
| 628 | + // be unconditional on saw_star, not just "saw_star && out.empty()". |
| 629 | + if (saw_star) { |
| 630 | + *worker = parent; |
| 631 | + return; |
| 632 | + } |
| 633 | + *worker = std::move(out); |
| 634 | +} |
| 635 | + |
| 636 | +void IntersectPermissionGrants(Environment* env, |
| 637 | + EnvironmentOptions* w, |
| 638 | + const EnvironmentOptions* parent) { |
| 639 | + w->permission = true; |
| 640 | + w->permission_audit = w->permission_audit || parent->permission_audit; |
| 641 | +#define V(field, flag) w->field = w->field && parent->field; |
| 642 | + PERMISSION_BOOL_FLAGS(V) |
| 643 | +#undef V |
| 644 | + FilterPathListToParentSubset(env, &w->allow_fs_read, parent->allow_fs_read); |
| 645 | + FilterPathListToParentSubset(env, &w->allow_fs_write, parent->allow_fs_write); |
| 646 | +} |
| 647 | + |
| 648 | +bool IsPermissionCliToken(const std::string& a) { |
| 649 | + if (a == "--permission" || a == "--permission-audit") return true; |
| 650 | + if (a == "--allow-fs-read" || a == "--allow-fs-write") return true; |
| 651 | + if (a.rfind("--allow-fs-read=", 0) == 0) return true; |
| 652 | + if (a.rfind("--allow-fs-write=", 0) == 0) return true; |
| 653 | +#define V(field, flag) \ |
| 654 | + if (a == flag) return true; \ |
| 655 | + { \ |
| 656 | + const size_t n = sizeof(flag) - 1; \ |
| 657 | + if (a.size() > n && a.compare(0, n, flag) == 0 && a[n] == '=') \ |
| 658 | + return true; \ |
| 659 | + } |
| 660 | + PERMISSION_BOOL_FLAGS(V) |
| 661 | +#undef V |
| 662 | + return false; |
| 663 | +} |
| 664 | + |
| 665 | +bool ExecArgvHasPermissionToken(const std::vector<std::string>& argv) { |
| 666 | + for (const std::string& tok : argv) { |
| 667 | + if (IsPermissionCliToken(tok)) return true; |
| 668 | + } |
| 669 | + return false; |
| 670 | +} |
| 671 | + |
| 672 | +void ClampWorkerPermissionToParent( |
| 673 | + Environment* env, |
| 674 | + PerIsolateOptions* worker_opts, |
| 675 | + const std::vector<std::string>& exec_argv_out) { |
| 676 | + if (worker_opts == nullptr || env == nullptr || |
| 677 | + !env->permission()->enabled()) { |
| 678 | + return; |
| 679 | + } |
| 680 | + EnvironmentOptions* parent = |
| 681 | + env->isolate_data()->options()->get_per_env_options(); |
| 682 | + EnvironmentOptions* w = worker_opts->get_per_env_options(); |
| 683 | + if (parent == nullptr || w == nullptr) return; |
| 684 | + |
| 685 | + // Ceiling (inherit parent grants) only when the worker did not ask for any |
| 686 | + // permission-related configuration. If execArgv contains permission tokens |
| 687 | + // (or options already reflect them), intersect so an explicit |
| 688 | + // `--permission` without fs grants stays restrictive instead of being |
| 689 | + // widened back to the parent allowlist. |
| 690 | + const bool permission_requested = WorkerConfiguredPermission(w) || |
| 691 | + ExecArgvHasPermissionToken(exec_argv_out); |
| 692 | + if (!permission_requested) { |
| 693 | + ApplyParentPermissionCeiling(w, parent); |
| 694 | + } else { |
| 695 | + if (!w->permission && !w->permission_audit) { |
| 696 | + w->permission = true; |
| 697 | + } |
| 698 | + IntersectPermissionGrants(env, w, parent); |
| 699 | + } |
| 700 | +} |
| 701 | + |
| 702 | +bool PermissionFlagTakesNextArg(const std::string& a) { |
| 703 | + return a == "--allow-fs-read" || a == "--allow-fs-write"; |
| 704 | +} |
| 705 | + |
| 706 | +bool PathSafeForAllowFlag(const std::string& path) { |
| 707 | + if (path.empty()) return false; |
| 708 | + for (unsigned char c : path) { |
| 709 | + if (c == 0 || c == 10 || c == 13) return false; |
| 710 | + } |
| 711 | + return true; |
| 712 | +} |
| 713 | + |
| 714 | +void RebuildExecArgvOutFromPermissionOptions( |
| 715 | + PerIsolateOptions* worker_opts, std::vector<std::string>* exec_argv_out) { |
| 716 | + if (worker_opts == nullptr || exec_argv_out == nullptr) return; |
| 717 | + EnvironmentOptions* w = worker_opts->get_per_env_options(); |
| 718 | + if (w == nullptr || !w->permission) return; |
| 719 | + |
| 720 | + std::vector<std::string> kept; |
| 721 | + kept.reserve(exec_argv_out->size()); |
| 722 | + for (size_t i = 0; i < exec_argv_out->size(); ++i) { |
| 723 | + const std::string& tok = (*exec_argv_out)[i]; |
| 724 | + if (tok.empty()) continue; |
| 725 | + if (IsPermissionCliToken(tok)) { |
| 726 | + // Space-form --allow-fs-read/--allow-fs-write always consume the next |
| 727 | + // token as their argument, regardless of its first character — paths |
| 728 | + // are legal starting with '-' on Unix, so gating on that would leave |
| 729 | + // a stray token behind instead of consuming it as the flag's value. |
| 730 | + if (PermissionFlagTakesNextArg(tok) && i + 1 < exec_argv_out->size()) { |
| 731 | + ++i; |
| 732 | + } |
| 733 | + continue; |
| 734 | + } |
| 735 | + kept.push_back(tok); |
| 736 | + } |
| 737 | + |
| 738 | + std::vector<std::string> out; |
| 739 | + out.reserve(kept.size() + 16 + w->allow_fs_read.size() + |
| 740 | + w->allow_fs_write.size()); |
| 741 | + for (const std::string& tok : kept) out.push_back(tok); |
| 742 | + |
| 743 | + out.push_back("--permission"); |
| 744 | + if (w->permission_audit) out.push_back("--permission-audit"); |
| 745 | +#define V(field, flag) \ |
| 746 | + if (w->field) out.push_back(flag); |
| 747 | + PERMISSION_BOOL_FLAGS(V) |
| 748 | +#undef V |
| 749 | + for (const std::string& p : w->allow_fs_read) { |
| 750 | + if (!PathSafeForAllowFlag(p)) continue; |
| 751 | + out.push_back("--allow-fs-read=" + p); |
| 752 | + } |
| 753 | + for (const std::string& p : w->allow_fs_write) { |
| 754 | + if (!PathSafeForAllowFlag(p)) continue; |
| 755 | + out.push_back("--allow-fs-write=" + p); |
| 756 | + } |
| 757 | + *exec_argv_out = std::move(out); |
| 758 | +} |
| 759 | + |
| 760 | +#undef PERMISSION_BOOL_FLAGS |
| 761 | + |
| 762 | +} // namespace |
| 763 | + |
506 | 764 | void Worker::New(const FunctionCallbackInfo<Value>& args) { |
507 | 765 | Environment* env = Environment::GetCurrent(args); |
508 | 766 | THROW_IF_INSUFFICIENT_PERMISSIONS( |
@@ -559,6 +817,11 @@ void Worker::New(const FunctionCallbackInfo<Value>& args) { |
559 | 817 | THROW_ERR_OPERATION_FAILED(env, "Failed to copy environment variables"); |
560 | 818 | } |
561 | 819 |
|
| 820 | + // Keep the main-branch gate so custom env / NODE_OPTIONS and execArgv |
| 821 | + // (including []) still go through fresh option parsing. Empty execArgv |
| 822 | + // must not skip that path — only the permission ceiling below differs. |
| 823 | + const bool explicit_exec_argv = args[2]->IsArray(); |
| 824 | + |
562 | 825 | if (args[1]->IsObject() || args[2]->IsArray()) { |
563 | 826 | per_isolate_opts.reset(new PerIsolateOptions()); |
564 | 827 |
|
@@ -682,6 +945,50 @@ void Worker::New(const FunctionCallbackInfo<Value>& args) { |
682 | 945 | per_isolate_opts = env->isolate_data()->options()->Clone(); |
683 | 946 | } |
684 | 947 |
|
| 948 | + // Any explicit execArgv (including []): clamp permission grants to the |
| 949 | + // parent. [] stays on the fresh-parse path above so NODE_OPTIONS still |
| 950 | + // applies; the ceiling then re-attaches parent permission grants. |
| 951 | + if (env->permission()->enabled() && per_isolate_opts && explicit_exec_argv) { |
| 952 | + // Prefer the original JS execArgv strings for "did the caller ask for |
| 953 | + // permission flags?" — Parse may consume known tokens out of |
| 954 | + // exec_argv_out, which would otherwise make a restrictive |
| 955 | + // `--permission` (no fs grants) look unconfigured and hit the ceiling. |
| 956 | + std::vector<std::string> permission_argv_probe = exec_argv_out; |
| 957 | + if (args[2]->IsArray()) { |
| 958 | + Local<Array> array = args[2].As<Array>(); |
| 959 | + uint32_t length = array->Length(); |
| 960 | + for (uint32_t i = 0; i < length; i++) { |
| 961 | + Local<Value> arg; |
| 962 | + if (!array->Get(env->context(), i).ToLocal(&arg)) { |
| 963 | + return; |
| 964 | + } |
| 965 | + Local<String> arg_v8; |
| 966 | + if (!arg->ToString(env->context()).ToLocal(&arg_v8)) { |
| 967 | + return; |
| 968 | + } |
| 969 | + Utf8Value arg_utf8_value(args.GetIsolate(), arg_v8); |
| 970 | + permission_argv_probe.emplace_back(arg_utf8_value.out(), |
| 971 | + arg_utf8_value.length()); |
| 972 | + } |
| 973 | + } |
| 974 | + ClampWorkerPermissionToParent( |
| 975 | + env, per_isolate_opts.get(), permission_argv_probe); |
| 976 | + // Match permissions.md: --permission wins over --permission-audit. |
| 977 | + EnvironmentOptions* clamped = |
| 978 | + per_isolate_opts->get_per_env_options(); |
| 979 | + if (clamped != nullptr && clamped->permission) { |
| 980 | + clamped->permission_audit = false; |
| 981 | + } |
| 982 | + RebuildExecArgvOutFromPermissionOptions(per_isolate_opts.get(), |
| 983 | + &exec_argv_out); |
| 984 | + // Workers load via LOAD_SCRIPT after Environment construction, so |
| 985 | + // argv_ has no script path yet. Without this, Environment's implicit |
| 986 | + // entrypoint grant pushes empty argv[1], PathResolve turns that into |
| 987 | + // cwd, and a restrictive `--permission` (no fs grants) still allows |
| 988 | + // reading anything under cwd — which broke the empty-grant cases. |
| 989 | + per_isolate_opts->get_per_env_options()->has_eval_string = true; |
| 990 | + } |
| 991 | + |
685 | 992 | // Internal workers should not wait for inspector frontend to connect or |
686 | 993 | // break on the first line of internal scripts. Module loader threads are |
687 | 994 | // essential to load user codes and must not be blocked by the inspector |
|
0 commit comments