Commit f378cfcfbfc for nodejs
commit f378cfcfbfcb673d942ec60d6fb75fbb2a125796
Author: James M Snell <jasnell@gmail.com>
Date: Sat Sep 19 13:47:05 2026 +0000
src,lib: add --allow-env permission
Necessarily semver-major.
When `--permission` is on, every env var not matched by
`--allow-env` is removed at startup. It takes names,
prefix patterns (`PREFIX_*`), or `*`, repeatable or
comma-sep'd.
There are a range of env vars that Node.js itself uses,
and a default range that are generally known to be safe
in common usage. These are never scrubbed. These include
things like `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`, `PATH`,
`HOME`, etc. `NODE_ENV` is not in the defaults and must
be allowed explicitly.
Proxy vars (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`) are
also not in the defaults since they can carry credentials.
When `--use-env-proxy` or `NODE_USE_ENV_PROXY` is set and
any of them were removed, a single warning naming them is
emitted.
Env vars can be dropped at runtime after reading using
`permission.drop()`. This is a stronger protection than
using `process.env.FOO = undefined` because it will
scrub the env var also from the environment block.
On Linux, the removed entries are overwritten in the
initial environment block. fs reads of any other
process's /proc/<pid>/environ, ancestors included, are
denied regardless of `--allow-fs-read`. A process's own
is readable only with `--allow-env=*`. Symlinks are
resolved before the check so paths like
/dev/fd/../../<ppid>/environ are caught. The check only
canonicalizes paths that statfs() reports are on procfs.
On Windows, removal also clears the C runtime's copy
of the environ using _wputenv_s.
Reading a removed name returns undefined, warns once per
name, and publishes to a diagnostics channel.
Env file keys are allowed. If the user had reason to pass
in an env file the assumption is they meant to allow them.
File-source config (node.config.json and NODE_OPTIONS
from a .env file) can only narrow the allow list.
Embedders must call ScrubProcessEnvironment() themselves
on startup. This is left up to the embedder to determine
the exact timing but needs to be called before startup
actually happens.
Child processes are started with `--allow-env=*`. Those
either receive the explicit env they were started with
or only the env they inherit from the parent. Since the
parent process is scrubbed, and the child cannot read
any other process's /proc/<pid>/environ, it should never
see more than the parent can.
Main part of the impl was done by hand. Docs, tests,
verification pass, and cleanup nits were automated.
Signed-off-by: James M Snell <jasnell@gmail.com>
Assisted-by: Opencode
PR-URL: https://github.com/nodejs/node/pull/66132
Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
diff --git a/doc/api/cli.md b/doc/api/cli.md
index cafc7ed2e4b..01116321f7a 100644
--- a/doc/api/cli.md
+++ b/doc/api/cli.md
@@ -191,6 +191,51 @@ This behavior also applies to `child_process.spawn()`, but in that case, the
flags are propagated via the `NODE_OPTIONS` environment variable rather than
directly through the process arguments.
+### `--allow-env`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+> Stability: 1.1 - Active development
+
+When using the [Permission Model][], the process starts without the environment
+variables it has not been granted access to. At startup, every variable that
+`--allow-env` does not match is removed from the process environment. Removed
+variables are absent from `process.env`, from diagnostic reports, from native
+code calling `getenv()`, and from the environment of child processes and worker
+threads.
+
+The valid values are:
+
+* `*` - Grants access to every environment variable.
+* A variable name, for example `--allow-env=DATABASE_URL`.
+* A variable name prefix followed by `*`, for example `--allow-env=APP_*`.
+
+Multiple values can be passed by repeating the flag, or by separating them with
+commas: `--allow-env=PORT,APP_*`. Variable names are case-insensitive on
+Windows.
+
+Example:
+
+```js
+console.log(process.env.DATABASE_URL);
+console.log(process.env.AWS_SECRET_ACCESS_KEY);
+```
+
+```console
+$ node --permission --allow-fs-read=* --allow-env=DATABASE_URL index.js
+postgres://localhost/app
+undefined
+(node:1234) Warning: The permission model removed the environment variable "AWS_SECRET_ACCESS_KEY" at startup. Use --allow-env to manage permissions.
+```
+
+The variables that Node.js and its bundled dependencies read, such as
+`NODE_OPTIONS`, `PATH`, `HOME`, `TZ`, and `SSL_CERT_FILE`, are always kept, as
+are the variables defined in [`--env-file`][] files. `NODE_ENV` is not kept
+by default, so applications and libraries that read it need
+`--allow-env=NODE_ENV`. See [Environment variable permissions][] for details.
+
### `--allow-ffi`
<!-- YAML
@@ -402,6 +447,11 @@ This flag grants broad authority to configured OpenSSL STORE loaders. A loader
may access files, devices, tokens, or the network. Access performed by a loader
is not constrained by the `fs.read`, `fs.write`, or `net` permission scopes.
+Loaders and the modules they load are subject to [`--allow-env`][], however.
+Environment variables they rely on, such as `SOFTHSM2_CONF` for SoftHSM, are
+removed at startup unless they are granted explicitly with `--allow-env`. See
+[Environment variable permissions][] for details.
+
### `--allow-wasi`
<!-- YAML
@@ -2538,6 +2588,7 @@ following permissions are restricted:
* File System - manageable through
[`--allow-fs-read`][], [`--allow-fs-write`][] flags
* Network - manageable through [`--allow-net`][] flag
+* Environment variables - manageable through [`--allow-env`][] flag
* Child Process - manageable through [`--allow-child-process`][] flag
* Worker Threads - manageable through [`--allow-worker`][] flag
* WASI - manageable through [`--allow-wasi`][] flag
@@ -4179,6 +4230,7 @@ one is included in the list below.
* `--allow-addons`
* `--allow-child-process`
+* `--allow-env`
* `--allow-ffi`
* `--allow-fs-read`
* `--allow-fs-vfs`
@@ -4826,6 +4878,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[CommonJS module]: modules.md
[DEP0025 warning]: deprecations.md#dep0025-requirenodesys
[ECMAScript module]: esm.md#modules-ecmascript-modules
+[Environment variable permissions]: permissions.md#environment-variable-permissions
[EventSource Web API]: https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
[ExperimentalWarning: `vm.measureMemory` is an experimental feature]: vm.md#vmmeasurememoryoptions
[FIPS mode]: crypto.md#fips-mode
@@ -4849,6 +4902,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`'crypto.fips.indicator'`]: diagnostics_channel.md#event-cryptofipsindicator
[`--allow-addons`]: #--allow-addons
[`--allow-child-process`]: #--allow-child-process
+[`--allow-env`]: #--allow-env
[`--allow-fs-read`]: #--allow-fs-read
[`--allow-fs-write`]: #--allow-fs-write
[`--allow-net`]: #--allow-net
diff --git a/doc/api/embedding.md b/doc/api/embedding.md
index dfb84b49ef9..3bbff4dfb3e 100644
--- a/doc/api/embedding.md
+++ b/doc/api/embedding.md
@@ -72,6 +72,51 @@ int main(int argc, char** argv) {
}
```
+### Restricting access to environment variables
+
+<!-- YAML
+added: REPLACEME
+-->
+
+When the arguments passed to `node::InitializeOncePerProcess()` enable the
+[Permission Model][] without `--allow-env=*`, the process environment must not
+contain any variable that [`--allow-env`][] does not grant access to.
+`node::InitializeOncePerProcess()` fails otherwise. Unlike the `node`
+executable, embedders own the process environment, so Node.js does not remove
+these variables itself.
+
+`node::ScrubProcessEnvironment()` removes them. Because it modifies the process
+environment without any locking that native code calling `getenv()`
+participates in, it must be called before starting any thread that may read the
+environment, and before `node::InitializeOncePerProcess()`:
+
+```cpp
+int main(int argc, char** argv) {
+ argv = uv_setup_args(argc, argv);
+ std::vector<std::string> args(argv, argv + argc);
+
+ // Keep the variables the embedder itself reads, in addition to the ones
+ // Node.js reads (see node::GetRuntimeEnvironmentDefaults()).
+ node::ProcessEnvironmentScrubOptions scrub_options;
+ scrub_options.allow = {"PORT", "APP_*"};
+ if (node::ScrubProcessEnvironment(scrub_options).IsNothing()) {
+ return 1;
+ }
+
+ // args contains, for example, --permission --allow-env=PORT
+ std::unique_ptr<node::InitializationResult> result =
+ node::InitializeOncePerProcess(args, {
+ node::ProcessInitializationFlags::kNoInitializeV8,
+ node::ProcessInitializationFlags::kNoInitializeNodeV8Platform
+ });
+ // ...
+}
+```
+
+`process.permission.drop('env', name)` removes a variable from the process
+environment, so it throws when called from a `node::Environment` created
+without `node::EnvironmentFlags::kOwnsProcessState`.
+
### Setting up a per-instance state
<!-- YAML
@@ -178,6 +223,8 @@ int RunNodeInstance(MultiIsolatePlatform* platform,
```
[CLI options]: cli.md
+[Permission Model]: permissions.md#permission-model
+[`--allow-env`]: cli.md#--allow-env
[`process.memoryUsage()`]: process.md#processmemoryusage
[deprecation policy]: deprecations.md
[embedtest.cc]: https://github.com/nodejs/node/blob/HEAD/test/embedding/embedtest.cc
diff --git a/doc/api/permissions.md b/doc/api/permissions.md
index e84cbc0cce2..b531541f65f 100644
--- a/doc/api/permissions.md
+++ b/doc/api/permissions.md
@@ -61,9 +61,9 @@ The Permission Model has two operational modes:
When starting Node.js with `--permission`,
the ability to access the file system through the `fs` module, access the network,
-spawn processes, use `node:worker_threads`, use native addons, use WASI, use
-FFI, and enable the runtime inspector will be restricted (the listener for
-SIGUSR1 won't be created).
+access environment variables, spawn processes, use `node:worker_threads`, use
+native addons, use WASI, use FFI, and enable the runtime inspector will be
+restricted (the listener for SIGUSR1 won't be created).
```console
$ node --permission index.js
@@ -79,6 +79,8 @@ Error: Access to this API has been restricted
Allowing access to spawning a process and creating worker threads can be done
using the [`--allow-child-process`][] and [`--allow-worker`][] respectively.
+To grant access to environment variables, use [`--allow-env`][].
+
To allow network access, use [`--allow-net`][] and for allowing native addons
when using permission model, use the [`--allow-addons`][]
flag. For WASI, use the [`--allow-wasi`][] flag. For FFI, use the
@@ -157,9 +159,9 @@ mode. Execution continues normally.
Audit mode is useful for discovering what permissions your application
requires before deploying with [`--permission`][]. It can also be combined
with the [`--allow-fs-read`][], [`--allow-fs-write`][], [`--allow-net`][],
-[`--allow-child-process`][], [`--allow-worker`][], [`--allow-addons`][],
-[`--allow-wasi`][], and [`--allow-ffi`][] flags to audit a subset of
-permissions while granting others.
+[`--allow-env`][], [`--allow-child-process`][], [`--allow-worker`][],
+[`--allow-addons`][], [`--allow-wasi`][], and [`--allow-ffi`][] flags to audit
+a subset of permissions while granting others.
When a permission check fails in audit mode, a message is published to the
diagnostics channel corresponding to the denied scope. The channel names are:
@@ -172,6 +174,7 @@ diagnostics channel corresponding to the denied scope. The channel names are:
* `node:permission-model:wasi` — WASI
* `node:permission-model:addon` — Native Addons
* `node:permission-model:ffi` — FFI
+* `node:permission-model:env` — Environment variables
Each message is an object with the following properties:
@@ -266,6 +269,109 @@ both to the top-level `node:fs` functions and to the equivalent
`FileHandle` methods, and currently includes `fsync`/`fdatasync`,
`fchmod`, and `fchown` (and their synchronous variants).
+#### Environment variable permissions
+
+When the Permission Model is enforced, the process only has access to the
+environment variables that [`--allow-env`][] grants access to.
+
+Instead of checking each access, Node.js removes every other variable from the
+process environment at startup, before any JavaScript code runs and before
+Node.js starts any other thread. Removed variables are absent from everything
+that exposes the environment of the process: `process.env`, diagnostic reports,
+native code calling `getenv()`, worker threads, and the environment inherited by
+child processes.
+
+```console
+$ node --permission --allow-env=PORT --allow-env=APP_* index.js
+```
+
+The valid arguments for the flag are:
+
+* `*` - Grants access to every environment variable. Nothing is removed.
+* A variable name, such as `PORT`.
+* A variable name prefix followed by `*`, such as `APP_*`.
+
+Some variables are always kept:
+
+* The variables that Node.js and its bundled dependencies read after startup,
+ such as `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`, `PATH`, `HOME`, `TMPDIR`, `TZ`,
+ `LANG`, `SSL_CERT_FILE`, and the variables that terminal color detection
+ reads. Other variables whose names start with `NODE_`, such as
+ `NODE_AUTH_TOKEN`, are not kept.
+* The variables defined in the files passed to [`--env-file`][] and
+ [`--env-file-if-exists`][]. If a variable is defined in such a file and also
+ inherited from the parent process, and `--allow-env` does not grant access to
+ it, the inherited value is removed and the value from the file is used.
+
+`NODE_ENV` is not kept either. Node.js does not read it, but many applications
+and libraries do, and treat it being unset as a development environment. Grant
+access to it explicitly:
+
+```console
+$ node --permission --allow-env=NODE_ENV index.js
+```
+
+Proxy URLs often contain credentials, so the `HTTP_PROXY`, `HTTPS_PROXY`, and
+`NO_PROXY` variables, and their lowercase forms, are not kept. Grant access to
+them explicitly when using [`--use-env-proxy`][]. When `--use-env-proxy` is
+enabled and any of them were removed at startup, a warning naming them is
+emitted.
+
+Native code that Node.js loads on behalf of the application, such as addons,
+OpenSSL providers and STORE loaders, and the libraries they load in turn, sees
+the same reduced environment. Only the variables that OpenSSL itself reads are
+kept, not those read by third-party modules it loads. For example, a PKCS#11
+provider backed by SoftHSM needs `SOFTHSM2_CONF` to find its token, and fails to
+initialize without it. Grant access to such variables explicitly:
+
+```console
+$ node --permission --allow-openssl-store --allow-env=SOFTHSM2_CONF index.js
+```
+
+Reading a variable that was removed at startup returns `undefined`, emits a
+warning the first time, and publishes a message to the
+`node:permission-model:env` diagnostics channel.
+
+Variables set at runtime, for example with `process.env.KEY = 'value'` or
+[`process.loadEnvFile()`][], are not restricted, as they cannot reveal what was
+removed.
+
+Dropping a variable with [`permission.drop()`][] removes it from the
+environment. Dropping the whole `env` scope removes every variable except the
+ones Node.js reads itself. This makes it possible to read a secret during
+initialization, and then remove it:
+
+```js
+const databaseUrl = process.env.DATABASE_URL;
+process.permission.drop('env', 'DATABASE_URL');
+```
+
+When a process that enforces the Permission Model spawns a child process, the
+child is started with `--allow-env=*`: the environment it inherits only contains
+variables that the parent had access to. The child can still read its own
+`/proc/<pid>/environ` on Linux, but not that of any other process, see below.
+
+In audit mode, nothing is removed. Accesses to variables that `--allow-env`
+does not grant access to are published to the `node:permission-model:env`
+diagnostics channel instead.
+
+On Linux, `/proc/<pid>/environ` exposes the environment a process was started
+with. When the Permission Model is enforced, reading the `/proc/<pid>/environ`
+file of any other process, including the parent process and its ancestors, is
+denied regardless of [`--allow-fs-read`][]. Reading the process's own file is
+only allowed with `--allow-env=*`. Symbolic links are resolved before the
+check, so paths that reach these files indirectly, such as
+`/dev/fd/../environ`, are denied as well.
+
+In addition, the removed variables are overwritten in the initial environment
+block of the process, so that other processes do not find them in its
+`/proc/<pid>/environ` either. Variables removed later with
+[`permission.drop()`][] are overwritten there as well.
+
+These measures do not change the environment of other processes. A process
+granted [`--allow-child-process`][] can read their environment through other
+programs.
+
#### Configuration file support
In addition to passing permission flags on the command line, they can also be
@@ -297,6 +403,20 @@ automatically enables the `--permission` flag. Run with:
$ node --experimental-default-config-file app.js
```
+A configuration file, like the `NODE_OPTIONS` defined in an [`--env-file`][]
+file, may be controlled by the project being run rather than by whoever starts
+Node.js. When the command line or the `NODE_OPTIONS` environment variable
+enable the Permission Model, the `allow-env` values these files define can only
+narrow the access that [`--allow-env`][] grants, and never widen it:
+
+```console
+$ node --permission --allow-env=APP_* --experimental-config-file=node.config.json app.js
+```
+
+With `"allow-env": ["*"]` in `node.config.json`, only the variables starting with
+`APP_` are kept. With `"allow-env": ["APP_DATABASE_URL", "OTHER"]`, only
+`APP_DATABASE_URL` is.
+
#### Using the Permission Model with `npx`
If you're using [`npx`][] to execute a Node.js script, you can enable the
@@ -348,6 +468,7 @@ There are constraints you need to know before using this system:
* When using the Permission Model the following features will be restricted:
* Native modules
* Network
+ * Environment variables
* Child process
* Worker Threads
* Inspector protocol
@@ -410,6 +531,7 @@ Developers relying on --permission to sandbox untrusted code should be aware tha
[Security Policy]: https://github.com/nodejs/node/blob/main/SECURITY.md
[`--allow-addons`]: cli.md#--allow-addons
[`--allow-child-process`]: cli.md#--allow-child-process
+[`--allow-env`]: cli.md#--allow-env
[`--allow-ffi`]: cli.md#--allow-ffi
[`--allow-fs-read`]: cli.md#--allow-fs-read
[`--allow-fs-write`]: cli.md#--allow-fs-write
@@ -417,8 +539,13 @@ Developers relying on --permission to sandbox untrusted code should be aware tha
[`--allow-openssl-store`]: cli.md#--allow-openssl-store
[`--allow-wasi`]: cli.md#--allow-wasi
[`--allow-worker`]: cli.md#--allow-worker
+[`--env-file-if-exists`]: cli.md#--env-file-if-existsfile
+[`--env-file`]: cli.md#--env-filefile
[`--permission-audit`]: cli.md#--permission-audit
[`--permission`]: cli.md#--permission
+[`--use-env-proxy`]: cli.md#--use-env-proxy
[`crypto.createPrivateKey()`]: crypto.md#cryptocreateprivatekeykey
[`npx`]: https://docs.npmjs.com/cli/commands/npx
+[`permission.drop()`]: process.md#processpermissiondropscope-reference
[`permission.has()`]: process.md#processpermissionhasscope-reference
+[`process.loadEnvFile()`]: process.md#processloadenvfilepath
diff --git a/doc/api/process.md b/doc/api/process.md
index 8d0e4ae8d6f..8b210a1e7c0 100644
--- a/doc/api/process.md
+++ b/doc/api/process.md
@@ -3163,6 +3163,7 @@ The available scopes are:
* `fs.read` - File System read operations
* `fs.write` - File System write operations
* `child` - Child process spawning operations
+* `env` - Environment variables
* `openssl.store` - Loading keys through OpenSSL STORE loaders
* `worker` - Worker thread spawning operation
* `ffi` - Foreign function interface operations
@@ -3220,6 +3221,8 @@ The available scopes are the same as [`process.permission.has()`][]:
* `fs.read` - File System read operations
* `fs.write` - File System write operations
* `child` - Child process spawning operations
+* `env` - Environment variables. Dropping a variable removes it from the
+ environment
* `openssl.store` - Loading keys through OpenSSL STORE loaders
* `worker` - Worker thread spawning operation
* `net` - Network operations
diff --git a/doc/node.1 b/doc/node.1
index fad3aa95f15..f1b5afe2705 100644
--- a/doc/node.1
+++ b/doc/node.1
@@ -118,6 +118,42 @@ This behavior also applies to \fBchild_process.spawn()\fR, but in that case, the
flags are propagated via the \fBNODE_OPTIONS\fR environment variable rather than
directly through the process arguments.
.
+.It Fl -allow-env
+When using the Permission Model, the process starts without the environment
+variables it has not been granted access to. At startup, every variable that
+\fB--allow-env\fR does not match is removed from the process environment. Removed
+variables are absent from \fBprocess.env\fR, from diagnostic reports, from native
+code calling \fBgetenv()\fR, and from the environment of child processes and worker
+threads.
+The valid values are:
+.Bl -bullet
+.It
+\fB*\fR - Grants access to every environment variable.
+.It
+A variable name, for example \fB--allow-env=DATABASE_URL\fR.
+.It
+A variable name prefix followed by \fB*\fR, for example \fB--allow-env=APP_*\fR.
+.El
+Multiple values can be passed by repeating the flag, or by separating them with
+commas: \fB--allow-env=PORT,APP_*\fR. Variable names are case-insensitive on
+Windows.
+Example:
+.Bd -literal
+console.log(process.env.DATABASE_URL);
+console.log(process.env.AWS_SECRET_ACCESS_KEY);
+.Ed
+.Bd -literal
+$ node --permission --allow-fs-read=* --allow-env=DATABASE_URL index.js
+postgres://localhost/app
+undefined
+(node:1234) Warning: The permission model removed the environment variable "AWS_SECRET_ACCESS_KEY" at startup. Use --allow-env to manage permissions.
+.Ed
+The variables that Node.js and its bundled dependencies read, such as
+\fBNODE_OPTIONS\fR, \fBPATH\fR, \fBHOME\fR, \fBTZ\fR, and \fBSSL_CERT_FILE\fR, are always kept, as
+are the variables defined in \fB--env-file\fR files. \fBNODE_ENV\fR is not kept
+by default, so applications and libraries that read it need
+\fB--allow-env=NODE_ENV\fR. See Environment variable permissions for details.
+.
.It Fl -allow-ffi
When using the Permission Model, the process will not be able to use FFI
APIs by default. Attempts to use FFI APIs will throw an \fBERR_ACCESS_DENIED\fR
@@ -243,6 +279,10 @@ an \fBERR_ACCESS_DENIED\fR unless the user explicitly passes the
This flag grants broad authority to configured OpenSSL STORE loaders. A loader
may access files, devices, tokens, or the network. Access performed by a loader
is not constrained by the \fBfs.read\fR, \fBfs.write\fR, or \fBnet\fR permission scopes.
+Loaders and the modules they load are subject to \fB--allow-env\fR, however.
+Environment variables they rely on, such as \fBSOFTHSM2_CONF\fR for SoftHSM, are
+removed at startup unless they are granted explicitly with \fB--allow-env\fR. See
+Environment variable permissions for details.
.
.It Fl -allow-wasi
When using the Permission Model, the process will not be capable of creating
@@ -1274,6 +1314,8 @@ File System - manageable through
.It
Network - manageable through \fB--allow-net\fR flag
.It
+Environment variables - manageable through \fB--allow-env\fR flag
+.It
Child Process - manageable through \fB--allow-child-process\fR flag
.It
Worker Threads - manageable through \fB--allow-worker\fR flag
@@ -2124,6 +2166,8 @@ one is included in the list below.
.It
\fB--allow-child-process\fR
.It
+\fB--allow-env\fR
+.It
\fB--allow-ffi\fR
.It
\fB--allow-fs-read\fR
diff --git a/lib/child_process.js b/lib/child_process.js
index 1fdc520d31e..e2eb30d15b6 100644
--- a/lib/child_process.js
+++ b/lib/child_process.js
@@ -570,14 +570,26 @@ function copyPermissionModelFlagsToEnv(env, key, args) {
return;
}
+ // Enforcing the permission model removed the variables --allow-env does not
+ // grant access to at startup, so everything left in the environment a child
+ // inherits is accessible. A child can never see more than its parent had.
+ const allowAllEnv = !permission.isAuditMode();
+
const flagsToCopy = getPermissionModelFlagsToCopy();
for (const arg of process.execArgv) {
+ if (allowAllEnv && arg.startsWith('--allow-env')) {
+ continue;
+ }
for (const flag of flagsToCopy) {
if (arg.startsWith(flag)) {
env[key] = `${env[key] ? env[key] + ' ' + arg : arg}`;
}
}
}
+
+ if (allowAllEnv) {
+ env[key] = `${env[key] ? env[key] + ' ' : ''}--allow-env=*`;
+ }
}
let emittedDEP0190Already = false;
diff --git a/lib/internal/process/permission.js b/lib/internal/process/permission.js
index acfbab036bb..d2921694b99 100644
--- a/lib/internal/process/permission.js
+++ b/lib/internal/process/permission.js
@@ -76,6 +76,7 @@ module.exports = ObjectFreeze({
'--allow-fs-write',
'--allow-addons',
'--allow-child-process',
+ '--allow-env',
'--allow-net',
'--allow-inspector',
'--allow-wasi',
diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js
index 74f4480565e..57bfb8eb845 100644
--- a/lib/internal/process/pre_execution.js
+++ b/lib/internal/process/pre_execution.js
@@ -1,7 +1,9 @@
'use strict';
const {
+ ArrayPrototypeFilter,
ArrayPrototypeForEach,
+ ArrayPrototypeJoin,
ArrayPrototypeSplice,
Date,
DatePrototypeGetDate,
@@ -280,6 +282,9 @@ function setupHttpProxy() {
if (!getOptionValue('--use-env-proxy')) {
return;
}
+ // Before reading the variables below, which would warn about each removed
+ // one without saying that it disables the proxy settings.
+ warnAboutRemovedProxyEnvVars();
if (!process.env.HTTP_PROXY && !process.env.HTTPS_PROXY &&
!process.env.http_proxy && !process.env.https_proxy) {
return;
@@ -296,6 +301,31 @@ function setupHttpProxy() {
// existing libraries that sets the global dispatcher or monkey patches the global agent.
}
+// Enforcing the permission model removes the environment variables that
+// --allow-env does not grant access to at startup. The proxy variables are not
+// among the variables it keeps by default, because their values may contain
+// credentials, so --use-env-proxy silently stops applying them unless they
+// are allowed.
+function warnAboutRemovedProxyEnvVars() {
+ const permission = require('internal/process/permission');
+ if (!permission.isEnabled() || permission.isAuditMode()) {
+ return;
+ }
+ const { takeRemovedEnvVarWarning } = internalBinding('permission');
+ const removed = ArrayPrototypeFilter([
+ 'HTTP_PROXY', 'http_proxy',
+ 'HTTPS_PROXY', 'https_proxy',
+ 'NO_PROXY', 'no_proxy',
+ ], (name) => takeRemovedEnvVarWarning(name));
+ if (removed.length === 0) {
+ return;
+ }
+ process.emitWarning(
+ `--use-env-proxy is enabled, but the permission model removed ${ArrayPrototypeJoin(removed, ', ')} ` +
+ 'from the environment at startup, so the proxy settings they define are not applied. ' +
+ 'Use --allow-env to grant access to them.');
+}
+
function initializeModuleLoaders(options) {
const { shouldSpawnLoaderHookWorker, shouldPreloadModules } = options;
// Initialize certain special module.Module properties and the CJS conditions.
diff --git a/node.gyp b/node.gyp
index 7ad2d95ef9e..b1504efb010 100644
--- a/node.gyp
+++ b/node.gyp
@@ -186,6 +186,7 @@
'src/node_worker.cc',
'src/node_zlib.cc',
'src/path.cc',
+ 'src/permission/env_permission.cc',
'src/permission/fs_permission.cc',
'src/permission/permission.cc',
'src/pipe_wrap.cc',
@@ -321,6 +322,7 @@
'src/node_worker.h',
'src/path.h',
'src/permission/boolean_permission.h',
+ 'src/permission/env_permission.h',
'src/permission/fs_permission.h',
'src/permission/permission.h',
'src/permission/permission_base.h',
diff --git a/src/env.cc b/src/env.cc
index d8851d01172..1f8235598c8 100644
--- a/src/env.cc
+++ b/src/env.cc
@@ -8,6 +8,7 @@
#include "node_buffer.h"
#include "node_context_data.h"
#include "node_contextify.h"
+#include "node_dotenv.h"
#include "node_errors.h"
#include "node_file_utils.h"
#include "node_internals.h"
@@ -1136,6 +1137,20 @@ Environment::Environment(IsolateData* isolate_data,
permission()->Apply(this, args, permission::PermissionScope::kWASI);
}
+ {
+ std::vector<std::string> allow_env =
+ permission::ParseEnvAllowList(options_->allow_env);
+ // Variables defined in env files are allowed. The environment scrub
+ // removed any inherited values they had, so only the files' values
+ // are visible.
+ if (options_->has_env_file_string) {
+ for (std::string& key : per_process::dotenv_file.GetKeys()) {
+ allow_env.push_back(std::move(key));
+ }
+ }
+ permission()->Apply(this, allow_env, permission::PermissionScope::kEnv);
+ }
+
// Implicit allow entrypoint to kFileSystemRead
if (!options_->has_eval_string && !options_->force_repl) {
std::string first_argv;
diff --git a/src/node.cc b/src/node.cc
index 268f0a4c756..d0b5310626c 100644
--- a/src/node.cc
+++ b/src/node.cc
@@ -46,6 +46,7 @@
#include "node_snapshot_builder.h"
#include "node_v8_platform-inl.h"
#include "node_version.h"
+#include "permission/env_permission.h"
#if HAVE_OPENSSL
#include "ncrypto.h"
@@ -863,6 +864,62 @@ int ProcessGlobalArgs(std::vector<std::string>* args,
static std::atomic_bool init_called{false};
+// Collects --allow-env per option source. Files (the configuration file, and
+// NODE_OPTIONS defined in env files) may be controlled by the project being
+// run rather than by whoever started Node.js, so when the command line or the
+// NODE_OPTIONS environment variable enable the permission model, --allow-env
+// values from files can only narrow the access those sources grant.
+class AllowEnvSources {
+ public:
+ explicit AllowEnvSources(EnvironmentOptions* options) : options_(options) {}
+
+ // Must be called before the options of a source are parsed.
+ void BeginSource() {
+ saved_permission_ = options_->permission;
+ saved_permission_audit_ = options_->permission_audit;
+ options_->permission = false;
+ options_->permission_audit = false;
+ }
+
+ // Must be called after the options of a source are parsed.
+ void EndSource(bool trusted) {
+ if (trusted && (options_->permission || options_->permission_audit)) {
+ trusted_enables_permission_ = true;
+ }
+ options_->permission = options_->permission || saved_permission_;
+ options_->permission_audit =
+ options_->permission_audit || saved_permission_audit_;
+
+ std::vector<std::string>& values = trusted ? trusted_ : from_files_;
+ values.insert(
+ values.end(), options_->allow_env.begin(), options_->allow_env.end());
+ options_->allow_env.clear();
+ }
+
+ // Must be called once every source has been parsed.
+ void Finish() {
+ std::vector<std::string> trusted = permission::ParseEnvAllowList(trusted_);
+ std::vector<std::string> from_files =
+ permission::ParseEnvAllowList(from_files_);
+ if (trusted_enables_permission_ && !from_files.empty()) {
+ options_->allow_env =
+ permission::IntersectEnvAllowLists(trusted, from_files);
+ return;
+ }
+ options_->allow_env = std::move(trusted);
+ options_->allow_env.insert(
+ options_->allow_env.end(), from_files.begin(), from_files.end());
+ }
+
+ private:
+ EnvironmentOptions* options_;
+ std::vector<std::string> trusted_;
+ std::vector<std::string> from_files_;
+ bool trusted_enables_permission_ = false;
+ bool saved_permission_ = false;
+ bool saved_permission_audit_ = false;
+};
+
// TODO(addaleax): Turn this into a wrapper around InitializeOncePerProcess()
// (with the corresponding additional flags set), then eventually remove this.
static ExitCode InitializeNodeWithArgsInternal(
@@ -982,6 +1039,9 @@ static ExitCode InitializeNodeWithArgsInternal(
node_options = node_options_from_config + node_options_from_dotenv;
+ AllowEnvSources allow_env_sources(
+ per_process::cli_options->per_isolate->per_env.get());
+
#if !defined(NODE_WITHOUT_NODE_OPTIONS)
bool should_parse_node_options =
!(flags & ProcessInitializationFlags::kDisableNodeOptionsEnv);
@@ -995,8 +1055,9 @@ static ExitCode InitializeNodeWithArgsInternal(
#endif
if (should_parse_node_options) {
// NODE_OPTIONS environment variable is preferred over the file one.
- if (credentials::SafeGetenv("NODE_OPTIONS", &node_options) ||
- !node_options.empty()) {
+ const bool node_options_from_env =
+ credentials::SafeGetenv("NODE_OPTIONS", &node_options);
+ if (node_options_from_env || !node_options.empty()) {
std::vector<std::string> env_argv =
ParseNodeOptionsEnvVar(node_options, errors);
@@ -1005,9 +1066,11 @@ static ExitCode InitializeNodeWithArgsInternal(
// [0] is expected to be the program name, fill it in from the real argv.
env_argv.insert(env_argv.begin(), argv->at(0));
+ allow_env_sources.BeginSource();
const ExitCode exit_code = ProcessGlobalArgsInternal(
&env_argv, nullptr, errors, kAllowedInEnvvar);
if (exit_code != ExitCode::kNoFailure) return exit_code;
+ allow_env_sources.EndSource(/* trusted */ node_options_from_env);
}
} else {
std::string node_repl_external_env = {};
@@ -1030,15 +1093,21 @@ static ExitCode InitializeNodeWithArgsInternal(
// [0] is expected to be the program name, fill it in from the real argv.
extra_argv.insert(extra_argv.begin(), argv->at(0));
// Parse the extra argv coming from the config file
+ allow_env_sources.BeginSource();
ExitCode exit_code = ProcessGlobalArgsInternal(
&extra_argv, nullptr, errors, kDisallowedInEnvvar);
if (exit_code != ExitCode::kNoFailure) return exit_code;
+ allow_env_sources.EndSource(/* trusted */ false);
// Parse options coming from the command line.
+ allow_env_sources.BeginSource();
exit_code =
ProcessGlobalArgsInternal(argv, exec_argv, errors, kDisallowedInEnvvar);
if (exit_code != ExitCode::kNoFailure) return exit_code;
+ allow_env_sources.EndSource(/* trusted */ true);
}
+ allow_env_sources.Finish();
+
// Every option source has now been parsed, so cross-source option
// constraints can finally be validated.
CheckGlobalBenchOptions(errors);
@@ -1166,10 +1235,17 @@ bool CanEnableWebAssemblyTrapHandler() {
}
#endif // NODE_USE_V8_WASM_TRAP_HANDLER
+// Whether InitializeOncePerProcessInternal() scrubs the process environment
+// when the permission model restricts access to it. Only node::Start() does
+// this. Embedders own their process environment, and scrub it themselves.
+enum class EnvironmentScrubMode { kNever, kIfRestricted };
+
static std::shared_ptr<InitializationResultImpl>
-InitializeOncePerProcessInternal(const std::vector<std::string>& args,
- ProcessInitializationFlags::Flags flags =
- ProcessInitializationFlags::kNoFlags) {
+InitializeOncePerProcessInternal(
+ const std::vector<std::string>& args,
+ ProcessInitializationFlags::Flags flags =
+ ProcessInitializationFlags::kNoFlags,
+ EnvironmentScrubMode scrub_mode = EnvironmentScrubMode::kNever) {
auto result = std::make_shared<InitializationResultImpl>();
result->args_ = args;
@@ -1358,6 +1434,54 @@ InitializeOncePerProcessInternal(const std::vector<std::string>& args,
#endif // HAVE_OPENSSL
}
+ {
+ const auto& env_options = per_process::cli_options->per_isolate->per_env;
+ const std::vector<std::string> allow =
+ permission::ParseEnvAllowList(env_options->allow_env);
+ const bool allow_all =
+ std::find(allow.begin(), allow.end(), "*") != allow.end();
+ if (env_options->permission_audit && !allow_all) {
+ // In audit mode nothing is removed, but accesses to the variables that
+ // enforcing the permission model would remove are published.
+ permission::RecordAuditedEnvironmentVariables(allow);
+ } else if (env_options->permission && !allow_all) {
+ if (scrub_mode == EnvironmentScrubMode::kIfRestricted) {
+ // When the permission model is enforced, remove every environment
+ // variable that --allow-env does not grant access to. Every
+ // per-process consumer of the environment has read it by now, and the
+ // process is still single-threaded: the platform worker threads start
+ // below.
+ permission::ScrubProcessEnvironment({.allow = allow});
+ } else {
+ // Embedders own the process environment, and must remove these
+ // variables themselves, with ScrubProcessEnvironment().
+ const std::vector<std::string> denied =
+ permission::FindDeniedEnvironmentVariables(allow);
+ if (!denied.empty()) {
+ constexpr size_t kMaxListedNames = 5;
+ std::string names;
+ for (size_t i = 0; i < denied.size() && i < kMaxListedNames; i++) {
+ if (i > 0) names += ", ";
+ names += denied[i];
+ }
+ if (denied.size() > kMaxListedNames) {
+ names += ", and " +
+ std::to_string(denied.size() - kMaxListedNames) + " more";
+ }
+ result->errors_.push_back(
+ "The process environment contains variables that --allow-env "
+ "does not grant access to (" +
+ names +
+ "). Remove them with node::ScrubProcessEnvironment() before "
+ "calling node::InitializeOncePerProcess().");
+ result->exit_code_ = ExitCode::kInvalidCommandLineArgument;
+ result->early_return_ = true;
+ return result;
+ }
+ }
+ }
+ }
+
if (!(flags & ProcessInitializationFlags::kNoInitializeNodeV8Platform)) {
uv_thread_setname("node-MainThread");
per_process::v8_platform.Initialize(
@@ -1431,6 +1555,29 @@ std::shared_ptr<InitializationResult> InitializeOncePerProcess(
return InitializeOncePerProcessInternal(args, flags);
}
+v8::Maybe<std::vector<std::string>> ScrubProcessEnvironment(
+ const ProcessEnvironmentScrubOptions& options) {
+ if (per_process::v8_initialized) {
+ return v8::Nothing<std::vector<std::string>>();
+ }
+ for (const std::string& pattern : options.allow) {
+ if (!permission::IsValidEnvAllowPattern(pattern)) {
+ return v8::Nothing<std::vector<std::string>>();
+ }
+ }
+ return v8::Just(permission::ScrubProcessEnvironment({
+ .allow = options.allow,
+ .keep_runtime_defaults = options.keep_runtime_defaults,
+ .wipe_initial_block = options.wipe_initial_block,
+ }));
+}
+
+std::vector<std::string> GetRuntimeEnvironmentDefaults() {
+ const std::span<const std::string_view> defaults =
+ permission::GetRuntimeEnvironmentDefaults();
+ return std::vector<std::string>(defaults.begin(), defaults.end());
+}
+
void TearDownOncePerProcess() {
const uint32_t flags = init_process_flags.load();
ResetStdio();
@@ -1662,7 +1809,9 @@ static ExitCode StartInternal(int argc, char** argv) {
std::shared_ptr<InitializationResultImpl> result =
InitializeOncePerProcessInternal(
- std::vector<std::string>(argv, argv + argc));
+ std::vector<std::string>(argv, argv + argc),
+ ProcessInitializationFlags::kNoFlags,
+ EnvironmentScrubMode::kIfRestricted);
for (const std::string& error : result->errors()) {
FPrintF(stderr, "%s: %s\n", result->args().at(0), error);
}
diff --git a/src/node.h b/src/node.h
index 9018a4b39b0..87af30b67fd 100644
--- a/src/node.h
+++ b/src/node.h
@@ -324,6 +324,42 @@ inline std::shared_ptr<InitializationResult> InitializeOncePerProcess(
args, static_cast<ProcessInitializationFlags::Flags>(flags_accum));
}
+struct ProcessEnvironmentScrubOptions {
+ // The environment variables to keep. Each entry is `*`, a variable name, or
+ // a variable name prefix followed by `*`. Names are case-insensitive on
+ // Windows.
+ std::vector<std::string> allow;
+ // Whether to also keep the variables that Node.js and its bundled
+ // dependencies read after startup, see GetRuntimeEnvironmentDefaults().
+ bool keep_runtime_defaults = true;
+ // Whether to overwrite the removed variables in the environment block the
+ // process was started with, which /proc/<pid>/environ exposes. Only
+ // implemented on Linux.
+ bool wipe_initial_block = true;
+};
+
+// Removes every variable that `options` does not keep from the process
+// environment, and returns the names of the removed variables.
+//
+// node::Start() does this automatically when the permission model restricts
+// access to environment variables. When `args` passed to
+// InitializeOncePerProcess() enable the permission model without
+// `--allow-env=*`, the embedder must remove the variables that `--allow-env`
+// does not grant access to first: InitializeOncePerProcess() fails if the
+// process environment contains any of them.
+//
+// This modifies the process environment without any locking that native code
+// calling getenv() participates in. It must be called before starting any
+// thread that may read the environment, and before
+// InitializeOncePerProcess(). Returns Nothing() if `options.allow` contains an
+// invalid entry, or if InitializeOncePerProcess() has already completed.
+NODE_EXTERN v8::Maybe<std::vector<std::string>> ScrubProcessEnvironment(
+ const ProcessEnvironmentScrubOptions& options);
+
+// Returns the names, and name prefixes followed by `*`, of the environment
+// variables that Node.js and its bundled dependencies read after startup.
+NODE_EXTERN std::vector<std::string> GetRuntimeEnvironmentDefaults();
+
enum OptionEnvvarSettings {
// Allow the options to be set via the environment variable, like
// `NODE_OPTIONS`.
diff --git a/src/node_dotenv.cc b/src/node_dotenv.cc
index e42eb3c9dea..0c6fa30a41d 100644
--- a/src/node_dotenv.cc
+++ b/src/node_dotenv.cc
@@ -86,6 +86,15 @@ Maybe<void> Dotenv::SetEnvironment(node::Environment* env) {
return JustVoid();
}
+std::vector<std::string> Dotenv::GetKeys() const {
+ std::vector<std::string> keys;
+ keys.reserve(store_.size());
+ for (const auto& entry : store_) {
+ keys.push_back(entry.first);
+ }
+ return keys;
+}
+
MaybeLocal<Object> Dotenv::ToObject(Environment* env) const {
EscapableHandleScope scope(env->isolate());
diff --git a/src/node_dotenv.h b/src/node_dotenv.h
index 689c763907c..e0069bb08ce 100644
--- a/src/node_dotenv.h
+++ b/src/node_dotenv.h
@@ -30,6 +30,8 @@ class Dotenv {
void AssignNodeOptionsIfAvailable(std::string* node_options) const;
v8::Maybe<void> SetEnvironment(Environment* env);
v8::MaybeLocal<v8::Object> ToObject(Environment* env) const;
+ // The names of the variables parsed from the env files.
+ std::vector<std::string> GetKeys() const;
static std::vector<env_file_data> GetDataFromArgs(
const std::vector<std::string>& args);
@@ -38,6 +40,11 @@ class Dotenv {
std::map<std::string, std::string> store_;
};
+namespace per_process {
+// The env files passed with --env-file and --env-file-if-exists.
+extern Dotenv dotenv_file;
+} // namespace per_process
+
} // namespace node
#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
diff --git a/src/node_env_var.cc b/src/node_env_var.cc
index 25e405be86d..b55d099cbca 100644
--- a/src/node_env_var.cc
+++ b/src/node_env_var.cc
@@ -4,6 +4,8 @@
#include "node_external_reference.h"
#include "node_i18n.h"
#include "node_process-inl.h"
+#include "permission/env_permission.h"
+#include "permission/permission.h"
#include "util.h"
#include <time.h> // tzset(), _tzset()
@@ -429,6 +431,40 @@ void TraceEnvVar(Environment* env,
}
}
+// Called when process.env does not have `property`. If the permission model
+// removed the variable at startup, publishes the denial and warns once, so
+// that the variable does not just silently read as undefined.
+static Maybe<void> ReportRemovedEnvVar(Environment* env,
+ Local<String> property) {
+ if (!permission::IsProcessEnvironmentScrubbed()) return JustVoid();
+ Utf8Value key(env->isolate(), property);
+ if (!permission::WasRemovedByEnvironmentScrub(key.ToStringView())) {
+ return JustVoid();
+ }
+ env->permission()->PublishDenied(
+ env, permission::PermissionScope::kEnv, key.ToStringView());
+ if (permission::ShouldWarnAboutRemovedEnvVar(key.ToStringView()) &&
+ ProcessEmitWarning(env,
+ "The permission model removed the environment "
+ "variable \"%s\" at startup. Use --allow-env to "
+ "manage permissions.",
+ *key)
+ .IsNothing()) {
+ return Nothing<void>();
+ }
+ return JustVoid();
+}
+
+// In audit mode nothing is removed at startup. Publishes accesses to the
+// variables that enforcing the permission model would have removed instead.
+static void AuditEnvVar(Environment* env, Local<String> property) {
+ Utf8Value key(env->isolate(), property);
+ if (!permission::WasDeniedAtStartup(key.ToStringView())) return;
+ // is_granted() publishes the denial.
+ env->permission()->is_granted(
+ env, permission::PermissionScope::kEnv, key.ToStringView());
+}
+
static Intercepted EnvGetter(Local<Name> property,
const PropertyCallbackInfo<Value>& info) {
Environment* env = Environment::GetCurrent(info);
@@ -445,8 +481,15 @@ static Intercepted EnvGetter(Local<Name> property,
Local<Value> ret;
if (!value_string.ToLocal(&ret)) {
+ if (env->permission()->enabled() &&
+ ReportRemovedEnvVar(env, property.As<String>()).IsNothing()) {
+ return Intercepted::kYes;
+ }
return Intercepted::kNo;
}
+ if (env->permission()->warning_only()) {
+ AuditEnvVar(env, property.As<String>());
+ }
info.GetReturnValue().Set(ret);
return Intercepted::kYes;
}
@@ -496,10 +539,17 @@ static Intercepted EnvQuery(Local<Name> property,
bool has_env = (rc != -1);
TraceEnvVar(env, "query", property.As<String>());
if (has_env) {
+ if (env->permission()->warning_only()) {
+ AuditEnvVar(env, property.As<String>());
+ }
// Return attributes for the property.
info.GetReturnValue().Set(v8::None);
return Intercepted::kYes;
}
+ if (env->permission()->enabled() &&
+ ReportRemovedEnvVar(env, property.As<String>()).IsNothing()) {
+ return Intercepted::kYes;
+ }
}
return Intercepted::kNo;
}
diff --git a/src/node_options.cc b/src/node_options.cc
index 72ac73629d2..917f6a4f47c 100644
--- a/src/node_options.cc
+++ b/src/node_options.cc
@@ -7,6 +7,7 @@
#include "node_external_reference.h"
#include "node_internals.h"
#include "node_sea.h"
+#include "permission/env_permission.h"
#include "uv.h"
#if HAVE_OPENSSL
#include "ncrypto.h" // Defines OPENSSL_VERSION_PREREQ for BoringSSL.
@@ -315,6 +316,14 @@ void EnvironmentOptions::CheckOptions(std::vector<std::string>* errors,
errors->push_back("either --check or --eval can be used, not both");
}
+ for (const std::string& pattern : permission::ParseEnvAllowList(allow_env)) {
+ if (!permission::IsValidEnvAllowPattern(pattern)) {
+ errors->push_back("--allow-env must be '*', a variable name, or a "
+ "variable name prefix followed by '*'");
+ break;
+ }
+ }
+
if (!unhandled_rejections.empty() &&
unhandled_rejections != "warn-with-error-code" &&
unhandled_rejections != "throw" &&
@@ -859,6 +868,12 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
BOOL_FIELD(permission_audit),
kAllowedInEnvvar,
false);
+ AddOption("--allow-env",
+ "allow access to environment variables when any permissions are "
+ "set",
+ &EnvironmentOptions::allow_env,
+ kAllowedInEnvvar,
+ OptionNamespaces::kPermissionNamespace);
AddOption("--allow-fs-read",
"allow permissions to read the filesystem",
&EnvironmentOptions::allow_fs_read,
diff --git a/src/node_options.h b/src/node_options.h
index 96fff71ec85..78de1111ce9 100644
--- a/src/node_options.h
+++ b/src/node_options.h
@@ -161,6 +161,7 @@ class EnvironmentOptions : public Options {
#endif // HAVE_INSPECTOR
std::vector<std::string> conditions;
+ std::vector<std::string> allow_env;
std::vector<std::string> allow_fs_read;
std::vector<std::string> allow_fs_write;
std::vector<std::string> disable_warnings;
diff --git a/src/permission/env_permission.cc b/src/permission/env_permission.cc
new file mode 100644
index 00000000000..3ad98f33da5
--- /dev/null
+++ b/src/permission/env_permission.cc
@@ -0,0 +1,618 @@
+#include "permission/env_permission.h"
+
+#include "env-inl.h"
+#include "node_internals.h"
+#include "node_mutex.h"
+#include "util-inl.h"
+#include "uv.h"
+#include "v8.h"
+
+#include <algorithm>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <utility>
+
+#if defined(__linux__)
+#include <linux/magic.h> // PROC_SUPER_MAGIC
+#include <sys/vfs.h> // statfs()
+
+extern char** environ;
+#endif
+
+namespace node {
+
+using v8::Array;
+using v8::Context;
+using v8::HandleScope;
+using v8::Isolate;
+using v8::Local;
+using v8::LocalVector;
+using v8::String;
+using v8::Value;
+
+namespace permission {
+
+namespace {
+
+constexpr std::string_view kRuntimeEnvironmentDefaults[] = {
+ // Read by Node.js itself after startup.
+ "NODE_BENCH_CONTEXT",
+ "NODE_BENCH_FILE_RUN_ID",
+ "NODE_BENCH_RUN_ID",
+ "NODE_CHANNEL_FD",
+ "NODE_CHANNEL_SERIALIZATION_MODE",
+ "NODE_CLUSTER_SCHED_POLICY",
+ "NODE_COMPILE_CACHE",
+ "NODE_COMPILE_CACHE_PORTABLE",
+ "NODE_COMPILE_CACHE_READONLY",
+ "NODE_DEBUG",
+ "NODE_DEBUG_NATIVE",
+ "NODE_DISABLE_COLORS",
+ "NODE_DISABLE_COMPILE_CACHE",
+ "NODE_EXTRA_CA_CERTS",
+ "NODE_ICU_DATA",
+ "NODE_INSPECT_RESUME_ON_START",
+ "NODE_NO_WARNINGS",
+ "NODE_OPTIONS",
+ "NODE_PATH",
+ "NODE_PENDING_DEPRECATION",
+ "NODE_PENDING_PIPE_INSTANCES",
+ "NODE_PRESERVE_SYMLINKS",
+ "NODE_REDIRECT_WARNINGS",
+ "NODE_REPL_EXTERNAL_MODULE",
+ "NODE_REPL_HISTORY",
+ "NODE_TEST_CONTEXT",
+ "NODE_TEST_WORKER_ID",
+ "NODE_TLS_REJECT_UNAUTHORIZED",
+ "NODE_UNIQUE_ID",
+ "NODE_USE_ENV_PROXY",
+ "NODE_USE_SYSTEM_CA",
+ "NODE_V8_COVERAGE",
+ "WATCH_REPORT_DEPENDENCIES",
+ // Terminal and CI detection, see lib/internal/tty.js.
+ "APPVEYOR",
+ "BUILDKITE",
+ "CI",
+ "CI_NAME",
+ "CIRCLECI",
+ "COLORTERM",
+ "DRONE",
+ "FORCE_COLOR",
+ "GITEA_ACTIONS",
+ "GITHUB_ACTIONS",
+ "GITLAB_CI",
+ "NO_COLOR",
+ "TEAMCITY_VERSION",
+ "TERM",
+ "TERM_PROGRAM",
+ "TERM_PROGRAM_VERSION",
+ "TMUX",
+ "TRAVIS",
+ // libuv.
+ "HOME",
+ "PATH",
+ "TEMP",
+ "TMP",
+ "TMPDIR",
+ "USERPROFILE",
+ "UV_THREADPOOL_SIZE",
+ "UV_USE_IO_URING",
+ // Copied into child process environments by libuv on Windows, and read
+ // by lib/child_process.js.
+ "COMSPEC",
+ "HOMEDRIVE",
+ "HOMEPATH",
+ "LOGONSERVER",
+ "SYSTEMDRIVE",
+ "SYSTEMROOT",
+ "USERDOMAIN",
+ "USERNAME",
+ "WINDIR",
+ // c-ares and the system resolver.
+ "HOSTALIASES",
+ "LOCALDOMAIN",
+ "RES_OPTIONS",
+ // ICU, the time zone and the locale.
+ "ICU_DATA",
+ "LANG",
+ "LANGUAGE",
+ "LC_*",
+ "TZ",
+ // OpenSSL.
+ "OPENSSL_CONF",
+ "OPENSSL_CONF_INCLUDE",
+ "OPENSSL_ENGINES",
+ "OPENSSL_MODULES",
+ "SSL_CERT_DIR",
+ "SSL_CERT_FILE",
+ // The dynamic loader, for addons and FFI.
+ "DYLD_FALLBACK_LIBRARY_PATH",
+ "DYLD_LIBRARY_PATH",
+ "LD_LIBRARY_PATH",
+ "LIBPATH",
+};
+
+struct EnvScrubState {
+ Mutex mutex;
+ // Whether ScrubProcessEnvironment() removed the variables in `denied`.
+ std::atomic<bool> scrubbed{false};
+ // Whether RecordAuditedEnvironmentVariables() recorded them instead.
+ std::atomic<bool> audited{false};
+ std::unordered_set<std::string> denied;
+ std::unordered_set<std::string> warned;
+};
+
+EnvScrubState& GetScrubState() {
+ // Intentionally leaked, so that it can be used during process teardown.
+ static EnvScrubState* state = new EnvScrubState();
+ return *state;
+}
+
+// The key under which a name is stored in the sets above.
+std::string NormalizeEnvName(std::string_view name) {
+#ifdef _WIN32
+ return ToUpper(std::string(name));
+#else
+ return std::string(name);
+#endif
+}
+
+bool MatchesAnyPattern(std::span<const std::string> patterns,
+ std::string_view name) {
+ return std::any_of(
+ patterns.begin(), patterns.end(), [&](const std::string& pattern) {
+ return EnvNameMatchesPattern(pattern, name);
+ });
+}
+
+#ifdef _WIN32
+// libuv modifies the environment block of the process, but the C runtime keeps
+// its own copy of the environment, which getenv() reads. Removes a variable
+// from that copy.
+void UnsetCrtEnvironmentVariable(const std::string& name) {
+ _wputenv_s(ConvertUTF8ToWideString(name).c_str(), L"");
+}
+#endif // _WIN32
+
+// The entries of the environment block the process was started with, as
+// [pointer, length) pairs.
+using InitialEnvEntries = std::vector<std::pair<char*, size_t>>;
+
+#if defined(__linux__)
+// Reads the [start, end) address range of the environment block the process
+// was started with from fields 50 and 51 of /proc/self/stat (Linux 3.5+).
+bool GetInitialEnvironmentBlock(uintptr_t* start, uintptr_t* end) {
+ FILE* fp = fopen("/proc/self/stat", "re");
+ if (fp == nullptr) return false;
+ char buf[4096];
+ size_t length = fread(buf, 1, sizeof(buf) - 1, fp);
+ fclose(fp);
+ buf[length] = '\0';
+
+ // The command name in field 2 may contain spaces and parentheses, so start
+ // after the last ')', where field 3 begins.
+ char* p = strrchr(buf, ')');
+ if (p == nullptr) return false;
+ p++;
+
+ constexpr int kEnvStartField = 50;
+ constexpr int kEnvEndField = 51;
+ for (int field = 3; field <= kEnvEndField; field++) {
+ char* next;
+ uint64_t value = strtoull(p, &next, 10);
+ if (next == p) {
+ // A non-numeric field, such as the state in field 3.
+ while (*p == ' ') p++;
+ while (*p != ' ' && *p != '\0') p++;
+ if (*p == '\0') return false;
+ } else {
+ p = next;
+ }
+ if (field == kEnvStartField) *start = static_cast<uintptr_t>(value);
+ if (field == kEnvEndField) *end = static_cast<uintptr_t>(value);
+ }
+ return *start != 0 && *start < *end;
+}
+
+// Returns the entries for `names` that still point into the initial
+// environment block, which unsetenv() leaves untouched.
+InitialEnvEntries FindInitialEnvironmentEntries(
+ const std::vector<std::string>& names) {
+ InitialEnvEntries entries;
+ uintptr_t start = 0;
+ uintptr_t end = 0;
+ if (!GetInitialEnvironmentBlock(&start, &end)) return entries;
+
+ for (char** e = environ; e != nullptr && *e != nullptr; e++) {
+ char* entry = *e;
+ const uintptr_t address = reinterpret_cast<uintptr_t>(entry);
+ if (address < start || address >= end) continue;
+ const char* equals = strchr(entry, '=');
+ if (equals == nullptr) continue;
+ const std::string_view name(entry, equals - entry);
+ if (std::find(names.begin(), names.end(), name) == names.end()) continue;
+ size_t length = strlen(entry);
+ if (address + length > end) length = end - address;
+ entries.emplace_back(entry, length);
+ }
+ return entries;
+}
+#else
+InitialEnvEntries FindInitialEnvironmentEntries(
+ const std::vector<std::string>&) {
+ return {};
+}
+#endif // defined(__linux__)
+
+// Overwrites the entries returned by FindInitialEnvironmentEntries(). Call
+// this only once the variables no longer appear in `environ`, so that
+// /proc/<pid>/environ stops exposing their values.
+void WipeInitialEnvironmentEntries(const InitialEnvEntries& entries) {
+ for (const auto& [entry, length] : entries) {
+ memset(entry, 0, length);
+ }
+}
+
+} // namespace
+
+std::vector<std::string> ParseEnvAllowList(
+ std::span<const std::string> values) {
+ std::vector<std::string> patterns;
+ for (const std::string& value : values) {
+ size_t begin = 0;
+ while (begin <= value.size()) {
+ size_t end = value.find(',', begin);
+ if (end == std::string::npos) end = value.size();
+ if (end > begin) patterns.emplace_back(value.substr(begin, end - begin));
+ begin = end + 1;
+ }
+ }
+ return patterns;
+}
+
+bool IsValidEnvAllowPattern(std::string_view pattern) {
+ const size_t wildcard = pattern.find('*');
+ return !pattern.empty() && pattern.find('=') == std::string_view::npos &&
+ (wildcard == std::string_view::npos || wildcard == pattern.size() - 1);
+}
+
+bool EnvNameMatchesPattern(std::string_view pattern, std::string_view name) {
+ if (pattern == "*") return true;
+ const bool is_prefix = !pattern.empty() && pattern.back() == '*';
+ if (is_prefix) pattern.remove_suffix(1);
+ if (is_prefix ? name.size() < pattern.size()
+ : name.size() != pattern.size()) {
+ return false;
+ }
+#ifdef _WIN32
+ for (size_t i = 0; i < pattern.size(); i++) {
+ if (ToUpper(pattern[i]) != ToUpper(name[i])) return false;
+ }
+ return true;
+#else
+ return name.compare(0, pattern.size(), pattern) == 0;
+#endif
+}
+
+// Whether every name `specific` matches is also matched by `general`.
+static bool EnvPatternCovers(std::string_view general,
+ std::string_view specific) {
+ if (general == "*") return true;
+ if (specific.empty() || specific == "*") return false;
+ if (specific.back() != '*') return EnvNameMatchesPattern(general, specific);
+ // Two prefixes: `P*` only matches names `G*` matches if P starts with G.
+ if (general.empty() || general.back() != '*') return false;
+ specific.remove_suffix(1);
+ return EnvNameMatchesPattern(general, specific);
+}
+
+std::vector<std::string> IntersectEnvAllowLists(
+ std::span<const std::string> a, std::span<const std::string> b) {
+ std::vector<std::string> result;
+ for (const std::string& x : a) {
+ for (const std::string& y : b) {
+ if (EnvPatternCovers(x, y)) {
+ result.push_back(y);
+ } else if (EnvPatternCovers(y, x)) {
+ result.push_back(x);
+ }
+ }
+ }
+ return result;
+}
+
+std::span<const std::string_view> GetRuntimeEnvironmentDefaults() {
+ return kRuntimeEnvironmentDefaults;
+}
+
+bool IsRuntimeEnvironmentDefault(std::string_view name) {
+ return std::any_of(std::begin(kRuntimeEnvironmentDefaults),
+ std::end(kRuntimeEnvironmentDefaults),
+ [&](std::string_view pattern) {
+ return EnvNameMatchesPattern(pattern, name);
+ });
+}
+
+// Returns the names of the variables in the process environment that neither
+// `allow` nor, if `keep_runtime_defaults` is set, the runtime defaults match.
+// The caller must hold per_process::env_var_mutex.
+static std::vector<std::string> FindUnmatchedEnvironmentVariables(
+ std::span<const std::string> allow, bool keep_runtime_defaults) {
+ uv_env_item_t* items = nullptr;
+ int count = 0;
+ // Failing to enumerate the environment must not leave it unscrubbed.
+ CHECK_EQ(uv_os_environ(&items, &count), 0);
+ auto cleanup = OnScopeLeave([&]() { uv_os_free_environ(items, count); });
+
+ std::vector<std::string> names;
+ for (int i = 0; i < count; i++) {
+ const std::string_view name(items[i].name);
+ if (name.empty()) continue;
+#ifdef _WIN32
+ // Hidden variables, such as the per-drive working directories ("=C:").
+ if (name[0] == '=') continue;
+#endif
+ if (keep_runtime_defaults && IsRuntimeEnvironmentDefault(name)) continue;
+ if (MatchesAnyPattern(allow, name)) continue;
+ names.emplace_back(name);
+ }
+ return names;
+}
+
+std::vector<std::string> FindDeniedEnvironmentVariables(
+ std::span<const std::string> allow) {
+ Mutex::ScopedLock env_lock(per_process::env_var_mutex);
+ return FindUnmatchedEnvironmentVariables(allow, true);
+}
+
+std::vector<std::string> ScrubProcessEnvironment(
+ const EnvScrubOptions& options) {
+ EnvScrubState& state = GetScrubState();
+ Mutex::ScopedLock state_lock(state.mutex);
+ Mutex::ScopedLock env_lock(per_process::env_var_mutex);
+
+ std::vector<std::string> removed = FindUnmatchedEnvironmentVariables(
+ options.allow, options.keep_runtime_defaults);
+
+ // Locate the entries before unsetting the variables, which removes them
+ // from `environ` and so puts them out of reach.
+ InitialEnvEntries initial_entries;
+ if (options.wipe_initial_block) {
+ initial_entries = FindInitialEnvironmentEntries(removed);
+ }
+
+ for (const std::string& name : removed) {
+ uv_os_unsetenv(name.c_str());
+#ifdef _WIN32
+ UnsetCrtEnvironmentVariable(name);
+#endif
+ state.denied.insert(NormalizeEnvName(name));
+ }
+
+ // Now that environ no longer refers to them, overwrite the removed entries
+ // so that /proc/<pid>/environ does not expose them either.
+ WipeInitialEnvironmentEntries(initial_entries);
+
+ state.scrubbed.store(true);
+ return removed;
+}
+
+void RecordAuditedEnvironmentVariables(std::span<const std::string> allow) {
+ EnvScrubState& state = GetScrubState();
+ Mutex::ScopedLock state_lock(state.mutex);
+ Mutex::ScopedLock env_lock(per_process::env_var_mutex);
+ for (const std::string& name :
+ FindUnmatchedEnvironmentVariables(allow, true)) {
+ state.denied.insert(NormalizeEnvName(name));
+ }
+ state.audited.store(true);
+}
+
+bool IsProcessEnvironmentScrubbed() {
+ return GetScrubState().scrubbed.load();
+}
+
+bool WasRemovedByEnvironmentScrub(std::string_view name) {
+ EnvScrubState& state = GetScrubState();
+ if (!state.scrubbed.load()) return false;
+ Mutex::ScopedLock lock(state.mutex);
+ return state.denied.contains(NormalizeEnvName(name));
+}
+
+bool WasDeniedAtStartup(std::string_view name) {
+ EnvScrubState& state = GetScrubState();
+ if (!state.scrubbed.load() && !state.audited.load()) return false;
+ Mutex::ScopedLock lock(state.mutex);
+ return state.denied.contains(NormalizeEnvName(name));
+}
+
+bool ShouldWarnAboutRemovedEnvVar(std::string_view name) {
+ EnvScrubState& state = GetScrubState();
+ Mutex::ScopedLock lock(state.mutex);
+ return state.warned.insert(NormalizeEnvName(name)).second;
+}
+
+void EnvPermission::Apply(Environment* env,
+ std::span<const std::string> allow,
+ PermissionScope scope) {
+ RwLock::ScopedWriteLock lock(lock_);
+ patterns_.assign(allow.begin(), allow.end());
+ if (std::find(patterns_.begin(), patterns_.end(), "*") != patterns_.end()) {
+ granted_all_.store(true, std::memory_order_release);
+ }
+}
+
+void EnvPermission::Drop(Environment* env,
+ PermissionScope scope,
+ std::string_view param) {
+ {
+ RwLock::ScopedWriteLock lock(lock_);
+ granted_all_.store(false, std::memory_order_release);
+ if (param.empty()) {
+ dropped_all_ = true;
+ patterns_.clear();
+ dropped_.clear();
+ } else {
+ dropped_.insert(NormalizeEnvName(param));
+ }
+ }
+ // Not under lock_: this calls into the KVStore, which takes
+ // per_process::env_var_mutex, and into V8.
+ RemoveFromEnvironment(env, param.empty(), param);
+}
+
+bool EnvPermission::is_granted(Environment* env,
+ PermissionScope perm,
+ std::string_view param) const {
+ if (param.empty()) return granted_all();
+ RwLock::ScopedReadLock lock(lock_);
+ if (!dropped_.empty() && dropped_.contains(NormalizeEnvName(param))) {
+ return false;
+ }
+ if (granted_all()) return true;
+ return IsRuntimeEnvironmentDefault(param) ||
+ (!dropped_all_ && MatchesAnyPattern(patterns_, param));
+}
+
+#if defined(__linux__)
+namespace {
+
+bool IsAllDigits(std::string_view s) {
+ return !s.empty() && std::all_of(s.begin(), s.end(), [](char c) {
+ return c >= '0' && c <= '9';
+ });
+}
+
+// Whether `rest`, the part of a path after "<procfs>/<pid>/", names the
+// environ file of that process or of one of its threads.
+bool IsEnvironEntry(std::string_view rest) {
+ if (rest == "environ") return true;
+ constexpr std::string_view kTask = "task/";
+ if (!rest.starts_with(kTask)) return false;
+ rest.remove_prefix(kTask.size());
+ const size_t slash = rest.find('/');
+ return slash != std::string_view::npos &&
+ IsAllDigits(rest.substr(0, slash)) &&
+ rest.substr(slash + 1) == "environ";
+}
+
+// Whether the absolute `path` names this process's own environ file, for
+// procfs mounted at /proc. `lexical` also accepts the self and thread-self
+// links, for paths that were not canonicalized.
+bool IsOwnProcEnviron(std::string_view path, bool lexical) {
+ const std::string pid_dir = "/proc/" + std::to_string(uv_os_getpid()) + "/";
+ if (path.starts_with(pid_dir)) {
+ return IsEnvironEntry(path.substr(pid_dir.size()));
+ }
+ if (lexical) {
+ constexpr std::string_view kSelf = "/proc/self/";
+ if (path.starts_with(kSelf)) {
+ return IsEnvironEntry(path.substr(kSelf.size()));
+ }
+ if (path == "/proc/thread-self/environ") return true;
+ }
+ return false;
+}
+
+} // namespace
+
+bool IsProcEnvironReadDenied(std::string_view path, bool allow_own) {
+ const std::string raw(path);
+
+ // Only a file on procfs can be an environ file, however the path reaches
+ // it. statfs() follows symbolic links, and is a single system call, so
+ // canonicalizing the path is only paid for files that are on procfs.
+ struct statfs fs_info;
+ if (statfs(raw.c_str(), &fs_info) == 0) {
+ if (fs_info.f_type != PROC_SUPER_MAGIC) return false;
+ char* real = realpath(raw.c_str(), nullptr);
+ // Fail closed: a file on procfs whose path cannot be canonicalized is
+ // treated as an environ file.
+ if (real == nullptr) return true;
+ const std::string canonical(real);
+ free(real);
+ // procfs has no other files named environ. It may be mounted somewhere
+ // other than /proc, in which case the file is never recognized as this
+ // process's own, and so is always denied.
+ if (canonical != "environ" && !canonical.ends_with("/environ")) {
+ return false;
+ }
+ return !(allow_own && IsOwnProcEnviron(canonical, false));
+ }
+
+ // The file could not be examined, for example because it does not exist.
+ // Fall back to the path as given, which can still name a file that comes
+ // into existence before it is opened.
+ if (!raw.starts_with("/proc/") || !raw.ends_with("/environ")) return false;
+ return !(allow_own && IsOwnProcEnviron(raw, true));
+}
+#endif // defined(__linux__)
+
+void EnvPermission::RemoveFromEnvironment(Environment* env,
+ bool drop_all,
+ std::string_view name) {
+ if (env == nullptr) return;
+ // An Environment that does not own the process state shares the real
+ // process environment with the embedder, and must not modify it.
+ const bool is_process_environment =
+ env->env_vars() == per_process::system_environment;
+ if (is_process_environment && !env->owns_process_state()) return;
+
+ Isolate* isolate = env->isolate();
+ HandleScope handle_scope(isolate);
+ Local<Context> context = env->context();
+
+ // Collected first, so that the entries the process started with can be
+ // located while `environ` still refers to them.
+ LocalVector<String> keys_to_remove(isolate);
+ std::vector<std::string> removed;
+
+ if (!drop_all) {
+ Local<Value> key;
+ if (!ToV8Value(context, name, isolate).ToLocal(&key) || !key->IsString()) {
+ return;
+ }
+ keys_to_remove.emplace_back(key.As<String>());
+ removed.emplace_back(name);
+ } else {
+ Local<Array> keys;
+ if (!env->env_vars()->Enumerate(isolate).ToLocal(&keys)) return;
+ const uint32_t length = keys->Length();
+ for (uint32_t i = 0; i < length; i++) {
+ Local<Value> key;
+ if (!keys->Get(context, i).ToLocal(&key) || !key->IsString()) continue;
+ Utf8Value key_utf8(isolate, key);
+ if (IsRuntimeEnvironmentDefault(key_utf8.ToStringView())) continue;
+ keys_to_remove.emplace_back(key.As<String>());
+ removed.emplace_back(key_utf8.ToString());
+ }
+ }
+
+ // Only the real process environment has an initial block behind it. The
+ // KVStore methods below take per_process::env_var_mutex themselves, so it
+ // must not be held across them.
+ InitialEnvEntries initial_entries;
+ if (is_process_environment) {
+ Mutex::ScopedLock env_lock(per_process::env_var_mutex);
+ initial_entries = FindInitialEnvironmentEntries(removed);
+ }
+
+ for (Local<String> key : keys_to_remove) {
+ env->env_vars()->Delete(isolate, key);
+#ifdef _WIN32
+ if (is_process_environment) {
+ UnsetCrtEnvironmentVariable(Utf8Value(isolate, key).ToString());
+ }
+#endif
+ }
+
+ // As in ScrubProcessEnvironment(), overwrite the values that unsetenv()
+ // leaves behind in the initial environment block, which /proc/<pid>/environ
+ // would otherwise still expose.
+ WipeInitialEnvironmentEntries(initial_entries);
+}
+
+} // namespace permission
+
+} // namespace node
diff --git a/src/permission/env_permission.h b/src/permission/env_permission.h
new file mode 100644
index 00000000000..8ef425d769b
--- /dev/null
+++ b/src/permission/env_permission.h
@@ -0,0 +1,138 @@
+#ifndef SRC_PERMISSION_ENV_PERMISSION_H_
+#define SRC_PERMISSION_ENV_PERMISSION_H_
+
+#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
+
+#include "node_mutex.h"
+#include "permission/permission_base.h"
+
+#include <atomic>
+#include <span>
+#include <string>
+#include <string_view>
+#include <unordered_set>
+#include <vector>
+
+namespace node {
+
+class Environment;
+
+namespace permission {
+
+// Splits the values of --allow-env (which may be given more than once, and
+// may each hold a comma-separated list) into individual patterns.
+std::vector<std::string> ParseEnvAllowList(std::span<const std::string> values);
+
+// Whether `pattern` is `*`, a variable name, or a variable name prefix
+// followed by `*`.
+bool IsValidEnvAllowPattern(std::string_view pattern);
+
+// Whether `name` matches `pattern`: `*`, an exact name, or a prefix followed
+// by `*`. Names are compared case-insensitively on Windows, where environment
+// variable names are case-insensitive.
+bool EnvNameMatchesPattern(std::string_view pattern, std::string_view name);
+
+// Returns the patterns matching exactly the names that both `a` and `b` match.
+std::vector<std::string> IntersectEnvAllowLists(std::span<const std::string> a,
+ std::span<const std::string> b);
+
+// Environment variables that Node.js and its bundled dependencies read after
+// startup. The scrub keeps them, and dropping the whole env scope keeps them.
+std::span<const std::string_view> GetRuntimeEnvironmentDefaults();
+bool IsRuntimeEnvironmentDefault(std::string_view name);
+
+struct EnvScrubOptions {
+ // Names or patterns to keep, see EnvNameMatchesPattern().
+ std::span<const std::string> allow;
+ // Whether to keep GetRuntimeEnvironmentDefaults().
+ bool keep_runtime_defaults = true;
+ // Whether to overwrite the removed entries in the environment block the
+ // process started with, which /proc/<pid>/environ still exposes after
+ // unsetenv(). Best effort, and currently only implemented on Linux.
+ bool wipe_initial_block = true;
+};
+
+// Removes every environment variable that `options` does not keep from the
+// process environment, and returns the names it removed.
+//
+// This modifies the process environment without any locking that native
+// code calling getenv() participates in, so it must be called before any
+// such thread is started.
+std::vector<std::string> ScrubProcessEnvironment(
+ const EnvScrubOptions& options);
+
+// Returns the names of the variables in the process environment that neither
+// `allow` nor GetRuntimeEnvironmentDefaults() match.
+std::vector<std::string> FindDeniedEnvironmentVariables(
+ std::span<const std::string> allow);
+
+// In audit mode, nothing is removed. Records the names of the variables that
+// ScrubProcessEnvironment() would remove instead.
+void RecordAuditedEnvironmentVariables(std::span<const std::string> allow);
+
+// Whether ScrubProcessEnvironment() has run in this process.
+bool IsProcessEnvironmentScrubbed();
+
+// Whether `name` was removed by ScrubProcessEnvironment().
+bool WasRemovedByEnvironmentScrub(std::string_view name);
+
+// Whether `name` was removed by ScrubProcessEnvironment(), or recorded by
+// RecordAuditedEnvironmentVariables().
+bool WasDeniedAtStartup(std::string_view name);
+
+// Returns true only the first time it is called for a given `name`.
+bool ShouldWarnAboutRemovedEnvVar(std::string_view name);
+
+class EnvPermission final : public PermissionBase {
+ public:
+ void Apply(Environment* env,
+ std::span<const std::string> allow,
+ PermissionScope scope) override;
+ void Drop(Environment* env,
+ PermissionScope scope,
+ std::string_view param) override;
+ bool is_granted(Environment* env,
+ PermissionScope perm,
+ std::string_view param) const override;
+
+ // Whether every environment variable is accessible. Lock-free, so that the
+ // file system scope can consult it on every check, from any thread. A check
+ // that races with Drop() on another thread may observe the value from
+ // before the drop.
+ bool granted_all() const {
+ return granted_all_.load(std::memory_order_acquire);
+ }
+
+ private:
+ // Removes the variables `drop_all` or `name` refer to from the
+ // environment `env` exposes as process.env.
+ void RemoveFromEnvironment(Environment* env,
+ bool drop_all,
+ std::string_view name);
+
+ std::atomic<bool> granted_all_{false};
+ // Guards the members below. is_granted() may be called from threads other
+ // than the one that owns the Environment, such as the thread pool.
+ mutable RwLock lock_;
+ bool dropped_all_ = false;
+ std::vector<std::string> patterns_;
+ std::unordered_set<std::string> dropped_;
+};
+
+#if defined(__linux__)
+// Whether reading the file at `path` must be denied because it is, or
+// resolves to, the /proc/<pid>/environ file of a process, which exposes the
+// environment that process started with. Every process's file is denied
+// except this process's own, which is allowed only when `allow_own` is set.
+// Symbolic links are resolved first, so that paths such as
+// /dev/fd/../environ are recognized. `path` may be relative to the current
+// working directory.
+bool IsProcEnvironReadDenied(std::string_view path, bool allow_own);
+#endif // defined(__linux__)
+
+} // namespace permission
+
+} // namespace node
+
+#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
+#endif // SRC_PERMISSION_ENV_PERMISSION_H_
diff --git a/src/permission/permission.cc b/src/permission/permission.cc
index 5a873e9db5b..d5e93c729be 100644
--- a/src/permission/permission.cc
+++ b/src/permission/permission.cc
@@ -8,6 +8,7 @@
#include "node_file.h"
#include "permission/boolean_permission.h"
+#include "permission/env_permission.h"
#include "permission/fs_permission.h"
#include "permission/permission_base.h"
#include "v8-fast-api-calls.h"
@@ -58,6 +59,8 @@ constexpr std::string_view GetDiagnosticsChannelName(PermissionScope scope) {
return "node:permission-model:ffi";
case PermissionScope::kOpenSSLStore:
return "node:permission-model:openssl-store";
+ case PermissionScope::kEnv:
+ return "node:permission-model:env";
default:
return {};
}
@@ -77,6 +80,18 @@ Local<DictionaryTemplate> GetPermissionDiagnosticsTemplate(Environment* env) {
return tmpl;
}
+// Returns true if the permission model removed the environment variable
+// named by args[0] at startup and no warning about it has been emitted yet,
+// and records that one has been. For callers that emit a more specific
+// warning than the one reading the variable from process.env would.
+static void TakeRemovedEnvVarWarning(const FunctionCallbackInfo<Value>& args) {
+ Environment* env = Environment::GetCurrent(args);
+ CHECK(args[0]->IsString());
+ Utf8Value name(env->isolate(), args[0]);
+ args.GetReturnValue().Set(WasRemovedByEnvironmentScrub(name.ToStringView()) &&
+ ShouldWarnAboutRemovedEnvVar(name.ToStringView()));
+}
+
// permission.drop('fs.read', '/tmp/')
// permission.drop('child')
static void Drop(const FunctionCallbackInfo<Value>& args) {
@@ -89,6 +104,18 @@ static void Drop(const FunctionCallbackInfo<Value>& args) {
return;
}
+ // Dropping environment variables removes them from the environment. An
+ // Environment that does not own the process state shares the real process
+ // environment with the embedder, and must not modify it.
+ if (scope == PermissionScope::kEnv &&
+ env->env_vars() == per_process::system_environment &&
+ !env->owns_process_state()) {
+ return THROW_ERR_INVALID_STATE(
+ env,
+ "Environment variables can only be dropped from an Environment that "
+ "owns the process state");
+ }
+
if (args.Length() > 1 && !args[1]->IsUndefined()) {
// BufferValue copies raw bytes out of a Buffer/TypedArray as-is instead
// of forcing a (potentially lossy) UTF-8 string conversion, since paths
@@ -241,6 +268,11 @@ Permission::Permission() : enabled_(false), warning_only_(false) {
nodes_[static_cast<size_t>(PermissionScope::k##Name)] = \
std::make_shared<AllowRevokePermission>();
NET_PERMISSIONS(V)
+#undef V
+ env_permission_ = std::make_shared<EnvPermission>();
+#define V(Name, _, __, ___) \
+ nodes_[static_cast<size_t>(PermissionScope::k##Name)] = env_permission_;
+ ENV_PERMISSIONS(V)
#undef V
}
@@ -320,7 +352,21 @@ bool Permission::is_granted_quiet(Environment* env,
CHECK(permission != PermissionScope::kPermissionsRoot &&
permission != PermissionScope::kPermissionsCount);
auto& perm_node = nodes_[static_cast<size_t>(permission)];
- return perm_node && perm_node->is_granted(env, permission, res);
+ if (!perm_node || !perm_node->is_granted(env, permission, res)) {
+ return false;
+ }
+#if defined(__linux__)
+ // /proc/<pid>/environ exposes the environment a process was started with,
+ // including variables that the env scope does not grant access to. Other
+ // processes' files are always denied: a child process is granted every
+ // variable in the environment its parent hands it, and must not reach the
+ // environments the parent could not.
+ if (permission == PermissionScope::kFileSystemRead && !res.empty() &&
+ IsProcEnvironReadDenied(res, env_permission_->granted_all())) {
+ return false;
+ }
+#endif // defined(__linux__)
+ return true;
}
bool Permission::is_scope_granted(Environment* env,
@@ -406,6 +452,8 @@ void Initialize(Local<Object> target,
SetFastMethodNoSideEffect(
context, target, "has", Has, {fast_has_methods_, 2});
SetMethod(context, target, "drop", Drop);
+ SetMethod(
+ context, target, "takeRemovedEnvVarWarning", TakeRemovedEnvVarWarning);
target->SetIntegrityLevel(context, IntegrityLevel::kFrozen).FromJust();
}
@@ -416,6 +464,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(method);
}
registry->Register(Drop);
+ registry->Register(TakeRemovedEnvVarWarning);
}
} // namespace permission
diff --git a/src/permission/permission.h b/src/permission/permission.h
index 7422bb6a237..6debc4102b1 100644
--- a/src/permission/permission.h
+++ b/src/permission/permission.h
@@ -5,6 +5,7 @@
#include "debug_utils.h"
#include "node_diagnostics_channel.h"
+#include "permission/env_permission.h"
#include "permission/permission_base.h"
#include <array>
@@ -150,6 +151,8 @@ class Permission {
static_cast<size_t>(PermissionScope::kPermissionsCount);
std::array<std::shared_ptr<PermissionBase>, kPermissionCount> nodes_;
+ // Also stored in nodes_, kept here for the file system scope to consult.
+ std::shared_ptr<EnvPermission> env_permission_;
bool enabled_;
bool warning_only_;
mutable bool publishing_ = false;
diff --git a/src/permission/permission_base.h b/src/permission/permission_base.h
index 658175611ec..fb7b2a85d43 100644
--- a/src/permission/permission_base.h
+++ b/src/permission/permission_base.h
@@ -39,6 +39,8 @@ namespace permission {
#define OPENSSL_STORE_PERMISSIONS(V) \
V(OpenSSLStore, "openssl.store", PermissionsRoot, "--allow-openssl-store")
+#define ENV_PERMISSIONS(V) V(Env, "env", PermissionsRoot, "--allow-env")
+
#define PERMISSIONS(V) \
FILESYSTEM_PERMISSIONS(V) \
CHILD_PROCESS_PERMISSIONS(V) \
@@ -48,7 +50,8 @@ namespace permission {
NET_PERMISSIONS(V) \
ADDON_PERMISSIONS(V) \
FFI_PERMISSIONS(V) \
- OPENSSL_STORE_PERMISSIONS(V)
+ OPENSSL_STORE_PERMISSIONS(V) \
+ ENV_PERMISSIONS(V)
#define V(name, _, __, ___) k##name,
enum class PermissionScope {
diff --git a/test/cctest/test_env_permission.cc b/test/cctest/test_env_permission.cc
new file mode 100644
index 00000000000..8d1876b2678
--- /dev/null
+++ b/test/cctest/test_env_permission.cc
@@ -0,0 +1,83 @@
+#include "gtest/gtest.h"
+#include "node.h"
+#include "node_test_fixture.h"
+#include "permission/env_permission.h"
+
+#include <string>
+#include <vector>
+
+using node::permission::EnvNameMatchesPattern;
+using node::permission::GetRuntimeEnvironmentDefaults;
+using node::permission::IntersectEnvAllowLists;
+using node::permission::IsRuntimeEnvironmentDefault;
+using node::permission::IsValidEnvAllowPattern;
+using node::permission::ParseEnvAllowList;
+
+TEST(EnvPermissionTest, ParseEnvAllowList) {
+ const std::vector<std::string> values = {"A,B", "C", "", ",D,,E,"};
+ EXPECT_EQ(ParseEnvAllowList(values),
+ (std::vector<std::string>{"A", "B", "C", "D", "E"}));
+}
+
+TEST(EnvPermissionTest, IsValidEnvAllowPattern) {
+ EXPECT_TRUE(IsValidEnvAllowPattern("*"));
+ EXPECT_TRUE(IsValidEnvAllowPattern("PATH"));
+ EXPECT_TRUE(IsValidEnvAllowPattern("APP_*"));
+ EXPECT_FALSE(IsValidEnvAllowPattern(""));
+ EXPECT_FALSE(IsValidEnvAllowPattern("A=B"));
+ EXPECT_FALSE(IsValidEnvAllowPattern("*A"));
+ EXPECT_FALSE(IsValidEnvAllowPattern("A*B"));
+ EXPECT_FALSE(IsValidEnvAllowPattern("A**"));
+}
+
+TEST(EnvPermissionTest, EnvNameMatchesPattern) {
+ EXPECT_TRUE(EnvNameMatchesPattern("*", "ANYTHING"));
+ EXPECT_TRUE(EnvNameMatchesPattern("PATH", "PATH"));
+ EXPECT_FALSE(EnvNameMatchesPattern("PATH", "PATHEXT"));
+ EXPECT_FALSE(EnvNameMatchesPattern("PATHEXT", "PATH"));
+ EXPECT_TRUE(EnvNameMatchesPattern("APP_*", "APP_"));
+ EXPECT_TRUE(EnvNameMatchesPattern("APP_*", "APP_DB_URL"));
+ EXPECT_FALSE(EnvNameMatchesPattern("APP_*", "APP"));
+ EXPECT_FALSE(EnvNameMatchesPattern("APP_*", "OTHER_APP_DB"));
+#ifdef _WIN32
+ EXPECT_TRUE(EnvNameMatchesPattern("path", "PATH"));
+ EXPECT_TRUE(EnvNameMatchesPattern("app_*", "APP_DB_URL"));
+#else
+ EXPECT_FALSE(EnvNameMatchesPattern("path", "PATH"));
+ EXPECT_FALSE(EnvNameMatchesPattern("app_*", "APP_DB_URL"));
+#endif
+}
+
+TEST(EnvPermissionTest, IntersectEnvAllowLists) {
+ using List = std::vector<std::string>;
+ EXPECT_EQ(IntersectEnvAllowLists(List{"*"}, List{"A", "B_*"}),
+ (List{"A", "B_*"}));
+ EXPECT_EQ(IntersectEnvAllowLists(List{"A", "B"}, List{"*"}),
+ (List{"A", "B"}));
+ EXPECT_EQ(IntersectEnvAllowLists(List{"A", "B"}, List{"B", "C"}),
+ (List{"B"}));
+ EXPECT_EQ(IntersectEnvAllowLists(List{"APP_*"}, List{"APP_DB*", "OTHER"}),
+ (List{"APP_DB*"}));
+ EXPECT_EQ(IntersectEnvAllowLists(List{"APP_DB_URL"}, List{"APP_*"}),
+ (List{"APP_DB_URL"}));
+ EXPECT_EQ(IntersectEnvAllowLists(List{"APP_*"}, List{"OTHER_*"}), List{});
+ EXPECT_EQ(IntersectEnvAllowLists(List{}, List{"*"}), List{});
+}
+
+TEST(EnvPermissionTest, RuntimeEnvironmentDefaults) {
+ EXPECT_TRUE(IsRuntimeEnvironmentDefault("NODE_OPTIONS"));
+ EXPECT_TRUE(IsRuntimeEnvironmentDefault("TZ"));
+ EXPECT_TRUE(IsRuntimeEnvironmentDefault("LC_ALL"));
+ EXPECT_FALSE(IsRuntimeEnvironmentDefault("NODE_AUTH_TOKEN"));
+ EXPECT_FALSE(IsRuntimeEnvironmentDefault("HTTP_PROXY"));
+ EXPECT_EQ(node::GetRuntimeEnvironmentDefaults().size(),
+ GetRuntimeEnvironmentDefaults().size());
+}
+
+class EnvPermissionScrubTest : public NodeZeroIsolateTestFixture {};
+
+TEST_F(EnvPermissionScrubTest, ScrubProcessEnvironmentAfterInitialization) {
+ // The fixture has already initialized Node.js, so modifying the process
+ // environment is no longer safe.
+ EXPECT_TRUE(node::ScrubProcessEnvironment({}).IsNothing());
+}
diff --git a/test/embedding/embedtest.cc b/test/embedding/embedtest.cc
index 8f94e6e910c..760406f0918 100644
--- a/test/embedding/embedtest.cc
+++ b/test/embedding/embedtest.cc
@@ -100,6 +100,28 @@ NODE_MAIN(int argc, node::argv_type raw_argv[]) {
args.erase(it);
flags |= node::ProcessInitializationFlags::kNoHarvestBuiltinCodeCache;
}
+ // --embedder-scrub-env=<names>: remove the environment variables other than
+ // the comma-separated <names> before initializing Node.js.
+ static constexpr std::string_view kScrubEnvFlag = "--embedder-scrub-env=";
+ it = std::find_if(args.begin(), args.end(), [](const std::string& arg) {
+ return arg.starts_with(kScrubEnvFlag);
+ });
+ if (it != args.end()) {
+ node::ProcessEnvironmentScrubOptions scrub_options;
+ const std::string names = it->substr(kScrubEnvFlag.size());
+ for (size_t begin = 0; begin < names.size();) {
+ size_t end = names.find(',', begin);
+ if (end == std::string::npos) end = names.size();
+ scrub_options.allow.push_back(names.substr(begin, end - begin));
+ begin = end + 1;
+ }
+ args.erase(it);
+ if (node::ScrubProcessEnvironment(scrub_options).IsNothing()) {
+ fprintf(
+ stderr, "%s: ScrubProcessEnvironment() failed\n", args[0].c_str());
+ return 1;
+ }
+ }
std::shared_ptr<node::InitializationResult> result =
node::InitializeOncePerProcess(
args, static_cast<node::ProcessInitializationFlags::Flags>(flags));
diff --git a/test/embedding/test-embedding-permission-env.js b/test/embedding/test-embedding-permission-env.js
new file mode 100644
index 00000000000..aeb469833a9
--- /dev/null
+++ b/test/embedding/test-embedding-permission-env.js
@@ -0,0 +1,84 @@
+'use strict';
+
+// Tests node::ScrubProcessEnvironment(), and that embedders that enable the
+// permission model must remove the environment variables --allow-env does not
+// grant access to before node::InitializeOncePerProcess().
+
+const common = require('../common');
+const { spawnSyncAndAssert, spawnSyncAndExit } = require('../common/child_process');
+
+const embedtest = common.resolveBuiltBinary('embedtest');
+
+const env = {
+ ...process.env,
+ PERMISSION_ENV_SECRET: 'secret',
+ PERMISSION_ENV_ALLOWED: 'allowed',
+};
+
+const script = 'console.log(JSON.stringify([' +
+ 'process.env.PERMISSION_ENV_SECRET, process.env.PERMISSION_ENV_ALLOWED]))';
+
+const permissionFlags = [
+ '--permission',
+ '--allow-fs-read=*',
+ '--allow-env=PERMISSION_ENV_ALLOWED',
+];
+
+// Initialization fails while the environment contains denied variables.
+// The message lists at most five of them, so the child gets a minimal
+// environment. It still needs the dynamic loader's search path, e.g. when
+// built against a shared OpenSSL that lives outside the default search path.
+// The child's environment is not fully under the test's control either: macOS
+// adds __CF_USER_TEXT_ENCODING to it, so the message may list more than the
+// variables set here.
+const minimalEnv = {
+ PERMISSION_ENV_SECRET: 'secret',
+ PERMISSION_ENV_ALLOWED: 'allowed',
+};
+let loaderPathVar = 'LD_LIBRARY_PATH';
+if (common.isWindows) loaderPathVar = 'PATH';
+else if (common.isMacOS) loaderPathVar = 'DYLD_LIBRARY_PATH';
+else if (common.isAIX) loaderPathVar = 'LIBPATH';
+if (process.env[loaderPathVar] !== undefined) {
+ minimalEnv[loaderPathVar] = process.env[loaderPathVar];
+}
+spawnSyncAndExit(
+ embedtest,
+ [...permissionFlags, script],
+ { env: minimalEnv },
+ {
+ status: 9,
+ signal: null,
+ stderr: /The process environment contains variables that --allow-env does not grant access to \([^)]*\bPERMISSION_ENV_SECRET\b[^)]*\)\. Remove them with node::ScrubProcessEnvironment\(\) before calling node::InitializeOncePerProcess\(\)\./,
+ });
+
+// It succeeds once they have been removed.
+spawnSyncAndAssert(
+ embedtest,
+ ['--embedder-scrub-env=PERMISSION_ENV_ALLOWED', ...permissionFlags, script],
+ { env },
+ {
+ trim: true,
+ stdout: '[null,"allowed"]',
+ });
+
+// With --allow-env=*, nothing needs to be removed.
+spawnSyncAndAssert(
+ embedtest,
+ ['--permission', '--allow-fs-read=*', '--allow-env=*', script],
+ { env },
+ {
+ trim: true,
+ stdout: '["secret","allowed"]',
+ });
+
+// ScrubProcessEnvironment() rejects invalid patterns.
+spawnSyncAndExit(
+ embedtest,
+ ['--embedder-scrub-env=PERMISSION_*_ENV', script],
+ { env },
+ {
+ status: 1,
+ signal: null,
+ stderr: /ScrubProcessEnvironment\(\) failed/,
+ });
diff --git a/test/parallel/test-crypto-key-store-pkcs11.js b/test/parallel/test-crypto-key-store-pkcs11.js
index 0fec81a9c64..fe3158b0cd5 100644
--- a/test/parallel/test-crypto-key-store-pkcs11.js
+++ b/test/parallel/test-crypto-key-store-pkcs11.js
@@ -413,9 +413,12 @@ function assertPermissionModel() {
});
`;
+ // SoftHSM locates its configuration through SOFTHSM2_CONF, which the
+ // permission model would otherwise scrub from the environment at startup.
assertChild([
`--openssl-config=${kOpenSSLConfig}`,
'--permission',
+ '--allow-env=SOFTHSM2_CONF',
'--allow-fs-read=*',
'-e',
code,
@@ -424,6 +427,7 @@ function assertPermissionModel() {
assertChild([
`--openssl-config=${kOpenSSLConfig}`,
'--permission',
+ '--allow-env=SOFTHSM2_CONF',
'--allow-openssl-store',
'--allow-fs-read=*',
'-e',
diff --git a/test/parallel/test-fs-readdir-recursive-permission.js b/test/parallel/test-fs-readdir-recursive-permission.js
index b44454a213c..e877a4311e1 100644
--- a/test/parallel/test-fs-readdir-recursive-permission.js
+++ b/test/parallel/test-fs-readdir-recursive-permission.js
@@ -28,6 +28,7 @@ assert(expected.includes(path.join('a', 'b', '2')));
const { status, stderr } = spawnSync(process.execPath, [
'--permission',
+ '--allow-env=ALLOWED,BLOCKED,EXPECTED',
`--allow-fs-read=${allowed}`,
'-e',
`
diff --git a/test/parallel/test-permission-audit-fs-lstat-symlink-does-not-deny.js b/test/parallel/test-permission-audit-fs-lstat-symlink-does-not-deny.js
index f2d98a0b6f8..602a661a32a 100644
--- a/test/parallel/test-permission-audit-fs-lstat-symlink-does-not-deny.js
+++ b/test/parallel/test-permission-audit-fs-lstat-symlink-does-not-deny.js
@@ -48,7 +48,7 @@ function run(flag, { op, name }) {
};
const { status, stdout, stderr } = spawnSync(
process.execPath,
- [flag, '-e', childScript],
+ [flag, '--allow-env=BLOCKED_FILE,LINK_PATH', '-e', childScript],
{ encoding: 'utf8', env },
);
assert.strictEqual(status, 0, stderr);
diff --git a/test/parallel/test-permission-env-child-process.js b/test/parallel/test-permission-env-child-process.js
new file mode 100644
index 00000000000..fae79e2415d
--- /dev/null
+++ b/test/parallel/test-permission-env-child-process.js
@@ -0,0 +1,77 @@
+'use strict';
+
+const common = require('../common');
+const { isMainThread } = require('worker_threads');
+
+if (!isMainThread) {
+ common.skip('This test only works on a main thread');
+}
+
+if (process.config.variables.node_without_node_options) {
+ common.skip('missing NODE_OPTIONS support');
+}
+
+const { spawnSyncAndAssert } = require('../common/child_process');
+
+const env = {
+ ...process.env,
+ PERMISSION_ENV_SECRET: 'secret',
+ PERMISSION_ENV_ALLOWED: 'allowed',
+};
+
+// The environment of a child spawned by a process that enforces the
+// permission model already reflects --allow-env, so the child gets access to
+// all of it: including variables the parent added, and ones added for the
+// child only. It still cannot see what the parent removed.
+spawnSyncAndAssert(
+ process.execPath,
+ [
+ '--permission', '--allow-child-process', '--no-warnings',
+ '--allow-env=PERMISSION_ENV_ALLOWED', '-e',
+ `
+ const assert = require('assert');
+ const { spawnSync } = require('child_process');
+ process.env.PERMISSION_ENV_RUNTIME = 'runtime';
+ const { status, stdout, stderr } = spawnSync(process.execPath, [
+ '--no-warnings', '-p',
+ 'JSON.stringify([' +
+ 'process.env.PERMISSION_ENV_ALLOWED, ' +
+ 'process.env.PERMISSION_ENV_RUNTIME, ' +
+ 'process.env.PERMISSION_ENV_CHILD, ' +
+ 'process.env.PERMISSION_ENV_SECRET, ' +
+ 'process.permission.has("env")])',
+ ], {
+ env: { ...process.env, PERMISSION_ENV_CHILD: 'child' },
+ });
+ assert.strictEqual(status, 0, stderr.toString());
+ assert.deepStrictEqual(
+ JSON.parse(stdout),
+ ['allowed', 'runtime', 'child', null, true]);
+ `,
+ ],
+ { env },
+ {});
+
+// In audit mode nothing was removed, so children audit the same policy.
+spawnSyncAndAssert(
+ process.execPath,
+ [
+ '--permission-audit', '--allow-child-process', '--no-warnings',
+ '--allow-env=PERMISSION_ENV_ALLOWED', '-e',
+ `
+ const assert = require('assert');
+ const { spawnSync } = require('child_process');
+ const { status, stdout, stderr } = spawnSync(process.execPath, [
+ '--no-warnings', '-p',
+ 'JSON.stringify([' +
+ 'process.permission.has("env"), ' +
+ 'process.permission.has("env", "PERMISSION_ENV_ALLOWED"), ' +
+ 'process.permission.has("env", "PERMISSION_ENV_SECRET"), ' +
+ 'process.env.PERMISSION_ENV_SECRET])',
+ ]);
+ assert.strictEqual(status, 0, stderr.toString());
+ assert.deepStrictEqual(JSON.parse(stdout), [false, true, false, 'secret']);
+ `,
+ ],
+ { env },
+ {});
diff --git a/test/parallel/test-permission-env-cli.js b/test/parallel/test-permission-env-cli.js
new file mode 100644
index 00000000000..fbdeba0e009
--- /dev/null
+++ b/test/parallel/test-permission-env-cli.js
@@ -0,0 +1,41 @@
+'use strict';
+
+const common = require('../common');
+const { isMainThread } = require('worker_threads');
+
+if (!isMainThread) {
+ common.skip('This test only works on a main thread');
+}
+
+const assert = require('assert');
+const { spawnSyncAndAssert, spawnSyncAndExit } = require('../common/child_process');
+
+for (const value of ['A*B', '*A', 'A=B', 'A,B*C']) {
+ spawnSyncAndExit(
+ process.execPath,
+ ['--permission', `--allow-env=${value}`, '-e', ''],
+ {
+ status: 9,
+ signal: null,
+ stderr: /--allow-env must be '\*', a variable name, or a variable name prefix followed by '\*'/,
+ });
+}
+
+// --allow-env is accepted in NODE_OPTIONS.
+if (!process.config.variables.node_without_node_options) {
+ spawnSyncAndAssert(
+ process.execPath,
+ ['--permission', '-p', 'process.env.PERMISSION_ENV_ALLOWED'],
+ {
+ env: {
+ ...process.env,
+ NODE_OPTIONS: '--allow-env=PERMISSION_ENV_ALLOWED',
+ PERMISSION_ENV_ALLOWED: 'allowed',
+ },
+ },
+ {
+ stdout(output) {
+ assert.strictEqual(output.trim(), 'allowed');
+ },
+ });
+}
diff --git a/test/parallel/test-permission-env-config-file.js b/test/parallel/test-permission-env-config-file.js
new file mode 100644
index 00000000000..21a1f4d6126
--- /dev/null
+++ b/test/parallel/test-permission-env-config-file.js
@@ -0,0 +1,99 @@
+'use strict';
+
+const common = require('../common');
+const { isMainThread } = require('worker_threads');
+
+if (!isMainThread) {
+ common.skip('This test only works on a main thread');
+}
+
+const assert = require('assert');
+const fs = require('fs');
+const tmpdir = require('../common/tmpdir');
+const { spawnSyncAndAssert } = require('../common/child_process');
+
+tmpdir.refresh();
+
+function writeConfig(name, allowEnv) {
+ const path = tmpdir.resolve(name);
+ fs.writeFileSync(path, JSON.stringify({ permission: { 'allow-env': allowEnv } }));
+ return path;
+}
+
+const bothConfig = writeConfig('both.json', ['PERMISSION_ENV_A', 'PERMISSION_ENV_B']);
+const allConfig = writeConfig('all.json', ['*']);
+const prefixConfig = writeConfig('prefix.json', ['PERMISSION_ENV_PREFIX_DB*']);
+
+const envFile = tmpdir.resolve('options.env');
+fs.writeFileSync(envFile, 'NODE_OPTIONS="--allow-env=*"\n');
+
+const env = {
+ ...process.env,
+ PERMISSION_ENV_A: 'a',
+ PERMISSION_ENV_B: 'b',
+ PERMISSION_ENV_PREFIX_DB_URL: 'db',
+ PERMISSION_ENV_PREFIX_OTHER: 'other',
+};
+
+function visible(flags, extraEnv = {}) {
+ let result;
+ spawnSyncAndAssert(
+ process.execPath,
+ [
+ ...flags, '--no-warnings', '-p',
+ 'JSON.stringify(Object.keys(process.env).filter((name) => name.startsWith("PERMISSION_ENV_")).sort())',
+ ],
+ { env: { ...env, ...extraEnv } },
+ {
+ stdout(output) {
+ result = JSON.parse(output);
+ },
+ });
+ return result;
+}
+
+// When the command line enables the permission model, --allow-env values from
+// the configuration file can only narrow what the command line grants.
+assert.deepStrictEqual(
+ visible([
+ '--permission',
+ '--allow-env=PERMISSION_ENV_A',
+ `--experimental-config-file=${bothConfig}`,
+ ]),
+ ['PERMISSION_ENV_A']);
+
+assert.deepStrictEqual(
+ visible(['--permission', `--experimental-config-file=${allConfig}`]),
+ []);
+
+assert.deepStrictEqual(
+ visible([
+ '--permission',
+ '--allow-env=PERMISSION_ENV_PREFIX_*',
+ `--experimental-config-file=${prefixConfig}`,
+ ]),
+ ['PERMISSION_ENV_PREFIX_DB_URL']);
+
+// The same applies to NODE_OPTIONS defined in an env file.
+assert.deepStrictEqual(
+ visible([
+ '--permission',
+ '--allow-env=PERMISSION_ENV_A',
+ `--env-file=${envFile}`,
+ ]),
+ ['PERMISSION_ENV_A']);
+
+// When only the configuration file enables the permission model, its
+// --allow-env values apply.
+assert.deepStrictEqual(
+ visible([`--experimental-config-file=${bothConfig}`]),
+ ['PERMISSION_ENV_A', 'PERMISSION_ENV_B']);
+
+// The NODE_OPTIONS environment variable is not a file, and can grant access.
+if (!process.config.variables.node_without_node_options) {
+ assert.deepStrictEqual(
+ visible(
+ ['--permission', '--allow-env=PERMISSION_ENV_A'],
+ { NODE_OPTIONS: '--allow-env=PERMISSION_ENV_B' }),
+ ['PERMISSION_ENV_A', 'PERMISSION_ENV_B']);
+}
diff --git a/test/parallel/test-permission-env-drop.js b/test/parallel/test-permission-env-drop.js
new file mode 100644
index 00000000000..652513231e0
--- /dev/null
+++ b/test/parallel/test-permission-env-drop.js
@@ -0,0 +1,70 @@
+'use strict';
+
+const common = require('../common');
+const { isMainThread } = require('worker_threads');
+
+if (!isMainThread) {
+ common.skip('This test only works on a main thread');
+}
+
+const { spawnSyncAndAssert } = require('../common/child_process');
+
+const env = {
+ ...process.env,
+ PERMISSION_ENV_ONE: 'one',
+ PERMISSION_ENV_TWO: 'two',
+ TZ: 'UTC',
+};
+
+// Dropping a variable removes it from the environment.
+spawnSyncAndAssert(
+ process.execPath,
+ [
+ '--permission', '--allow-env=PERMISSION_ENV_ONE,PERMISSION_ENV_TWO', '-e',
+ `
+ const assert = require('assert');
+ const dc = require('diagnostics_channel');
+ const drops = [];
+ dc.subscribe('node:permission-model:env', (message) => {
+ if (message.drop) drops.push(message.resource);
+ });
+
+ assert.strictEqual(process.permission.has('env', 'PERMISSION_ENV_ONE'), true);
+ process.permission.drop('env', 'PERMISSION_ENV_ONE');
+ assert.strictEqual(process.permission.has('env', 'PERMISSION_ENV_ONE'), false);
+ assert.strictEqual(process.env.PERMISSION_ENV_ONE, undefined);
+ assert.strictEqual(process.permission.has('env', 'PERMISSION_ENV_TWO'), true);
+ assert.strictEqual(process.env.PERMISSION_ENV_TWO, 'two');
+
+ // Dropping the whole scope removes everything except the variables
+ // Node.js reads itself.
+ process.permission.drop('env');
+ assert.strictEqual(process.permission.has('env', 'PERMISSION_ENV_TWO'), false);
+ assert.strictEqual(process.env.PERMISSION_ENV_TWO, undefined);
+ assert.strictEqual(process.permission.has('env', 'TZ'), true);
+ assert.strictEqual(process.env.TZ, 'UTC');
+
+ assert.deepStrictEqual(drops, ['PERMISSION_ENV_ONE', '']);
+ `,
+ ],
+ { env },
+ {});
+
+// Dropping a variable when every variable is accessible.
+spawnSyncAndAssert(
+ process.execPath,
+ [
+ '--permission', '--allow-env=*', '-e',
+ `
+ const assert = require('assert');
+ assert.strictEqual(process.permission.has('env'), true);
+ process.permission.drop('env', 'PERMISSION_ENV_ONE');
+ assert.strictEqual(process.permission.has('env'), false);
+ assert.strictEqual(process.permission.has('env', 'PERMISSION_ENV_ONE'), false);
+ assert.strictEqual(process.env.PERMISSION_ENV_ONE, undefined);
+ assert.strictEqual(process.permission.has('env', 'PERMISSION_ENV_TWO'), true);
+ assert.strictEqual(process.env.PERMISSION_ENV_TWO, 'two');
+ `,
+ ],
+ { env },
+ {});
diff --git a/test/parallel/test-permission-env-file.js b/test/parallel/test-permission-env-file.js
new file mode 100644
index 00000000000..67513289db6
--- /dev/null
+++ b/test/parallel/test-permission-env-file.js
@@ -0,0 +1,71 @@
+'use strict';
+
+const common = require('../common');
+const { isMainThread } = require('worker_threads');
+
+if (!isMainThread) {
+ common.skip('This test only works on a main thread');
+}
+
+const assert = require('assert');
+const fs = require('fs');
+const tmpdir = require('../common/tmpdir');
+const { spawnSyncAndAssert } = require('../common/child_process');
+
+tmpdir.refresh();
+
+const envFile = tmpdir.resolve('permission.env');
+fs.writeFileSync(
+ envFile,
+ 'PERMISSION_ENV_FROM_FILE=file\nPERMISSION_ENV_BOTH=file\n');
+
+const env = {
+ ...process.env,
+ PERMISSION_ENV_BOTH: 'inherited',
+};
+
+const script = `console.log(JSON.stringify({
+ fromFile: process.env.PERMISSION_ENV_FROM_FILE,
+ both: process.env.PERMISSION_ENV_BOTH,
+ has: [
+ process.permission.has('env', 'PERMISSION_ENV_FROM_FILE'),
+ process.permission.has('env', 'PERMISSION_ENV_BOTH'),
+ ],
+}))`;
+
+// Variables defined in an env file are allowed. Only the file's values are
+// visible: an inherited value for the same name was removed at startup.
+spawnSyncAndAssert(
+ process.execPath,
+ ['--permission', `--env-file=${envFile}`, '-e', script],
+ { env },
+ {
+ stdout(output) {
+ assert.deepStrictEqual(JSON.parse(output), {
+ fromFile: 'file',
+ both: 'file',
+ has: [true, true],
+ });
+ },
+ });
+
+// A name that --allow-env grants access to keeps the usual precedence of the
+// inherited value over the file's.
+spawnSyncAndAssert(
+ process.execPath,
+ [
+ '--permission',
+ '--allow-env=PERMISSION_ENV_BOTH',
+ `--env-file=${envFile}`,
+ '-e', script,
+ ],
+ { env },
+ {
+ stdout(output) {
+ assert.deepStrictEqual(JSON.parse(output), {
+ fromFile: 'file',
+ both: 'inherited',
+ has: [true, true],
+ });
+ },
+ });
diff --git a/test/parallel/test-permission-env-proc-environ.js b/test/parallel/test-permission-env-proc-environ.js
new file mode 100644
index 00000000000..89c3cc9ffd0
--- /dev/null
+++ b/test/parallel/test-permission-env-proc-environ.js
@@ -0,0 +1,242 @@
+'use strict';
+
+const common = require('../common');
+const { isMainThread } = require('worker_threads');
+
+if (!isMainThread) {
+ common.skip('This test only works on a main thread');
+}
+
+if (!common.isLinux) {
+ common.skip('/proc/<pid>/environ is specific to Linux');
+}
+
+const assert = require('assert');
+const fs = require('fs');
+const { spawn } = require('child_process');
+const { spawnSyncAndAssert } = require('../common/child_process');
+
+const env = {
+ ...process.env,
+ PERMISSION_ENV_SECRET: 'secret-value',
+ PERMISSION_ENV_ALLOWED: 'allowed-value',
+};
+
+const tmpdir = require('../common/tmpdir');
+tmpdir.refresh();
+
+// A symbolic link that reaches the environ file of this process, which is
+// the parent of the processes below, under another name.
+const parentEnvironLink = tmpdir.resolve('parent-environ');
+fs.symlinkSync(`/proc/${process.pid}/environ`, parentEnvironLink);
+
+// The source of an array of the paths that reach the environ file of the
+// parent process, evaluated in the child.
+const parentPaths = `[
+ '/proc/' + process.ppid + '/environ',
+ '/proc/' + process.ppid + '/task/' + process.ppid + '/environ',
+ // Symbolic links resolve differently than the paths lexically do.
+ '/dev/fd/../../' + process.ppid + '/environ',
+ '/proc/self/root/proc/' + process.ppid + '/environ',
+ ${JSON.stringify(parentEnvironLink)},
+]`;
+
+// The same for the environ file of the process itself.
+const ownPaths = `[
+ '/proc/self/environ',
+ '/proc/thread-self/environ',
+ '/proc/' + process.pid + '/environ',
+ '/proc/self/task/' + process.pid + '/environ',
+ '/dev/fd/../environ',
+]`;
+
+// Source that asserts, in the child, that reading each of `paths` is denied.
+const assertDenied = (paths) => `
+ for (const path of ${paths}) {
+ require('assert').throws(() => require('fs').readFileSync(path), {
+ code: 'ERR_ACCESS_DENIED',
+ permission: 'FileSystemRead',
+ resource: path,
+ }, path);
+ }
+`;
+
+// /proc/<pid>/environ exposes the environment a process started with, so
+// reading it is denied while the env scope is restricted, even when the file
+// system scope would allow it.
+spawnSyncAndAssert(
+ process.execPath,
+ [
+ '--permission', '--allow-fs-read=*', '-e',
+ assertDenied(ownPaths) + assertDenied(parentPaths),
+ ],
+ { env },
+ {});
+
+// Relative paths are resolved against the working directory.
+spawnSyncAndAssert(
+ process.execPath,
+ [
+ '--permission', '--allow-fs-read=*', '--allow-env=*', '-e',
+ assertDenied(`[
+ process.ppid + '/environ',
+ '/proc/self/cwd/' + process.ppid + '/environ',
+ ]`),
+ ],
+ { env, cwd: '/proc' },
+ {});
+
+// Reading its own is allowed when every variable is accessible, but the
+// environment of any other process stays out of reach: a child process is
+// started with --allow-env=*, and must not see what its parent could not.
+spawnSyncAndAssert(
+ process.execPath,
+ [
+ '--permission', '--allow-fs-read=*', '--allow-env=*', '-e',
+ `
+ for (const path of ${ownPaths}) require('fs').readFileSync(path);
+ ${assertDenied(parentPaths)}
+ `,
+ ],
+ { env },
+ {});
+
+// The same holds for a child process that a restricted process spawns, which
+// inherits --allow-env=*.
+{
+ const grandchild = `
+ require('fs').readFileSync('/proc/self/environ');
+ ${assertDenied(`[
+ '/proc/' + process.ppid + '/environ',
+ '/dev/fd/../../' + process.ppid + '/environ',
+ ]`)}
+ `;
+ spawnSyncAndAssert(
+ process.execPath,
+ [
+ '--permission', '--allow-fs-read=*', '--allow-child-process', '-e',
+ `require('child_process').execFileSync(
+ process.execPath, ['-e', ${JSON.stringify(grandchild)}],
+ { stdio: 'inherit' });`,
+ ],
+ { env },
+ {});
+}
+
+// Other files on procfs are not affected.
+spawnSyncAndAssert(
+ process.execPath,
+ [
+ '--permission', '--allow-fs-read=*', '-e',
+ `
+ const fs = require('fs');
+ fs.readFileSync('/proc/self/stat');
+ fs.readFileSync('/proc/' + process.ppid + '/stat');
+ fs.readdirSync('/proc/self');
+ `,
+ ],
+ { env },
+ {});
+
+// Other processes do not find the removed variables there either, because
+// they are overwritten in the initial environment block.
+{
+ const child = spawn(
+ process.execPath,
+ [
+ '--permission', '--allow-env=PERMISSION_ENV_ALLOWED', '-e',
+ 'console.log("ready"); process.stdin.once("data", () => {});',
+ ],
+ { env, stdio: ['pipe', 'pipe', 'inherit'] });
+
+ child.stdout.once('data', common.mustCall(() => {
+ const environ = fs.readFileSync(`/proc/${child.pid}/environ`, 'latin1');
+ assert.doesNotMatch(environ, /PERMISSION_ENV_SECRET|secret-value/);
+ assert.match(environ, /PERMISSION_ENV_ALLOWED=allowed-value/);
+ child.stdin.end('done');
+ }));
+
+ child.on('exit', common.mustCall((code, signal) => {
+ assert.strictEqual(code, 0);
+ assert.strictEqual(signal, null);
+ }));
+}
+
+// permission.drop() overwrites the initial environment block too, so a
+// variable that startup kept does not stay readable there after it is
+// dropped at runtime.
+{
+ const child = spawn(
+ process.execPath,
+ [
+ '--permission', '--allow-env=PERMISSION_ENV_*', '-e',
+ `
+ console.log('ready');
+ process.stdin.once('data', () => {
+ process.permission.drop('env', 'PERMISSION_ENV_SECRET');
+ console.log('dropped');
+ process.stdin.once('data', () => {});
+ });
+ `,
+ ],
+ { env, stdio: ['pipe', 'pipe', 'inherit'] });
+
+ const readEnviron = () =>
+ fs.readFileSync(`/proc/${child.pid}/environ`, 'latin1');
+
+ child.stdout.once('data', common.mustCall(() => {
+ // Both are allowed at startup, so both are still in the initial block.
+ const environ = readEnviron();
+ assert.match(environ, /PERMISSION_ENV_SECRET=secret-value/);
+ assert.match(environ, /PERMISSION_ENV_ALLOWED=allowed-value/);
+
+ child.stdout.once('data', common.mustCall(() => {
+ const scrubbed = readEnviron();
+ assert.doesNotMatch(scrubbed, /PERMISSION_ENV_SECRET|secret-value/);
+ // Variables that were not dropped are left alone.
+ assert.match(scrubbed, /PERMISSION_ENV_ALLOWED=allowed-value/);
+ child.stdin.end('done');
+ }));
+
+ child.stdin.write('drop');
+ }));
+
+ child.on('exit', common.mustCall((code, signal) => {
+ assert.strictEqual(code, 0);
+ assert.strictEqual(signal, null);
+ }));
+}
+
+// Dropping the whole env scope does the same for every variable it removes.
+{
+ const child = spawn(
+ process.execPath,
+ [
+ '--permission', '--allow-env=PERMISSION_ENV_*', '-e',
+ `
+ console.log('ready');
+ process.stdin.once('data', () => {
+ process.permission.drop('env');
+ console.log('dropped');
+ process.stdin.once('data', () => {});
+ });
+ `,
+ ],
+ { env, stdio: ['pipe', 'pipe', 'inherit'] });
+
+ child.stdout.once('data', common.mustCall(() => {
+ child.stdout.once('data', common.mustCall(() => {
+ const environ = fs.readFileSync(`/proc/${child.pid}/environ`, 'latin1');
+ assert.doesNotMatch(environ, /PERMISSION_ENV_SECRET|secret-value/);
+ assert.doesNotMatch(environ, /PERMISSION_ENV_ALLOWED|allowed-value/);
+ child.stdin.end('done');
+ }));
+
+ child.stdin.write('drop');
+ }));
+
+ child.on('exit', common.mustCall((code, signal) => {
+ assert.strictEqual(code, 0);
+ assert.strictEqual(signal, null);
+ }));
+}
diff --git a/test/parallel/test-permission-env-scrub.js b/test/parallel/test-permission-env-scrub.js
new file mode 100644
index 00000000000..c5d86a87f7a
--- /dev/null
+++ b/test/parallel/test-permission-env-scrub.js
@@ -0,0 +1,164 @@
+'use strict';
+
+const common = require('../common');
+const { isMainThread } = require('worker_threads');
+
+if (!isMainThread) {
+ common.skip('This test only works on a main thread');
+}
+
+const assert = require('assert');
+const { spawnSyncAndAssert, spawnSyncAndExit } = require('../common/child_process');
+
+const names = [
+ 'PERMISSION_ENV_SECRET',
+ 'PERMISSION_ENV_ALLOWED',
+ 'PERMISSION_ENV_PREFIX_ONE',
+ 'PERMISSION_ENV_PREFIX_TWO',
+ // Starts with NODE_, but is not one of the variables Node.js reads.
+ 'NODE_AUTH_TOKEN',
+ // Read by Node.js itself, so it is kept.
+ 'TZ',
+];
+
+const env = {
+ ...process.env,
+ PERMISSION_ENV_SECRET: 'secret',
+ PERMISSION_ENV_ALLOWED: 'allowed',
+ PERMISSION_ENV_PREFIX_ONE: 'one',
+ PERMISSION_ENV_PREFIX_TWO: 'two',
+ NODE_AUTH_TOKEN: 'token',
+ TZ: 'UTC',
+};
+
+const script = `
+ const names = ${JSON.stringify(names)};
+ const { environmentVariables } = process.report.getReport();
+ console.log(JSON.stringify({
+ values: Object.fromEntries(names.map((name) => [name, process.env[name]])),
+ keys: names.filter((name) => Object.keys(process.env).includes(name)),
+ report: names.filter((name) => name in environmentVariables),
+ has: process.permission &&
+ Object.fromEntries(names.map((name) => [name, process.permission.has('env', name)])),
+ hasAll: process.permission?.has('env'),
+ }));
+`;
+
+function run(...flags) {
+ let result;
+ spawnSyncAndAssert(
+ process.execPath,
+ [...flags, '--no-warnings', '-e', script],
+ { env },
+ {
+ stdout(output) {
+ result = JSON.parse(output);
+ },
+ });
+ return result;
+}
+
+// Variables --allow-env does not grant access to are removed at startup.
+{
+ const result = run(
+ '--permission',
+ '--allow-env=PERMISSION_ENV_ALLOWED,PERMISSION_ENV_PREFIX_*');
+ const visible = [
+ 'PERMISSION_ENV_ALLOWED',
+ 'PERMISSION_ENV_PREFIX_ONE',
+ 'PERMISSION_ENV_PREFIX_TWO',
+ 'TZ',
+ ];
+ assert.deepStrictEqual(result.values, {
+ PERMISSION_ENV_ALLOWED: 'allowed',
+ PERMISSION_ENV_PREFIX_ONE: 'one',
+ PERMISSION_ENV_PREFIX_TWO: 'two',
+ TZ: 'UTC',
+ });
+ assert.deepStrictEqual(result.keys, visible);
+ assert.deepStrictEqual(result.report, visible);
+ assert.deepStrictEqual(result.has, {
+ PERMISSION_ENV_SECRET: false,
+ PERMISSION_ENV_ALLOWED: true,
+ PERMISSION_ENV_PREFIX_ONE: true,
+ PERMISSION_ENV_PREFIX_TWO: true,
+ NODE_AUTH_TOKEN: false,
+ TZ: true,
+ });
+ assert.strictEqual(result.hasAll, false);
+}
+
+// --allow-env can be given more than once.
+{
+ const result = run(
+ '--permission',
+ '--allow-env=PERMISSION_ENV_ALLOWED',
+ '--allow-env=PERMISSION_ENV_PREFIX_ONE');
+ assert.deepStrictEqual(result.keys, [
+ 'PERMISSION_ENV_ALLOWED',
+ 'PERMISSION_ENV_PREFIX_ONE',
+ 'TZ',
+ ]);
+}
+
+// Without --allow-env, only the variables Node.js reads itself are kept.
+{
+ const result = run('--permission');
+ assert.deepStrictEqual(result.keys, ['TZ']);
+ assert.strictEqual(result.hasAll, false);
+}
+
+// --allow-env=* grants access to every variable.
+{
+ const result = run('--permission', '--allow-env=*');
+ assert.deepStrictEqual(result.keys, names);
+ assert.deepStrictEqual(result.report, names);
+ assert.strictEqual(result.hasAll, true);
+}
+
+// Audit mode does not remove anything.
+{
+ const result = run('--permission-audit', '--allow-env=PERMISSION_ENV_ALLOWED');
+ assert.deepStrictEqual(result.keys, names);
+ assert.strictEqual(result.has.PERMISSION_ENV_SECRET, false);
+ assert.strictEqual(result.has.PERMISSION_ENV_ALLOWED, true);
+ assert.strictEqual(result.hasAll, false);
+}
+
+// --allow-env requires the permission model.
+spawnSyncAndExit(
+ process.execPath,
+ ['--allow-env=PERMISSION_ENV_ALLOWED', '-e', ''],
+ { env },
+ {
+ status: 1,
+ signal: null,
+ stderr: /ERR_MISSING_OPTION.*--permission is required|--permission is required/,
+ });
+
+// Workers see the removed variables as absent, whether they share the
+// environment or copy it.
+spawnSyncAndAssert(
+ process.execPath,
+ [
+ '--permission', '--allow-worker', '--no-warnings', '-e',
+ `
+ const assert = require('assert');
+ const { Worker, SHARE_ENV } = require('worker_threads');
+ for (const workerEnv of [SHARE_ENV, undefined]) {
+ new Worker(
+ 'require("worker_threads").parentPort.postMessage(process.env.PERMISSION_ENV_SECRET)',
+ { eval: true, env: workerEnv },
+ ).on('message', (value) => assert.strictEqual(value, undefined));
+ }
+ `,
+ ],
+ { env },
+ {});
+
+// Environment variable names are case-insensitive on Windows.
+if (common.isWindows) {
+ const result = run('--permission', '--allow-env=permission_env_allowed');
+ assert.deepStrictEqual(result.keys, ['PERMISSION_ENV_ALLOWED', 'TZ']);
+ assert.strictEqual(result.has.PERMISSION_ENV_ALLOWED, true);
+}
diff --git a/test/parallel/test-permission-env-warning.js b/test/parallel/test-permission-env-warning.js
new file mode 100644
index 00000000000..13a99208f5d
--- /dev/null
+++ b/test/parallel/test-permission-env-warning.js
@@ -0,0 +1,167 @@
+'use strict';
+
+const common = require('../common');
+const { isMainThread } = require('worker_threads');
+
+if (!isMainThread) {
+ common.skip('This test only works on a main thread');
+}
+
+const assert = require('assert');
+const { spawnSyncAndAssert } = require('../common/child_process');
+
+const env = {
+ ...process.env,
+ PERMISSION_ENV_SECRET: 'secret',
+};
+
+// Reading a variable that was removed at startup publishes a denial every
+// time, and warns once.
+spawnSyncAndAssert(
+ process.execPath,
+ [
+ '--permission', '-e',
+ `
+ const assert = require('assert');
+ const dc = require('diagnostics_channel');
+ const events = [];
+ dc.subscribe('node:permission-model:env', (message) => {
+ events.push([message.permission, message.resource]);
+ });
+
+ assert.strictEqual(process.env.PERMISSION_ENV_SECRET, undefined);
+ assert.strictEqual(process.env.PERMISSION_ENV_SECRET, undefined);
+ assert.strictEqual('PERMISSION_ENV_SECRET' in process.env, false);
+ // Variables that were never set are not reported.
+ assert.strictEqual(process.env.PERMISSION_ENV_NEVER_SET, undefined);
+
+ assert.deepStrictEqual(events, [
+ ['Env', 'PERMISSION_ENV_SECRET'],
+ ['Env', 'PERMISSION_ENV_SECRET'],
+ ['Env', 'PERMISSION_ENV_SECRET'],
+ ]);
+ `,
+ ],
+ { env },
+ {
+ stderr(output) {
+ const warnings = output.match(
+ /Warning: The permission model removed the environment variable "PERMISSION_ENV_SECRET" at startup\. Use --allow-env to manage permissions\./g);
+ assert.strictEqual(warnings?.length, 1, output);
+ assert.doesNotMatch(output, /PERMISSION_ENV_NEVER_SET/);
+ },
+ });
+
+// --no-warnings silences the warning.
+spawnSyncAndAssert(
+ process.execPath,
+ ['--permission', '--no-warnings', '-e', 'process.env.PERMISSION_ENV_SECRET'],
+ { env },
+ { stderr: '' });
+
+// In audit mode nothing is removed. Accesses to variables that --allow-env
+// does not grant access to are published instead, without a warning.
+spawnSyncAndAssert(
+ process.execPath,
+ [
+ '--permission-audit', '--allow-env=PERMISSION_ENV_ALLOWED', '-e',
+ `
+ const assert = require('assert');
+ const dc = require('diagnostics_channel');
+ const events = [];
+ dc.subscribe('node:permission-model:env', (message) => {
+ events.push(message.resource);
+ });
+
+ assert.strictEqual(process.env.PERMISSION_ENV_SECRET, 'secret');
+ assert.strictEqual('PERMISSION_ENV_SECRET' in process.env, true);
+ assert.strictEqual(process.env.PERMISSION_ENV_ALLOWED, 'allowed');
+ process.env.TZ;
+ // Variables created at runtime would not have been removed either.
+ process.env.PERMISSION_ENV_RUNTIME = 'runtime';
+ assert.strictEqual(process.env.PERMISSION_ENV_RUNTIME, 'runtime');
+
+ assert.deepStrictEqual(events, [
+ 'PERMISSION_ENV_SECRET',
+ 'PERMISSION_ENV_SECRET',
+ ]);
+ `,
+ ],
+ { env: { ...env, PERMISSION_ENV_ALLOWED: 'allowed' } },
+ {
+ stderr(output) {
+ assert.doesNotMatch(output, /removed the environment variable/);
+ },
+ });
+
+// --use-env-proxy stops applying the proxy variables the permission model
+// removes, which a single warning points out. Reading them afterwards does
+// not warn again.
+{
+ const proxyEnv = { ...process.env };
+ for (const name of ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY']) {
+ delete proxyEnv[name];
+ delete proxyEnv[name.toLowerCase()];
+ }
+ delete proxyEnv.NODE_USE_ENV_PROXY;
+ proxyEnv.HTTP_PROXY = 'http://proxy.invalid:8080';
+ proxyEnv.NO_PROXY = 'localhost';
+
+ spawnSyncAndAssert(
+ process.execPath,
+ [
+ '--permission', '--use-env-proxy', '-e',
+ 'process.env.HTTP_PROXY; process.env.NO_PROXY;',
+ ],
+ { env: proxyEnv },
+ {
+ stderr(output) {
+ const warnings = output.match(
+ /Warning: --use-env-proxy is enabled, but the permission model removed HTTP_PROXY, NO_PROXY from the environment at startup/g);
+ assert.strictEqual(warnings?.length, 1, output);
+ assert.doesNotMatch(output, /removed the environment variable/);
+ },
+ });
+
+ // NODE_USE_ENV_PROXY enables it too.
+ spawnSyncAndAssert(
+ process.execPath,
+ ['--permission', '-e', '0'],
+ { env: { ...proxyEnv, NODE_USE_ENV_PROXY: '1' } },
+ { stderr: /--use-env-proxy is enabled, but the permission model removed HTTP_PROXY, NO_PROXY/ });
+
+ // Only the variables that were removed are named.
+ spawnSyncAndAssert(
+ process.execPath,
+ ['--permission', '--use-env-proxy', '--allow-env=HTTP_PROXY', '-e', '0'],
+ { env: proxyEnv },
+ {
+ stderr(output) {
+ assert.match(output, /the permission model removed NO_PROXY from/);
+ },
+ });
+
+ // No warning once they are allowed.
+ spawnSyncAndAssert(
+ process.execPath,
+ [
+ '--permission', '--use-env-proxy', '--allow-env=HTTP_PROXY,NO_PROXY',
+ '-e', '0',
+ ],
+ { env: proxyEnv },
+ { stderr: '' });
+
+ // Nor without --use-env-proxy, which does not read them.
+ spawnSyncAndAssert(
+ process.execPath,
+ ['--permission', '-e', '0'],
+ { env: proxyEnv },
+ { stderr: '' });
+
+ // Nor in audit mode, which removes nothing.
+ spawnSyncAndAssert(
+ process.execPath,
+ ['--permission-audit', '--use-env-proxy', '-e', '0'],
+ { env: proxyEnv },
+ { stderr: '' });
+}
diff --git a/test/parallel/test-permission-fs-read.js b/test/parallel/test-permission-fs-read.js
index d36776b9496..6261fe24020 100644
--- a/test/parallel/test-permission-fs-read.js
+++ b/test/parallel/test-permission-fs-read.js
@@ -1,4 +1,4 @@
-// Flags: --permission --allow-fs-read=* --allow-fs-write=* --allow-child-process
+// Flags: --permission --allow-fs-read=* --allow-fs-write=* --allow-child-process --allow-env=NODE_TEST_DIR,TEST_*
'use strict';
const common = require('../common');
@@ -43,6 +43,7 @@ const commonPath = path.join(__filename, '../../common');
process.execPath,
[
'--permission',
+ '--allow-env=BOUNDARY_FILE',
...grantedFiles.map((file) => `--allow-fs-read=${file}`),
...grantedFiles.map((file) => `--allow-fs-write=${file}`),
'-e',
@@ -79,6 +80,7 @@ const commonPath = path.join(__filename, '../../common');
process.execPath,
[
'--permission',
+ '--allow-env=BLOCKEDFILE,BLOCKEDFOLDER,ALLOWEDFOLDER',
// Do not uncomment this line
// `--allow-fs-read=${file}`,
`--allow-fs-read=${commonPathWildcard}`,
diff --git a/test/parallel/test-permission-fs-symlink-target-write.js b/test/parallel/test-permission-fs-symlink-target-write.js
index 1cffead4dd7..0df7a20b28e 100644
--- a/test/parallel/test-permission-fs-symlink-target-write.js
+++ b/test/parallel/test-permission-fs-symlink-target-write.js
@@ -1,4 +1,4 @@
-// Flags: --permission --allow-fs-read=* --allow-fs-write=* --allow-child-process
+// Flags: --permission --allow-fs-read=* --allow-fs-write=* --allow-child-process --allow-env=NODE_TEST_DIR,TEST_*
'use strict';
const common = require('../common');
@@ -42,6 +42,7 @@ fs.writeFileSync(path.join(readWriteFolder, 'file'), 'NO evil file contents');
process.execPath,
[
'--permission',
+ '--allow-env=READONLYFOLDER,READWRITEFOLDER,WRITEONLYFOLDER',
`--allow-fs-read=${file}`, `--allow-fs-read=${commonPathWildcard}`, `--allow-fs-read=${readOnlyFolder}`, `--allow-fs-read=${readWriteFolder}`,
`--allow-fs-write=${readWriteFolder}`, `--allow-fs-write=${writeOnlyFolder}`,
file,
diff --git a/test/parallel/test-permission-fs-symlink.js b/test/parallel/test-permission-fs-symlink.js
index d918e4ea0fe..c8d6afe89ba 100644
--- a/test/parallel/test-permission-fs-symlink.js
+++ b/test/parallel/test-permission-fs-symlink.js
@@ -1,4 +1,4 @@
-// Flags: --permission --allow-fs-read=* --allow-fs-write=* --allow-child-process
+// Flags: --permission --allow-fs-read=* --allow-fs-write=* --allow-child-process --allow-env=NODE_TEST_DIR,TEST_*
'use strict';
const common = require('../common');
@@ -54,6 +54,7 @@ const traversalSymlink = path.join(allowedFolder, 'deep1', 'deep2', 'deep3', 'go
process.execPath,
[
'--permission',
+ '--allow-env=BLOCKEDFILE,BLOCKEDFOLDER,EXISTINGSYMLINK',
`--allow-fs-read=${file}`, `--allow-fs-read=${commonPathWildcard}`, `--allow-fs-read=${symlinkFromBlockedFile}`,
`--allow-fs-read=${allowedFolder}`,
`--allow-fs-write=${symlinkFromBlockedFile}`,
diff --git a/test/parallel/test-permission-fs-traversal-path.js b/test/parallel/test-permission-fs-traversal-path.js
index ed9e434b6b8..4c502359a53 100644
--- a/test/parallel/test-permission-fs-traversal-path.js
+++ b/test/parallel/test-permission-fs-traversal-path.js
@@ -1,4 +1,4 @@
-// Flags: --permission --allow-fs-read=* --allow-fs-write=* --allow-child-process
+// Flags: --permission --allow-fs-read=* --allow-fs-write=* --allow-child-process --allow-env=NODE_TEST_DIR,TEST_*
'use strict';
const common = require('../common');
@@ -38,6 +38,7 @@ const commonPathWildcard = path.join(__filename, '../../common*');
process.execPath,
[
'--permission',
+ '--allow-env=ALLOWEDFOLDER,BLOCKEDFOLDER',
`--allow-fs-read=${file}`, `--allow-fs-read=${commonPathWildcard}`, `--allow-fs-read=${allowedFolder}`,
`--allow-fs-write=${allowedFolder}`,
file,
diff --git a/test/parallel/test-permission-fs-write-report.js b/test/parallel/test-permission-fs-write-report.js
index 728bc4d34c8..7a726ce8e50 100644
--- a/test/parallel/test-permission-fs-write-report.js
+++ b/test/parallel/test-permission-fs-write-report.js
@@ -15,7 +15,7 @@ if (!common.hasCrypto) {
// We need to define the flags dynamically to account for the `NODE_TEST_DIR` env var.
if (!process.permission) {
spawnSyncAndExitWithoutError(process.execPath, [
- '--permission',
+ '--permission', '--allow-env=NODE_TEST_DIR,TEST_*',
'--allow-fs-read=*', `--allow-fs-write=${process.env.NODE_TEST_DIR || './test'}/.tmp.*`, '--allow-child-process',
__filename,
]);
diff --git a/test/parallel/test-permission-fs-write.js b/test/parallel/test-permission-fs-write.js
index 385a37e2a92..2269c5847ef 100644
--- a/test/parallel/test-permission-fs-write.js
+++ b/test/parallel/test-permission-fs-write.js
@@ -1,4 +1,4 @@
-// Flags: --permission --allow-fs-read=* --allow-child-process
+// Flags: --permission --allow-fs-read=* --allow-child-process --allow-env=NODE_TEST_DIR,TEST_*
'use strict';
const common = require('../common');
@@ -31,6 +31,7 @@ const file = fixtures.path('permission', 'fs-write.js');
process.execPath,
[
'--permission',
+ '--allow-env=ALLOWEDFILE,ALLOWEDFOLDER,BLOCKEDFILE,BLOCKEDFOLDER,RELATIVEBLOCKEDFILE,RELATIVEBLOCKEDFOLDER',
'--allow-fs-read=*',
`--allow-fs-write=${regularFile}`, `--allow-fs-write=${commonPath}`,
file,
diff --git a/test/parallel/test-permission-has-reference-types.js b/test/parallel/test-permission-has-reference-types.js
index 8509046dc5e..0f92aa2e579 100644
--- a/test/parallel/test-permission-has-reference-types.js
+++ b/test/parallel/test-permission-has-reference-types.js
@@ -32,6 +32,7 @@ const { status, stderr } = spawnSync(
process.execPath,
[
'--permission',
+ '--allow-env=ALLOWED_DIR,ALLOWED_FILE,DENIED_FILE',
`--allow-fs-read=${allowedDir}`,
fixtures.path('permission', 'has-reference-types.js'),
],
diff --git a/test/parallel/test-permission-net-fetch.js b/test/parallel/test-permission-net-fetch.js
index c943188aeb3..934681de5b0 100644
--- a/test/parallel/test-permission-net-fetch.js
+++ b/test/parallel/test-permission-net-fetch.js
@@ -25,6 +25,7 @@ const server = http.createServer((req, res) => {
process.execPath,
[
'--permission',
+ '--allow-env=URL',
'--allow-fs-read=*',
file,
],
diff --git a/test/parallel/test-permission-net-tcp.js b/test/parallel/test-permission-net-tcp.js
index fdc33366702..5fcf490e652 100644
--- a/test/parallel/test-permission-net-tcp.js
+++ b/test/parallel/test-permission-net-tcp.js
@@ -21,6 +21,7 @@ const server = net.createServer().listen(0, common.mustCall(() => {
process.execPath,
[
'--permission',
+ '--allow-env=HOST,PORT',
'--allow-fs-read=*',
file,
],
diff --git a/test/parallel/test-permission-net-warning.js b/test/parallel/test-permission-net-warning.js
index 65fae2a1001..1f5ab7ef5e6 100644
--- a/test/parallel/test-permission-net-warning.js
+++ b/test/parallel/test-permission-net-warning.js
@@ -1,4 +1,4 @@
-// Flags: --permission --allow-net --allow-fs-read=*
+// Flags: --permission --allow-net --allow-fs-read=* --allow-env=*
'use strict';
const common = require('../common');
diff --git a/test/parallel/test-permission-openssl-store.js b/test/parallel/test-permission-openssl-store.js
index f97657051ca..b1cfcdc4988 100644
--- a/test/parallel/test-permission-openssl-store.js
+++ b/test/parallel/test-permission-openssl-store.js
@@ -1,4 +1,4 @@
-// Flags: --permission --allow-fs-read=* --allow-fs-write=* --allow-openssl-store --allow-child-process
+// Flags: --permission --allow-fs-read=* --allow-fs-write=* --allow-openssl-store --allow-child-process --allow-env=NODE_TEST_DIR,TEST_*
'use strict';
const common = require('../common');