From 84562866ff562c826aa90401d2c8d8bd8bb8952f Mon Sep 17 00:00:00 2001 From: Philipp Dunkel Date: Mon, 31 Aug 2026 14:28:12 +0200 Subject: [PATCH 1/4] vfs: load native addons from a mounted file system A native addon inside a virtual file system could not be require()d: dlopen() and LoadLibrary() open a shared object by path, and a VFS path has no inode for them to open. Read the addon's bytes from the VFS and load them from a private, self-cleaning image instead, using the smallest on-disk footprint each platform allows: on Linux an anonymous memfd loaded through /proc/self/fd, so the bytes never reach the file system; on other POSIX platforms a file in a 0700 mkdtemp() directory, unlinked right after loading, with the mapping keeping it alive; on Windows a temp file opened FILE_FLAG_DELETE_ON_CLOSE whose handle is held until exit. This is internal. process.dlopen() keeps its documented (module, filename[, flags]) signature and ignores anything further; the bytes are passed to a dlopenBinary() reachable only through the process_methods binding, which the VFS hook calls for VFS paths. Addons on the real file system are untouched and load directly. Signed-off-by: Philipp Dunkel --- doc/api/vfs.md | 6 + lib/internal/vfs/setup.js | 22 ++ src/node_binding.cc | 325 +++++++++++++++++++++++++++- src/node_binding.h | 1 + src/node_process_methods.cc | 2 + test/parallel/test-dlopen-binary.js | 51 +++++ test/parallel/test-vfs-addon.js | 37 ++++ 7 files changed, 438 insertions(+), 6 deletions(-) create mode 100644 test/parallel/test-dlopen-binary.js create mode 100644 test/parallel/test-vfs-addon.js diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 333154417dc2..6f67a85ad8a8 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -419,6 +419,12 @@ system, the callers are responsible for avoiding removal or invalidation of modules in the virtual file system while they are being loaded. +Native addons (`.node` files) stored in a mounted VFS can be `require()`d as +well. The operating system's dynamic loader cannot open a virtual path, so the +addon's bytes are read from the VFS and loaded from a private, self-cleaning +temporary image instead. Addons on the real file system are unaffected and +load directly. + ## Use with Single Executable Applications When running as a [Single Executable Application][] built with diff --git a/lib/internal/vfs/setup.js b/lib/internal/vfs/setup.js index 979990d1a855..a45cd47a9bf4 100644 --- a/lib/internal/vfs/setup.js +++ b/lib/internal/vfs/setup.js @@ -914,6 +914,26 @@ function installModuleLoaderOverrides() { }); } +let originalDlopen; + +function installAddonLoader() { + originalDlopen = process.dlopen; + process.dlopen = function(module, filename, flags) { + // dlopen(2) cannot open a native addon that lives in a VFS by path (it has + // no real inode). Read its bytes and hand them to the internal + // dlopenBinary(), which writes them to a private, self-cleaning temporary + // image - an in-memory memfd on Linux - and loads that. Only VFS paths take + // this route; everything else loads straight from disk through the + // unchanged process.dlopen(). + if (StringPrototypeStartsWith(filename, normalizedVfsRootPrefix)) { + const { readFileSync } = require('fs'); + const { dlopenBinary } = internalBinding('process_methods'); + return dlopenBinary(module, filename, flags, readFileSync(filename)); + } + return originalDlopen(module, filename, flags); + }; +} + /** * Install all VFS hooks: module loader overrides and fs handlers. */ @@ -922,6 +942,7 @@ function installHooks() { debug('install hooks'); normalizedVfsRootPrefix = getNormalizedVfsRoot() + sep; installModuleLoaderOverrides(); + installAddonLoader(); vfsHandlerObj = createVfsHandlers(); setVfsHandlers(vfsHandlerObj); hooksInstalled = true; @@ -939,6 +960,7 @@ function uninstallHooks() { setLoaderOverrides(); setVfsHandlers(null); vfsHandlerObj = undefined; + process.dlopen = originalDlopen; hooksInstalled = false; } diff --git a/src/node_binding.cc b/src/node_binding.cc index 26f8d046f6da..f651964b0421 100644 --- a/src/node_binding.cc +++ b/src/node_binding.cc @@ -9,6 +9,20 @@ #include "util.h" #include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#include +#include +#if defined(__linux__) +#include +#include +#endif +#endif #if HAVE_OPENSSL #define NODE_BUILTIN_OPENSSL_BINDINGS(V) V(crypto) V(tls_wrap) @@ -438,13 +452,272 @@ inline node_api_addon_get_api_version_func GetNapiAddonGetApiVersionCallback( dlib->GetSymbolAddress(STRINGIFY(NODE_API_MODULE_GET_API_VERSION))); } -// DLOpen is process.dlopen(module, filename, flags). -// Used to load 'module.node' dynamically shared objects. +namespace { + +#ifndef _WIN32 +// Write the whole buffer to `fd`, retrying partial writes and EINTR. +bool WriteAllToFd(int fd, const char* data, size_t len) { + size_t off = 0; + while (off < len) { + ssize_t n = write(fd, data + off, len - off); + if (n < 0) { + if (errno == EINTR) continue; + return false; + } + off += static_cast(n); + } + return true; +} + +#if defined(__linux__) +int NodeMemfdCreate(const char* name, unsigned int flags) { + return static_cast(syscall(SYS_memfd_create, name, flags)); +} +#endif // __linux__ +#endif // !_WIN32 + +// Materializes native-addon bytes into a form dlopen()/LoadLibrary() can load, +// with the smallest, most private on-disk footprint each platform allows: +// Linux: an anonymous in-memory memfd, loaded via /proc/self/fd/N - +// the bytes never touch the filesystem. +// other POSIX: a 0700 mkdtemp() directory plus an O_EXCL|O_NOFOLLOW file, +// unlink()ed right after the load (the mapping keeps it alive). +// Windows: a temp file opened FILE_FLAG_DELETE_ON_CLOSE; its handle is +// retained for the process lifetime so the file is removed +// automatically once the process (and the loaded DLL) exit. +// Used for an addon that lives somewhere dlopen() cannot open by path, such as +// a virtual file system. +class AddonImage { + public: + AddonImage() = default; + ~AddonImage(); + AddonImage(const AddonImage&) = delete; + AddonImage& operator=(const AddonImage&) = delete; + + // On success sets path() to a real, loadable path for `data`. + bool Materialize(const char* data, size_t len); + const std::string& path() const { return path_; } + const std::string& errmsg() const { return errmsg_; } + + // Call exactly once, right after DLib::Open(); `opened` says whether the load + // succeeded. Releases the transient resources that are no longer needed (a + // successful load holds its own mapping): on POSIX closes the memfd or + // unlinks the temp file; on Windows retains the delete-on-close handle for + // the process lifetime when opened, or closes it (deleting the file) on + // failure. + void AfterOpen(bool opened); + + private: + std::string path_; + std::string errmsg_; + bool consumed_ = false; +#ifdef _WIN32 + HANDLE handle_ = INVALID_HANDLE_VALUE; +#else + bool MaterializeTempFile(const char* data, size_t len); + int fd_ = -1; + std::string temp_dir_; // non-empty only for the temp-file (non-memfd) path +#endif +}; + +#ifdef _WIN32 + +// Delete-on-close handles kept alive until process exit so their temp files +// outlive the loaded DLLs and are removed once the process ends. +Mutex g_retained_addon_handles_mutex; +std::vector* g_retained_addon_handles = nullptr; + +bool AddonImage::Materialize(const char* data, size_t len) { + wchar_t dir[MAX_PATH + 1]; + DWORD dir_len = GetTempPathW(MAX_PATH + 1, dir); + if (dir_len == 0 || dir_len > MAX_PATH) { + errmsg_ = "could not locate the temporary directory"; + return false; + } + wchar_t file[MAX_PATH + 1]; + if (GetTempFileNameW(dir, L"nod", 0, file) == 0) { + errmsg_ = "could not create a temporary file name"; + return false; + } + // Reopen the just-created file delete-on-close, sharing delete so the loader + // can map it while it is delete-pending; the file is removed when this handle + // and the loader's section are both released (i.e. at process exit). + handle_ = CreateFileW(file, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, + CREATE_ALWAYS, + FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE, + nullptr); + if (handle_ == INVALID_HANDLE_VALUE) { + errmsg_ = "could not create a temporary file for the native addon"; + return false; + } + size_t off = 0; + while (off < len) { + DWORD chunk = + len - off > MAXDWORD ? MAXDWORD : static_cast(len - off); + DWORD written = 0; + if (!WriteFile(handle_, data + off, chunk, &written, nullptr)) { + errmsg_ = "could not write the native addon to a temporary file"; + CloseHandle(handle_); + handle_ = INVALID_HANDLE_VALUE; + return false; + } + off += written; + } + int utf8_len = + WideCharToMultiByte(CP_UTF8, 0, file, -1, nullptr, 0, nullptr, nullptr); + if (utf8_len <= 0) { + errmsg_ = "could not encode the temporary file path"; + CloseHandle(handle_); + handle_ = INVALID_HANDLE_VALUE; + return false; + } + path_.resize(utf8_len - 1); + WideCharToMultiByte( + CP_UTF8, 0, file, -1, path_.data(), utf8_len, nullptr, nullptr); + return true; +} + +void AddonImage::AfterOpen(bool opened) { + consumed_ = true; + if (handle_ == INVALID_HANDLE_VALUE) return; + if (!opened) { + CloseHandle(handle_); // delete-on-close removes the file + handle_ = INVALID_HANDLE_VALUE; + return; + } + Mutex::ScopedLock lock(g_retained_addon_handles_mutex); + if (g_retained_addon_handles == nullptr) { + g_retained_addon_handles = new std::vector(); + } + g_retained_addon_handles->push_back(handle_); + handle_ = INVALID_HANDLE_VALUE; +} + +AddonImage::~AddonImage() { + // Materialized but Open() was never reached (e.g. an exception in between): + // closing the delete-on-close handle removes the file. + if (!consumed_ && handle_ != INVALID_HANDLE_VALUE) CloseHandle(handle_); +} + +#else // !_WIN32 + +bool AddonImage::Materialize(const char* data, size_t len) { +#if defined(__linux__) + // Prefer an anonymous in-memory fd, loaded through /proc/self/fd: nothing + // reaches the filesystem, so there is no temp file to secure, unlink, or + // leak, and no noexec-mount problem. Request an executable memfd (MFD_EXEC, + // Linux 6.3+); retry without it on older kernels that reject the flag, then + // fall back to a temp file if memfd is unavailable entirely. +#ifndef MFD_CLOEXEC +#define MFD_CLOEXEC 0x0001U +#endif +#ifndef MFD_EXEC +#define MFD_EXEC 0x0010U +#endif + fd_ = NodeMemfdCreate("node-addon", MFD_CLOEXEC | MFD_EXEC); + if (fd_ == -1 && errno == EINVAL) { + fd_ = NodeMemfdCreate("node-addon", MFD_CLOEXEC); + } + if (fd_ != -1) { + if (!WriteAllToFd(fd_, data, len)) { + errmsg_ = "could not write the native addon to an in-memory file"; + close(fd_); + fd_ = -1; + return false; + } + path_ = "/proc/self/fd/" + std::to_string(fd_); + return true; + } +#endif // __linux__ + return MaterializeTempFile(data, len); +} + +bool AddonImage::MaterializeTempFile(const char* data, size_t len) { + // A private 0700 directory (mkdtemp) so no other user can pre-create the path + // as a symlink or swap the file between the write and the load; O_EXCL and + // O_NOFOLLOW harden the create against a race inside it. + const char* env_tmp = getenv("TMPDIR"); + std::string tmpdir = + (env_tmp != nullptr && *env_tmp != '\0') ? env_tmp : "/tmp"; + if (tmpdir.back() != '/') tmpdir.push_back('/'); + std::string tmpl = tmpdir + "node-addon-XXXXXX"; + std::vector buf(tmpl.begin(), tmpl.end()); + buf.push_back('\0'); + if (mkdtemp(buf.data()) == nullptr) { + errmsg_ = "could not create a temporary directory for the native addon"; + return false; + } + temp_dir_ = buf.data(); + std::string file = temp_dir_ + "/addon.node"; + fd_ = open(file.c_str(), + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + 0600); + if (fd_ == -1) { + errmsg_ = "could not create a temporary file for the native addon"; + rmdir(temp_dir_.c_str()); + temp_dir_.clear(); + return false; + } + if (!WriteAllToFd(fd_, data, len)) { + errmsg_ = "could not write the native addon to a temporary file"; + close(fd_); + fd_ = -1; + unlink(file.c_str()); + rmdir(temp_dir_.c_str()); + temp_dir_.clear(); + return false; + } + // dlopen() reopens by path; the post-open mapping keeps the inode alive. + close(fd_); + fd_ = -1; + path_ = file; + return true; +} + +void AddonImage::AfterOpen(bool opened) { + consumed_ = true; + // The right cleanup is the same whether or not the load worked. + (void)opened; + // memfd: the load's mapping (or nothing, on failure) owns it from here. + if (fd_ != -1) { + close(fd_); + fd_ = -1; + return; + } + if (!temp_dir_.empty()) { + // On success the mapping keeps the inode alive, so unlinking now leaves no + // on-disk trace; on failure this just cleans up. + unlink(path_.c_str()); + rmdir(temp_dir_.c_str()); + temp_dir_.clear(); + } +} + +AddonImage::~AddonImage() { + if (consumed_) return; + if (fd_ != -1) close(fd_); + if (!temp_dir_.empty()) { + unlink(path_.c_str()); + rmdir(temp_dir_.c_str()); + } +} + +#endif // _WIN32 + +} // namespace + +// Shared by process.dlopen() and the internal dlopenBinary(). `allow_binary` +// says whether args[3] may carry the addon's bytes; it is false for +// process.dlopen(), whose signature stays (module, filename[, flags]). // // FIXME(bnoordhuis) Not multi-context ready. TBD how to resolve the conflict // when two contexts try to load the same shared object. Maybe have a shadow // cache that's a plain C list or hash table that's shared across contexts? -void DLOpen(const FunctionCallbackInfo& args) { +static void DLOpenImpl(const FunctionCallbackInfo& args, + bool allow_binary) { Environment* env = Environment::GetCurrent(args); if (env->no_native_addons()) { @@ -464,7 +737,11 @@ void DLOpen(const FunctionCallbackInfo& args) { } int32_t flags = DLib::kDefaultFlags; - if (args.Length() > 2 && !args[2]->Int32Value(context).To(&flags)) { + // On the internal path an undefined flags argument keeps the default, so a + // caller can pass the bytes without choosing flags. process.dlopen() keeps + // its original behaviour of rejecting any non-integer that is present. + if (args.Length() > 2 && !(allow_binary && args[2]->IsUndefined()) && + !args[2]->Int32Value(context).To(&flags)) { return THROW_ERR_INVALID_ARG_TYPE(env, "flag argument must be an integer."); } @@ -477,12 +754,34 @@ void DLOpen(const FunctionCallbackInfo& args) { return; // Exception pending. } - node::Utf8Value filename(env->isolate(), args[1]); // Cast - env->TryLoadAddon(*filename, flags, [&](DLib* dlib) { + node::Utf8Value filename(env->isolate(), args[1]); // The addon's path, + // used for diagnostics. + + // On the internal path args[3] carries the addon's bytes, for an addon that + // lives somewhere dlopen() cannot open by path (e.g. a virtual file system). + // Materialize them into a private, self-cleaning image and load that, while + // still reporting `filename` in any error. + AddonImage image; + const char* load_path = *filename; + if (allow_binary && args.Length() > 3 && !args[3]->IsUndefined()) { + if (!args[3]->IsArrayBufferView()) { + return THROW_ERR_INVALID_ARG_TYPE( + env, "binary must be a Buffer, TypedArray, or DataView"); + } + ArrayBufferViewContents binary(args[3]); + if (!image.Materialize(binary.data(), binary.length())) { + return THROW_ERR_DLOPEN_FAILED( + env, "%s: %s", image.errmsg().c_str(), *filename); + } + load_path = image.path().c_str(); + } + + env->TryLoadAddon(load_path, flags, [&](DLib* dlib) { static Mutex dlib_load_mutex; Mutex::ScopedLock lock(dlib_load_mutex); const bool is_opened = dlib->Open(); + image.AfterOpen(is_opened); // Objects containing v14 or later modules will have registered themselves // on the pending list. Activate all of them now. At present, only one @@ -582,6 +881,20 @@ void DLOpen(const FunctionCallbackInfo& args) { // coverity[leaked_storage] } +// process.dlopen(module, filename[, flags]). Used to load 'module.node' +// dynamically shared objects. +void DLOpen(const FunctionCallbackInfo& args) { + DLOpenImpl(args, false); +} + +// Internal only, reached through the process_methods binding rather than the +// process object: dlopenBinary(module, filename, flags, binary) loads an addon +// from bytes already in memory. Used for addons served by a virtual file +// system, which the dynamic loader cannot open by path. +void DLOpenBinary(const FunctionCallbackInfo& args) { + DLOpenImpl(args, true); +} + inline struct node_module* FindModule(struct node_module* list, const char* name, int flag) { diff --git a/src/node_binding.h b/src/node_binding.h index b05c68ad0726..c200cc0d0c8a 100644 --- a/src/node_binding.h +++ b/src/node_binding.h @@ -168,6 +168,7 @@ void CreateInternalBindingTemplates(IsolateData* isolate_data); void GetInternalBinding(const v8::FunctionCallbackInfo& args); void GetLinkedBinding(const v8::FunctionCallbackInfo& args); void DLOpen(const v8::FunctionCallbackInfo& args); +void DLOpenBinary(const v8::FunctionCallbackInfo& args); } // namespace binding diff --git a/src/node_process_methods.cc b/src/node_process_methods.cc index 8f800be35431..6a35e5c5e4c4 100644 --- a/src/node_process_methods.cc +++ b/src/node_process_methods.cc @@ -807,6 +807,7 @@ static void CreatePerIsolateProperties(IsolateData* isolate_data, SetMethodNoSideEffect(isolate, target, "cwd", Cwd); SetMethod(isolate, target, "dlopen", binding::DLOpen); + SetMethod(isolate, target, "dlopenBinary", binding::DLOpenBinary); SetMethod(isolate, target, "reallyExit", ReallyExit); #if defined __POSIX__ && !defined(__PASE__) @@ -854,6 +855,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(Cwd); registry->Register(binding::DLOpen); + registry->Register(binding::DLOpenBinary); registry->Register(ReallyExit); #if defined __POSIX__ && !defined(__PASE__) diff --git a/test/parallel/test-dlopen-binary.js b/test/parallel/test-dlopen-binary.js new file mode 100644 index 000000000000..8a575a52c975 --- /dev/null +++ b/test/parallel/test-dlopen-binary.js @@ -0,0 +1,51 @@ +// Flags: --expose-internals +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { internalBinding } = require('internal/test/binding'); + +// dlopenBinary() loads a native addon from bytes already in memory, for an +// addon that the dynamic loader cannot open by path (e.g. one served by a +// virtual file system). It is internal: process.dlopen() keeps its documented +// (module, filename[, flags]) signature and never takes the bytes. +const { dlopenBinary } = internalBinding('process_methods'); +assert.strictEqual(typeof dlopenBinary, 'function'); +assert.strictEqual(process.dlopen.length, 0); + +const addonPath = path.join( + __dirname, '..', 'addons', 'hello-world', 'build', 'Release', 'binding.node'); +if (!fs.existsSync(addonPath)) common.skip('the hello-world addon is not built'); +const bytes = fs.readFileSync(addonPath); +const flags = (os.constants.dlopen?.RTLD_LAZY) || 0; + +// A path that does not exist on disk, as a VFS-resident addon would be. +const fakePath = path.join(os.tmpdir(), 'nonexistent-vfs-dir', 'binding.node'); +assert.ok(!fs.existsSync(fakePath)); + +const m = { exports: {} }; +dlopenBinary(m, fakePath, flags, bytes); +assert.strictEqual(typeof m.exports.hello, 'function'); +assert.strictEqual(m.exports.hello(), 'world'); + +// The flags argument may be left undefined when passing bytes. +const m2 = { exports: {} }; +dlopenBinary(m2, fakePath, undefined, bytes); +assert.strictEqual(m2.exports.hello(), 'world'); + +// Without the bytes, that nonexistent path cannot be loaded. +assert.throws(() => dlopenBinary({ exports: {} }, fakePath, flags), + { code: 'ERR_DLOPEN_FAILED' }); + +// A non-buffer binary is rejected. +assert.throws( + () => dlopenBinary({ exports: {} }, fakePath, flags, 'nope'), + { code: 'ERR_INVALID_ARG_TYPE' }); + +// process.dlopen() ignores a fourth argument rather than loading from it: the +// public signature is unchanged, so this still fails on the missing path. +assert.throws( + () => process.dlopen({ exports: {} }, fakePath, flags, bytes), + { code: 'ERR_DLOPEN_FAILED' }); diff --git a/test/parallel/test-vfs-addon.js b/test/parallel/test-vfs-addon.js new file mode 100644 index 000000000000..1c138a187d0a --- /dev/null +++ b/test/parallel/test-vfs-addon.js @@ -0,0 +1,37 @@ +// Flags: --experimental-vfs +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const vfs = require('node:vfs'); + +const addonPath = path.join( + __dirname, '..', 'addons', 'hello-world', 'build', 'Release', 'binding.node'); +if (!fs.existsSync(addonPath)) { + common.skip('the hello-world addon is not built'); +} + +// A native addon inside a mounted VFS can be require()d: dlopen() cannot open +// the reserved mount path, so the loader hands the addon's bytes to +// process.dlopen's `binary` option, which loads them from a private temporary +// image (an in-memory memfd on Linux). +const myVfs = vfs.create(); +myVfs.writeFileSync('/binding.node', fs.readFileSync(addonPath)); +const mountPoint = myVfs.mount(); + +const before = new Set(fs.readdirSync(os.tmpdir())); +const addon = require(path.join(mountPoint, 'binding.node')); +assert.strictEqual(addon.hello(), 'world'); + +// On Linux the memfd path never touches the filesystem; on other POSIX the +// temp image is unlinked right after loading, so no addon temp file lingers. +// (Windows keeps a delete-on-close file until exit, so skip the check there.) +if (process.platform !== 'win32') { + const leaked = fs.readdirSync(os.tmpdir()) + .filter((f) => f.startsWith('node-addon') && !before.has(f)); + assert.deepStrictEqual(leaked, [], `addon temp not cleaned up: ${leaked}`); +} + +myVfs.unmount(); From 4ba57c28cdffd3250fd5cc0f71fa361469415008 Mon Sep 17 00:00:00 2001 From: Philipp Dunkel Date: Mon, 31 Aug 2026 14:46:19 +0200 Subject: [PATCH 2/4] src: format addon image loading with clang-format Reflow the temporary-image open() call to what `CLANG_FORMAT_START=$(git merge-base HEAD main) make format-cpp` produces, so the C++ formatting check passes. Signed-off-by: Philipp Dunkel --- src/node_binding.cc | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/node_binding.cc b/src/node_binding.cc index f651964b0421..4349b527c729 100644 --- a/src/node_binding.cc +++ b/src/node_binding.cc @@ -652,9 +652,8 @@ bool AddonImage::MaterializeTempFile(const char* data, size_t len) { } temp_dir_ = buf.data(); std::string file = temp_dir_ + "/addon.node"; - fd_ = open(file.c_str(), - O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, - 0600); + fd_ = open( + file.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); if (fd_ == -1) { errmsg_ = "could not create a temporary file for the native addon"; rmdir(temp_dir_.c_str()); From 5de94d759210fb5ee1c5be1a5250f9f4bffb200f Mon Sep 17 00:00:00 2001 From: Philipp Dunkel Date: Mon, 31 Aug 2026 16:24:38 +0200 Subject: [PATCH 3/4] src: check fs write permission when loading addon bytes dlopenBinary() materializes the addon's bytes into an image in the temporary directory before handing it to the dynamic loader. Under the permission model that write went unchecked: --allow-addons alone was enough to have Node.js write an executable image out. Require write access to the temporary directory as well, checked before the bytes are materialized. The check does not depend on whether the image reaches the file system on a given platform - Linux uses an anonymous memfd - so that what a program must be granted stays the same everywhere. process.dlopen() is unaffected: it never takes bytes, so it never reaches the check. Signed-off-by: Philipp Dunkel --- src/node_binding.cc | 44 +++++++++-- .../parallel/test-permission-dlopen-binary.js | 75 +++++++++++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 test/parallel/test-permission-dlopen-binary.js diff --git a/src/node_binding.cc b/src/node_binding.cc index 4349b527c729..0437f59ca60e 100644 --- a/src/node_binding.cc +++ b/src/node_binding.cc @@ -494,6 +494,11 @@ class AddonImage { AddonImage(const AddonImage&) = delete; AddonImage& operator=(const AddonImage&) = delete; + // The directory a temporary image would be written to, with a trailing + // separator; empty when it cannot be determined. Names the resource for the + // file-system permission check. + static std::string TempDir(); + // On success sets path() to a real, loadable path for `data`. bool Materialize(const char* data, size_t len); const std::string& path() const { return path_; } @@ -527,6 +532,20 @@ class AddonImage { Mutex g_retained_addon_handles_mutex; std::vector* g_retained_addon_handles = nullptr; +// static +std::string AddonImage::TempDir() { + wchar_t dir[MAX_PATH + 1]; + DWORD dir_len = GetTempPathW(MAX_PATH + 1, dir); + if (dir_len == 0 || dir_len > MAX_PATH) return std::string(); + int size = WideCharToMultiByte( + CP_UTF8, 0, dir, dir_len, nullptr, 0, nullptr, nullptr); + if (size <= 0) return std::string(); + std::string out(size, '\0'); + WideCharToMultiByte( + CP_UTF8, 0, dir, dir_len, out.data(), size, nullptr, nullptr); + return out; +} + bool AddonImage::Materialize(const char* data, size_t len) { wchar_t dir[MAX_PATH + 1]; DWORD dir_len = GetTempPathW(MAX_PATH + 1, dir); @@ -635,15 +654,20 @@ bool AddonImage::Materialize(const char* data, size_t len) { return MaterializeTempFile(data, len); } -bool AddonImage::MaterializeTempFile(const char* data, size_t len) { - // A private 0700 directory (mkdtemp) so no other user can pre-create the path - // as a symlink or swap the file between the write and the load; O_EXCL and - // O_NOFOLLOW harden the create against a race inside it. +// static +std::string AddonImage::TempDir() { const char* env_tmp = getenv("TMPDIR"); std::string tmpdir = (env_tmp != nullptr && *env_tmp != '\0') ? env_tmp : "/tmp"; if (tmpdir.back() != '/') tmpdir.push_back('/'); - std::string tmpl = tmpdir + "node-addon-XXXXXX"; + return tmpdir; +} + +bool AddonImage::MaterializeTempFile(const char* data, size_t len) { + // A private 0700 directory (mkdtemp) so no other user can pre-create the path + // as a symlink or swap the file between the write and the load; O_EXCL and + // O_NOFOLLOW harden the create against a race inside it. + std::string tmpl = TempDir() + "node-addon-XXXXXX"; std::vector buf(tmpl.begin(), tmpl.end()); buf.push_back('\0'); if (mkdtemp(buf.data()) == nullptr) { @@ -767,6 +791,16 @@ static void DLOpenImpl(const FunctionCallbackInfo& args, return THROW_ERR_INVALID_ARG_TYPE( env, "binary must be a Buffer, TypedArray, or DataView"); } + // Loading from bytes materializes them into an image in the temporary + // directory, so this needs write access there on top of the addon + // permission checked above. The check does not depend on whether the image + // actually reaches the file system on this platform (Linux uses an + // anonymous memfd): what a program must be granted should not vary by + // platform. + THROW_IF_INSUFFICIENT_PERMISSIONS( + env, + permission::PermissionScope::kFileSystemWrite, + AddonImage::TempDir()); ArrayBufferViewContents binary(args[3]); if (!image.Materialize(binary.data(), binary.length())) { return THROW_ERR_DLOPEN_FAILED( diff --git a/test/parallel/test-permission-dlopen-binary.js b/test/parallel/test-permission-dlopen-binary.js new file mode 100644 index 000000000000..02967c221c44 --- /dev/null +++ b/test/parallel/test-permission-dlopen-binary.js @@ -0,0 +1,75 @@ +// Flags: --expose-internals +'use strict'; + +// Loading an addon from bytes materializes them into an image in the temporary +// directory, so dlopenBinary() requires file-system write access on top of the +// addon permission every dlopen already needs. Granting only one of the two is +// not enough. + +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const addonPath = path.join( + __dirname, '..', 'addons', 'hello-world', 'build', 'Release', 'binding.node'); +if (!fs.existsSync(addonPath)) common.skip('the hello-world addon is not built'); + +// Loads the addon from bytes at a path that does not exist on disk, as an +// addon served by a virtual file system would be, and reports what happened. +const child = ` + const { internalBinding } = require('internal/test/binding'); + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + const { dlopenBinary } = internalBinding('process_methods'); + const bytes = fs.readFileSync(${JSON.stringify(addonPath)}); + const target = path.join(os.tmpdir(), 'nonexistent-vfs-dir', 'binding.node'); + const m = { exports: {} }; + try { + dlopenBinary(m, target, undefined, bytes); + console.log('LOADED', m.exports.hello()); + } catch (err) { + console.log('DENIED', err.code, err.permission ?? ''); + } +`; + +function run(...permissions) { + return spawnSync( + process.execPath, + ['--expose-internals', '--permission', '--allow-fs-read=*', + ...permissions, '-e', child], + { encoding: 'utf8' }); +} + +// Both permissions granted: the addon loads. +{ + const res = run('--allow-addons', '--allow-fs-write=*'); + assert.strictEqual(res.status, 0, res.stderr); + assert.match(res.stdout, /LOADED world/); +} + +// Addons allowed but no write access: refused by the file-system check, so the +// bytes are never materialized. +{ + const res = run('--allow-addons'); + assert.strictEqual(res.status, 0, res.stderr); + assert.match(res.stdout, /DENIED ERR_ACCESS_DENIED FileSystemWrite/); +} + +// Write access but addons disallowed: refused before the bytes are looked at, +// the same as any other dlopen under the permission model. +{ + const res = run('--allow-fs-write=*'); + assert.strictEqual(res.status, 0, res.stderr); + assert.match(res.stdout, /DENIED ERR_DLOPEN_DISABLED/); +} + +// Without the permission model neither check applies. +{ + const res = spawnSync(process.execPath, ['--expose-internals', '-e', child], + { encoding: 'utf8' }); + assert.strictEqual(res.status, 0, res.stderr); + assert.match(res.stdout, /LOADED world/); +} From f9081f8825ac0139770cd98d5ce2b781a3d7d425 Mon Sep 17 00:00:00 2001 From: Philipp Dunkel Date: Mon, 31 Aug 2026 16:32:55 +0200 Subject: [PATCH 4/4] src: register the --allow-fs-vfs permission flag Mounting a VFS under the permission model is refused with a message telling the user to pass --allow-fs-vfs, but that option was never registered, so `node --allow-fs-vfs` failed with "bad option" and a virtual file system could not be mounted under --permission at all. Register it alongside the other permission flags and document it. Signed-off-by: Philipp Dunkel --- doc/api/cli.md | 23 +++++++++++ doc/node.1 | 13 +++++++ src/node_options.cc | 6 +++ src/node_options.h | 1 + .../parallel/test-permission-dlopen-binary.js | 38 +++++++++++++++++++ 5 files changed, 81 insertions(+) diff --git a/doc/api/cli.md b/doc/api/cli.md index 59945be330a2..ff84d740913e 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -268,6 +268,26 @@ process.permission.has('fs.read', 'custom-require.js'); // true process.permission.has('fs.read', 'custom-require-2.js'); // true ``` +### `--allow-fs-vfs` + + + +> Stability: 1.1 - Active development + +When using the [Permission Model][], a [virtual file system][] cannot be +mounted by default: [`vfs.mount()`][] throws `ERR_INVALID_STATE` unless the +user explicitly passes the `--allow-fs-vfs` flag when starting Node.js. + +A mounted VFS serves paths that the file system permissions do not describe, +so mounting one is gated on its own flag rather than on `--allow-fs-read` or +`--allow-fs-write`. + +```console +$ node --experimental-vfs --permission --allow-fs-vfs app.js +``` + ### `--allow-fs-write`