Skip to content

Commit f378cfc

Browse files
committed
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: #66132 Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent 261c8a1 commit f378cfc

47 files changed

Lines changed: 2570 additions & 23 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎doc/api/cli.md‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,51 @@ This behavior also applies to `child_process.spawn()`, but in that case, the
191191
flags are propagated via the `NODE_OPTIONS` environment variable rather than
192192
directly through the process arguments.
193193

194+
### `--allow-env`
195+
196+
<!-- YAML
197+
added: REPLACEME
198+
-->
199+
200+
> Stability: 1.1 - Active development
201+
202+
When using the [Permission Model][], the process starts without the environment
203+
variables it has not been granted access to. At startup, every variable that
204+
`--allow-env` does not match is removed from the process environment. Removed
205+
variables are absent from `process.env`, from diagnostic reports, from native
206+
code calling `getenv()`, and from the environment of child processes and worker
207+
threads.
208+
209+
The valid values are:
210+
211+
* `*` - Grants access to every environment variable.
212+
* A variable name, for example `--allow-env=DATABASE_URL`.
213+
* A variable name prefix followed by `*`, for example `--allow-env=APP_*`.
214+
215+
Multiple values can be passed by repeating the flag, or by separating them with
216+
commas: `--allow-env=PORT,APP_*`. Variable names are case-insensitive on
217+
Windows.
218+
219+
Example:
220+
221+
```js
222+
console.log(process.env.DATABASE_URL);
223+
console.log(process.env.AWS_SECRET_ACCESS_KEY);
224+
```
225+
226+
```console
227+
$ node --permission --allow-fs-read=* --allow-env=DATABASE_URL index.js
228+
postgres://localhost/app
229+
undefined
230+
(node:1234) Warning: The permission model removed the environment variable "AWS_SECRET_ACCESS_KEY" at startup. Use --allow-env to manage permissions.
231+
```
232+
233+
The variables that Node.js and its bundled dependencies read, such as
234+
`NODE_OPTIONS`, `PATH`, `HOME`, `TZ`, and `SSL_CERT_FILE`, are always kept, as
235+
are the variables defined in [`--env-file`][] files. `NODE_ENV` is not kept
236+
by default, so applications and libraries that read it need
237+
`--allow-env=NODE_ENV`. See [Environment variable permissions][] for details.
238+
194239
### `--allow-ffi`
195240

196241
<!-- YAML
@@ -402,6 +447,11 @@ This flag grants broad authority to configured OpenSSL STORE loaders. A loader
402447
may access files, devices, tokens, or the network. Access performed by a loader
403448
is not constrained by the `fs.read`, `fs.write`, or `net` permission scopes.
404449

450+
Loaders and the modules they load are subject to [`--allow-env`][], however.
451+
Environment variables they rely on, such as `SOFTHSM2_CONF` for SoftHSM, are
452+
removed at startup unless they are granted explicitly with `--allow-env`. See
453+
[Environment variable permissions][] for details.
454+
405455
### `--allow-wasi`
406456

407457
<!-- YAML
@@ -2538,6 +2588,7 @@ following permissions are restricted:
25382588
* File System - manageable through
25392589
[`--allow-fs-read`][], [`--allow-fs-write`][] flags
25402590
* Network - manageable through [`--allow-net`][] flag
2591+
* Environment variables - manageable through [`--allow-env`][] flag
25412592
* Child Process - manageable through [`--allow-child-process`][] flag
25422593
* Worker Threads - manageable through [`--allow-worker`][] flag
25432594
* WASI - manageable through [`--allow-wasi`][] flag
@@ -4179,6 +4230,7 @@ one is included in the list below.
41794230

41804231
* `--allow-addons`
41814232
* `--allow-child-process`
4233+
* `--allow-env`
41824234
* `--allow-ffi`
41834235
* `--allow-fs-read`
41844236
* `--allow-fs-vfs`
@@ -4826,6 +4878,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
48264878
[CommonJS module]: modules.md
48274879
[DEP0025 warning]: deprecations.md#dep0025-requirenodesys
48284880
[ECMAScript module]: esm.md#modules-ecmascript-modules
4881+
[Environment variable permissions]: permissions.md#environment-variable-permissions
48294882
[EventSource Web API]: https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
48304883
[ExperimentalWarning: `vm.measureMemory` is an experimental feature]: vm.md#vmmeasurememoryoptions
48314884
[FIPS mode]: crypto.md#fips-mode
@@ -4849,6 +4902,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
48494902
[`'crypto.fips.indicator'`]: diagnostics_channel.md#event-cryptofipsindicator
48504903
[`--allow-addons`]: #--allow-addons
48514904
[`--allow-child-process`]: #--allow-child-process
4905+
[`--allow-env`]: #--allow-env
48524906
[`--allow-fs-read`]: #--allow-fs-read
48534907
[`--allow-fs-write`]: #--allow-fs-write
48544908
[`--allow-net`]: #--allow-net

‎doc/api/embedding.md‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,51 @@ int main(int argc, char** argv) {
7272
}
7373
```
7474
75+
### Restricting access to environment variables
76+
77+
<!-- YAML
78+
added: REPLACEME
79+
-->
80+
81+
When the arguments passed to `node::InitializeOncePerProcess()` enable the
82+
[Permission Model][] without `--allow-env=*`, the process environment must not
83+
contain any variable that [`--allow-env`][] does not grant access to.
84+
`node::InitializeOncePerProcess()` fails otherwise. Unlike the `node`
85+
executable, embedders own the process environment, so Node.js does not remove
86+
these variables itself.
87+
88+
`node::ScrubProcessEnvironment()` removes them. Because it modifies the process
89+
environment without any locking that native code calling `getenv()`
90+
participates in, it must be called before starting any thread that may read the
91+
environment, and before `node::InitializeOncePerProcess()`:
92+
93+
```cpp
94+
int main(int argc, char** argv) {
95+
argv = uv_setup_args(argc, argv);
96+
std::vector<std::string> args(argv, argv + argc);
97+
98+
// Keep the variables the embedder itself reads, in addition to the ones
99+
// Node.js reads (see node::GetRuntimeEnvironmentDefaults()).
100+
node::ProcessEnvironmentScrubOptions scrub_options;
101+
scrub_options.allow = {"PORT", "APP_*"};
102+
if (node::ScrubProcessEnvironment(scrub_options).IsNothing()) {
103+
return 1;
104+
}
105+
106+
// args contains, for example, --permission --allow-env=PORT
107+
std::unique_ptr<node::InitializationResult> result =
108+
node::InitializeOncePerProcess(args, {
109+
node::ProcessInitializationFlags::kNoInitializeV8,
110+
node::ProcessInitializationFlags::kNoInitializeNodeV8Platform
111+
});
112+
// ...
113+
}
114+
```
115+
116+
`process.permission.drop('env', name)` removes a variable from the process
117+
environment, so it throws when called from a `node::Environment` created
118+
without `node::EnvironmentFlags::kOwnsProcessState`.
119+
75120
### Setting up a per-instance state
76121

77122
<!-- YAML
@@ -178,6 +223,8 @@ int RunNodeInstance(MultiIsolatePlatform* platform,
178223
```
179224
180225
[CLI options]: cli.md
226+
[Permission Model]: permissions.md#permission-model
227+
[`--allow-env`]: cli.md#--allow-env
181228
[`process.memoryUsage()`]: process.md#processmemoryusage
182229
[deprecation policy]: deprecations.md
183230
[embedtest.cc]: https://github.com/nodejs/node/blob/HEAD/test/embedding/embedtest.cc

‎doc/api/permissions.md‎

Lines changed: 133 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ The Permission Model has two operational modes:
6161

6262
When starting Node.js with `--permission`,
6363
the ability to access the file system through the `fs` module, access the network,
64-
spawn processes, use `node:worker_threads`, use native addons, use WASI, use
65-
FFI, and enable the runtime inspector will be restricted (the listener for
66-
SIGUSR1 won't be created).
64+
access environment variables, spawn processes, use `node:worker_threads`, use
65+
native addons, use WASI, use FFI, and enable the runtime inspector will be
66+
restricted (the listener for SIGUSR1 won't be created).
6767

6868
```console
6969
$ node --permission index.js
@@ -79,6 +79,8 @@ Error: Access to this API has been restricted
7979
Allowing access to spawning a process and creating worker threads can be done
8080
using the [`--allow-child-process`][] and [`--allow-worker`][] respectively.
8181

82+
To grant access to environment variables, use [`--allow-env`][].
83+
8284
To allow network access, use [`--allow-net`][] and for allowing native addons
8385
when using permission model, use the [`--allow-addons`][]
8486
flag. For WASI, use the [`--allow-wasi`][] flag. For FFI, use the
@@ -157,9 +159,9 @@ mode. Execution continues normally.
157159
Audit mode is useful for discovering what permissions your application
158160
requires before deploying with [`--permission`][]. It can also be combined
159161
with the [`--allow-fs-read`][], [`--allow-fs-write`][], [`--allow-net`][],
160-
[`--allow-child-process`][], [`--allow-worker`][], [`--allow-addons`][],
161-
[`--allow-wasi`][], and [`--allow-ffi`][] flags to audit a subset of
162-
permissions while granting others.
162+
[`--allow-env`][], [`--allow-child-process`][], [`--allow-worker`][],
163+
[`--allow-addons`][], [`--allow-wasi`][], and [`--allow-ffi`][] flags to audit
164+
a subset of permissions while granting others.
163165

164166
When a permission check fails in audit mode, a message is published to the
165167
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:
172174
* `node:permission-model:wasi` — WASI
173175
* `node:permission-model:addon` — Native Addons
174176
* `node:permission-model:ffi` — FFI
177+
* `node:permission-model:env` — Environment variables
175178

176179
Each message is an object with the following properties:
177180

@@ -266,6 +269,109 @@ both to the top-level `node:fs` functions and to the equivalent
266269
`FileHandle` methods, and currently includes `fsync`/`fdatasync`,
267270
`fchmod`, and `fchown` (and their synchronous variants).
268271

272+
#### Environment variable permissions
273+
274+
When the Permission Model is enforced, the process only has access to the
275+
environment variables that [`--allow-env`][] grants access to.
276+
277+
Instead of checking each access, Node.js removes every other variable from the
278+
process environment at startup, before any JavaScript code runs and before
279+
Node.js starts any other thread. Removed variables are absent from everything
280+
that exposes the environment of the process: `process.env`, diagnostic reports,
281+
native code calling `getenv()`, worker threads, and the environment inherited by
282+
child processes.
283+
284+
```console
285+
$ node --permission --allow-env=PORT --allow-env=APP_* index.js
286+
```
287+
288+
The valid arguments for the flag are:
289+
290+
* `*` - Grants access to every environment variable. Nothing is removed.
291+
* A variable name, such as `PORT`.
292+
* A variable name prefix followed by `*`, such as `APP_*`.
293+
294+
Some variables are always kept:
295+
296+
* The variables that Node.js and its bundled dependencies read after startup,
297+
such as `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`, `PATH`, `HOME`, `TMPDIR`, `TZ`,
298+
`LANG`, `SSL_CERT_FILE`, and the variables that terminal color detection
299+
reads. Other variables whose names start with `NODE_`, such as
300+
`NODE_AUTH_TOKEN`, are not kept.
301+
* The variables defined in the files passed to [`--env-file`][] and
302+
[`--env-file-if-exists`][]. If a variable is defined in such a file and also
303+
inherited from the parent process, and `--allow-env` does not grant access to
304+
it, the inherited value is removed and the value from the file is used.
305+
306+
`NODE_ENV` is not kept either. Node.js does not read it, but many applications
307+
and libraries do, and treat it being unset as a development environment. Grant
308+
access to it explicitly:
309+
310+
```console
311+
$ node --permission --allow-env=NODE_ENV index.js
312+
```
313+
314+
Proxy URLs often contain credentials, so the `HTTP_PROXY`, `HTTPS_PROXY`, and
315+
`NO_PROXY` variables, and their lowercase forms, are not kept. Grant access to
316+
them explicitly when using [`--use-env-proxy`][]. When `--use-env-proxy` is
317+
enabled and any of them were removed at startup, a warning naming them is
318+
emitted.
319+
320+
Native code that Node.js loads on behalf of the application, such as addons,
321+
OpenSSL providers and STORE loaders, and the libraries they load in turn, sees
322+
the same reduced environment. Only the variables that OpenSSL itself reads are
323+
kept, not those read by third-party modules it loads. For example, a PKCS#11
324+
provider backed by SoftHSM needs `SOFTHSM2_CONF` to find its token, and fails to
325+
initialize without it. Grant access to such variables explicitly:
326+
327+
```console
328+
$ node --permission --allow-openssl-store --allow-env=SOFTHSM2_CONF index.js
329+
```
330+
331+
Reading a variable that was removed at startup returns `undefined`, emits a
332+
warning the first time, and publishes a message to the
333+
`node:permission-model:env` diagnostics channel.
334+
335+
Variables set at runtime, for example with `process.env.KEY = 'value'` or
336+
[`process.loadEnvFile()`][], are not restricted, as they cannot reveal what was
337+
removed.
338+
339+
Dropping a variable with [`permission.drop()`][] removes it from the
340+
environment. Dropping the whole `env` scope removes every variable except the
341+
ones Node.js reads itself. This makes it possible to read a secret during
342+
initialization, and then remove it:
343+
344+
```js
345+
const databaseUrl = process.env.DATABASE_URL;
346+
process.permission.drop('env', 'DATABASE_URL');
347+
```
348+
349+
When a process that enforces the Permission Model spawns a child process, the
350+
child is started with `--allow-env=*`: the environment it inherits only contains
351+
variables that the parent had access to. The child can still read its own
352+
`/proc/<pid>/environ` on Linux, but not that of any other process, see below.
353+
354+
In audit mode, nothing is removed. Accesses to variables that `--allow-env`
355+
does not grant access to are published to the `node:permission-model:env`
356+
diagnostics channel instead.
357+
358+
On Linux, `/proc/<pid>/environ` exposes the environment a process was started
359+
with. When the Permission Model is enforced, reading the `/proc/<pid>/environ`
360+
file of any other process, including the parent process and its ancestors, is
361+
denied regardless of [`--allow-fs-read`][]. Reading the process's own file is
362+
only allowed with `--allow-env=*`. Symbolic links are resolved before the
363+
check, so paths that reach these files indirectly, such as
364+
`/dev/fd/../environ`, are denied as well.
365+
366+
In addition, the removed variables are overwritten in the initial environment
367+
block of the process, so that other processes do not find them in its
368+
`/proc/<pid>/environ` either. Variables removed later with
369+
[`permission.drop()`][] are overwritten there as well.
370+
371+
These measures do not change the environment of other processes. A process
372+
granted [`--allow-child-process`][] can read their environment through other
373+
programs.
374+
269375
#### Configuration file support
270376

271377
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:
297403
$ node --experimental-default-config-file app.js
298404
```
299405

406+
A configuration file, like the `NODE_OPTIONS` defined in an [`--env-file`][]
407+
file, may be controlled by the project being run rather than by whoever starts
408+
Node.js. When the command line or the `NODE_OPTIONS` environment variable
409+
enable the Permission Model, the `allow-env` values these files define can only
410+
narrow the access that [`--allow-env`][] grants, and never widen it:
411+
412+
```console
413+
$ node --permission --allow-env=APP_* --experimental-config-file=node.config.json app.js
414+
```
415+
416+
With `"allow-env": ["*"]` in `node.config.json`, only the variables starting with
417+
`APP_` are kept. With `"allow-env": ["APP_DATABASE_URL", "OTHER"]`, only
418+
`APP_DATABASE_URL` is.
419+
300420
#### Using the Permission Model with `npx`
301421

302422
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:
348468
* When using the Permission Model the following features will be restricted:
349469
* Native modules
350470
* Network
471+
* Environment variables
351472
* Child process
352473
* Worker Threads
353474
* Inspector protocol
@@ -410,15 +531,21 @@ Developers relying on --permission to sandbox untrusted code should be aware tha
410531
[Security Policy]: https://github.com/nodejs/node/blob/main/SECURITY.md
411532
[`--allow-addons`]: cli.md#--allow-addons
412533
[`--allow-child-process`]: cli.md#--allow-child-process
534+
[`--allow-env`]: cli.md#--allow-env
413535
[`--allow-ffi`]: cli.md#--allow-ffi
414536
[`--allow-fs-read`]: cli.md#--allow-fs-read
415537
[`--allow-fs-write`]: cli.md#--allow-fs-write
416538
[`--allow-net`]: cli.md#--allow-net
417539
[`--allow-openssl-store`]: cli.md#--allow-openssl-store
418540
[`--allow-wasi`]: cli.md#--allow-wasi
419541
[`--allow-worker`]: cli.md#--allow-worker
542+
[`--env-file-if-exists`]: cli.md#--env-file-if-existsfile
543+
[`--env-file`]: cli.md#--env-filefile
420544
[`--permission-audit`]: cli.md#--permission-audit
421545
[`--permission`]: cli.md#--permission
546+
[`--use-env-proxy`]: cli.md#--use-env-proxy
422547
[`crypto.createPrivateKey()`]: crypto.md#cryptocreateprivatekeykey
423548
[`npx`]: https://docs.npmjs.com/cli/commands/npx
549+
[`permission.drop()`]: process.md#processpermissiondropscope-reference
424550
[`permission.has()`]: process.md#processpermissionhasscope-reference
551+
[`process.loadEnvFile()`]: process.md#processloadenvfilepath

‎doc/api/process.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3163,6 +3163,7 @@ The available scopes are:
31633163
* `fs.read` - File System read operations
31643164
* `fs.write` - File System write operations
31653165
* `child` - Child process spawning operations
3166+
* `env` - Environment variables
31663167
* `openssl.store` - Loading keys through OpenSSL STORE loaders
31673168
* `worker` - Worker thread spawning operation
31683169
* `ffi` - Foreign function interface operations
@@ -3220,6 +3221,8 @@ The available scopes are the same as [`process.permission.has()`][]:
32203221
* `fs.read` - File System read operations
32213222
* `fs.write` - File System write operations
32223223
* `child` - Child process spawning operations
3224+
* `env` - Environment variables. Dropping a variable removes it from the
3225+
environment
32233226
* `openssl.store` - Loading keys through OpenSSL STORE loaders
32243227
* `worker` - Worker thread spawning operation
32253228
* `net` - Network operations

0 commit comments

Comments
 (0)