mirror of
https://github.com/nodejs/node.git
synced 2026-11-09 15:11:11 -05:00
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 <pip@pipobscure.com> PR-URL: https://github.com/nodejs/node/pull/65680 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Paolo Insogna <paolo@cowtech.it> Reviewed-By: James M Snell <jasnell@gmail.com>
This commit is contained in:
1 parent
dd0f2032e6
commit
ee36353428
12 files changed
+627
-6
No files matched your search
@@ -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`
|
||||
|
||||
<!-- YAML
|
||||
added: REPLACEME
|
||||
-->
|
||||
|
||||
> 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`
|
||||
|
||||
<!-- YAML
|
||||
@@ -4021,6 +4041,7 @@ one is included in the list below.
|
||||
* `--allow-child-process`
|
||||
* `--allow-ffi`
|
||||
* `--allow-fs-read`
|
||||
* `--allow-fs-vfs`
|
||||
* `--allow-fs-write`
|
||||
* `--allow-inspector`
|
||||
* `--allow-net`
|
||||
@@ -4741,6 +4762,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
|
||||
[`v8.startupSnapshot.addDeserializeCallback()`]: v8.md#v8startupsnapshotadddeserializecallbackcallback-data
|
||||
[`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data
|
||||
[`v8.startupSnapshot` API]: v8.md#startup-snapshot-api
|
||||
[`vfs.mount()`]: vfs.md#vfsmount
|
||||
[asynchronous module customization hooks]: module.md#asynchronous-customization-hooks
|
||||
[benchmark runner]: bench.md#command-line-runner
|
||||
[captured by the built-in snapshot of Node.js]: https://github.com/nodejs/node/blob/b19525a33cc84033af4addd0f80acd4dc33ce0cf/test/parallel/test-bootstrap-modules.js#L24
|
||||
@@ -4773,4 +4795,5 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
|
||||
[test runner execution model]: test.md#test-runner-execution-model
|
||||
[timezone IDs]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
[tracking issue for user-land snapshots]: https://github.com/nodejs/node/issues/44014
|
||||
[virtual file system]: vfs.md
|
||||
[ways that `TZ` is handled in other environments]: https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html
|
||||
@@ -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
|
||||
|
||||
+13
@@ -166,6 +166,17 @@ process.permission.has('fs.read', 'custom-require.js'); // true
|
||||
process.permission.has('fs.read', 'custom-require-2.js'); // true
|
||||
.Ed
|
||||
.
|
||||
.It Fl -allow-fs-vfs
|
||||
When using the Permission Model, a virtual file system cannot be
|
||||
mounted by default: \fBvfs.mount()\fR throws \fBERR_INVALID_STATE\fR unless the
|
||||
user explicitly passes the \fB--allow-fs-vfs\fR 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 \fB--allow-fs-read\fR or
|
||||
\fB--allow-fs-write\fR.
|
||||
.Bd -literal
|
||||
$ node --experimental-vfs --permission --allow-fs-vfs app.js
|
||||
.Ed
|
||||
.
|
||||
.It Fl -allow-fs-write
|
||||
This flag configures file system write permissions using
|
||||
the Permission Model.
|
||||
@@ -2018,6 +2029,8 @@ one is included in the list below.
|
||||
.It
|
||||
\fB--allow-fs-read\fR
|
||||
.It
|
||||
\fB--allow-fs-vfs\fR
|
||||
.It
|
||||
\fB--allow-fs-write\fR
|
||||
.It
|
||||
\fB--allow-inspector\fR
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+352
-6
@@ -9,6 +9,20 @@
|
||||
#include "util.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
#if defined(__linux__)
|
||||
#include <sys/mman.h>
|
||||
#include <sys/syscall.h>
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if HAVE_OPENSSL
|
||||
#define NODE_BUILTIN_OPENSSL_BINDINGS(V) V(crypto) V(tls_wrap)
|
||||
@@ -438,13 +452,295 @@ 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<size_t>(n);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#if defined(__linux__)
|
||||
int NodeMemfdCreate(const char* name, unsigned int flags) {
|
||||
return static_cast<int>(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;
|
||||
|
||||
// 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_; }
|
||||
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<HANDLE>* 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);
|
||||
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<DWORD>(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<HANDLE>();
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// 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('/');
|
||||
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<char> 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<Value>& args) {
|
||||
static void DLOpenImpl(const FunctionCallbackInfo<Value>& args,
|
||||
bool allow_binary) {
|
||||
Environment* env = Environment::GetCurrent(args);
|
||||
|
||||
if (env->no_native_addons()) {
|
||||
@@ -464,7 +760,11 @@ void DLOpen(const FunctionCallbackInfo<Value>& 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 +777,44 @@ void DLOpen(const FunctionCallbackInfo<Value>& 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");
|
||||
}
|
||||
// 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<char> 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 +914,20 @@ void DLOpen(const FunctionCallbackInfo<Value>& args) {
|
||||
// coverity[leaked_storage]
|
||||
}
|
||||
|
||||
// process.dlopen(module, filename[, flags]). Used to load 'module.node'
|
||||
// dynamically shared objects.
|
||||
void DLOpen(const FunctionCallbackInfo<Value>& 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<Value>& args) {
|
||||
DLOpenImpl(args, true);
|
||||
}
|
||||
|
||||
inline struct node_module* FindModule(struct node_module* list,
|
||||
const char* name,
|
||||
int flag) {
|
||||
|
||||
@@ -168,6 +168,7 @@ void CreateInternalBindingTemplates(IsolateData* isolate_data);
|
||||
void GetInternalBinding(const v8::FunctionCallbackInfo<v8::Value>& args);
|
||||
void GetLinkedBinding(const v8::FunctionCallbackInfo<v8::Value>& args);
|
||||
void DLOpen(const v8::FunctionCallbackInfo<v8::Value>& args);
|
||||
void DLOpenBinary(const v8::FunctionCallbackInfo<v8::Value>& args);
|
||||
|
||||
} // namespace binding
|
||||
|
||||
|
||||
@@ -738,6 +738,12 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
|
||||
&EnvironmentOptions::allow_fs_write,
|
||||
kAllowedInEnvvar,
|
||||
OptionNamespaces::kPermissionNamespace);
|
||||
AddOption("--allow-fs-vfs",
|
||||
"allow mounting a virtual file system when any permissions are set",
|
||||
BOOL_FIELD(allow_fs_vfs),
|
||||
kAllowedInEnvvar,
|
||||
false,
|
||||
OptionNamespaces::kPermissionNamespace);
|
||||
AddOption("--allow-addons",
|
||||
"allow use of addons when any permissions are set",
|
||||
BOOL_FIELD(allow_addons),
|
||||
|
||||
@@ -225,6 +225,7 @@ class EnvironmentOptions : public Options {
|
||||
DEFINE_BOOL_FIELD(permission) = false;
|
||||
DEFINE_BOOL_FIELD(permission_audit) = false;
|
||||
DEFINE_BOOL_FIELD(allow_addons) = false;
|
||||
DEFINE_BOOL_FIELD(allow_fs_vfs) = false;
|
||||
DEFINE_BOOL_FIELD(allow_inspector) = false;
|
||||
DEFINE_BOOL_FIELD(allow_child_process) = false;
|
||||
DEFINE_BOOL_FIELD(allow_net) = false;
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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' });
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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/);
|
||||
}
|
||||
|
||||
// The same, reached the way a program actually gets here: an addon inside a
|
||||
// mounted VFS. Mounting one under the permission model needs --allow-fs-vfs.
|
||||
{
|
||||
const mounted = `
|
||||
const fs = require('fs');
|
||||
const vfs = require('node:vfs');
|
||||
const myVfs = vfs.create();
|
||||
myVfs.writeFileSync('/binding.node',
|
||||
fs.readFileSync(${JSON.stringify(addonPath)}));
|
||||
const mountPoint = myVfs.mount();
|
||||
try {
|
||||
console.log('LOADED', require(mountPoint + '/binding.node').hello());
|
||||
} catch (err) {
|
||||
console.log('DENIED', err.code, err.permission ?? '');
|
||||
}
|
||||
`;
|
||||
const vfsRun = (...permissions) => spawnSync(
|
||||
process.execPath,
|
||||
['--experimental-vfs', '--permission', '--allow-fs-read=*',
|
||||
...permissions, '-e', mounted],
|
||||
{ encoding: 'utf8' });
|
||||
|
||||
const ok = vfsRun('--allow-fs-vfs', '--allow-addons', '--allow-fs-write=*');
|
||||
assert.strictEqual(ok.status, 0, ok.stderr);
|
||||
assert.match(ok.stdout, /LOADED world/);
|
||||
|
||||
// Mounting itself is refused without --allow-fs-vfs.
|
||||
const noVfs = vfsRun('--allow-addons', '--allow-fs-write=*');
|
||||
assert.notStrictEqual(noVfs.status, 0);
|
||||
assert.match(noVfs.stderr, /ERR_INVALID_STATE/);
|
||||
assert.match(noVfs.stderr, /--allow-fs-vfs/);
|
||||
|
||||
// Mounted, addons allowed, but no write access: the addon is refused.
|
||||
const noWrite = vfsRun('--allow-fs-vfs', '--allow-addons');
|
||||
assert.strictEqual(noWrite.status, 0, noWrite.stderr);
|
||||
assert.match(noWrite.stdout, /DENIED ERR_ACCESS_DENIED FileSystemWrite/);
|
||||
}
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user