Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

module: check env NODE_PRELOAD for preload modules #11888

Closed
wants to merge 1 commit into from

Conversation

jchip
Copy link

@jchip jchip commented Mar 17, 2017

Adds a new feature that checks the environment variable NODE_PRELOAD
that should be a : (or ; on windows) delimited string of modules inside
the global node_modules dir to preload, the same as those that can be
specified by the -r command line option except this is limited to
global node_modules.

Each module can be the absolute path of the module or just the name
of the module under the global node_modules dir.

Implements #11853

Checklist
  • make -j4 test (UNIX), or vcbuild test (Windows) passes
  • tests and/or benchmarks are included
  • documentation is changed or added
  • commit message follows commit guidelines
Affected core subsystem(s)

@nodejs-github-bot nodejs-github-bot added the lib / src Issues and PRs related to general changes in the lib or src directory. label Mar 17, 2017
@jchip jchip force-pushed the master branch 4 times, most recently from 2a5a163 to 89fba41 Compare March 17, 2017 05:36
@Trott Trott added the semver-minor PRs that contain new features and should be released in the next minor version. label Mar 17, 2017
@jasnell
Copy link
Member

jasnell commented Mar 17, 2017

@nodejs/ctc ... thoughts on this?

@bnoordhuis
Copy link
Member

I've said before that I'm not a fan of environment variables and I'm certainly no fan of environment variables that let you load arbitrary files. It would be a security disaster with a node binary that is setuid root.

Copy link
Contributor

@cjihrig cjihrig left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would also require documentation.

@@ -394,8 +394,15 @@

// Load preload modules
function preloadModules() {
if (process._preload_modules) {
NativeModule.require('module')._preloadModules(process._preload_modules);
let preloads = process._preload_modules;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use var here.

});

// test NODE_REQUIRE with multiple modules works
process.env.NODE_REQUIRE = fixtureA + path.delimiter + fixtureB;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a test where there is whitespace surrounding the delimiter?

childProcess.exec(
nodeBinary + ' ' + fixtureB,
function(err, stdout, stderr) {
if (err) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of an if statement, can we just make assertions on err?

@jchip
Copy link
Author

jchip commented Mar 17, 2017

@bnoordhuis how about checking setuid?

    let isSetUidRoot = false;
    if (process.platform !== "win32") {
      isSetUidRoot = process.getuid() !== process.geteuid() || 
        process.getgid() !== process.getegid();
    }

    if (!isSetUidRoot && process.env.NODE_REQUIRE) {
      const d = NativeModule.require('path').delimiter;
      preloads = (preloads || []).concat(process.env.NODE_REQUIRE.split(d));
    }

@jchip
Copy link
Author

jchip commented Mar 17, 2017

@cjihrig I wasn't sure whether doc should be included in this PR or in a separate PR. There is a YAML comment in the doc that indicates what version a feature was added. Should I just omit that when adding the docs?

@jasnell
Copy link
Member

jasnell commented Mar 17, 2017

I can't say that I'm a big fan of this idea either, for the same reasons as @bnoordhuis. There are fairly significant security ramifications should a malicious app successfully manage to manipulate the environment variables on a system.

There are a couple of ways to protect it -- for instance, we could check NODE_REQUIRE only if the -r command line argument is used without it's own value. That way it's an opt in but it kind of defeats the purpose of the env var in the first place.

Checking the uid is only a partial fix in that it protects against the root case but still puts users at risk. For instance, imagine an electron app that picks this up. A malicious app could theoretically inject malicious spyware code into the electron app by setting the env.

@jchip
Copy link
Author

jchip commented Mar 17, 2017

I am not 100% clear on the malicious app scenario yet, but I imagine that if an app is able to sneak in and inject a malicious code in your FS somewhere and set an env, it can do its bidding w/o requiring another JS app to execute its code?

@jchip
Copy link
Author

jchip commented Mar 17, 2017

Some other idea: to further protect this, maybe only allow modules that's been npm install -g, so only if path is under <node binary location>/../../lib/node_modules.

@jasnell
Copy link
Member

jasnell commented Mar 17, 2017

Manipulating the environment variables for a given user is a fairly trivial attack vector for a malicious bit of code and can be done with a minimal level of permissions on the system, unfortunately. It's common enough to even have it's own CAPEC classification. The basic idea is that malicious code running with lesser privileges can easily modify the environment variables depended on by code with higher privileges and higher sensitivity.

@jchip
Copy link
Author

jchip commented Mar 17, 2017

With shell, unless I source a script, most apps can't affect parent env but its own env copy only.
So to affect a user's env in a persistent manner, an app would have to modify the shell's RC file?
What other way can lesser privilege code affect my system's env in a persistent and global manner?

@jasnell
Copy link
Member

jasnell commented Mar 17, 2017

There are a number of proofs of concept that can be found via Google. Here's a quick read that explains a few such mechanisms: https://breakingmalware.com/vulnerabilities/elastic-boundaries-elevating-privileges-by-environment-variables-expansion/

@jchip
Copy link
Author

jchip commented Mar 17, 2017

Thanks for the link. Interesting read.

However, it also confirms the same question I was asking. Its scenarios 1-3 are all about the "malicious app" modifying its own env, and the last 2 scenarios is about windows specific UAC that elevates malicious code to admin priv, which then can do almost anything it want, including modifying persistent global env.

I understand the concern. Is limiting the scope of the module to the node binary's global install OK?

@jasnell
Copy link
Member

jasnell commented Mar 17, 2017

Not sure. Stewing over it a bit more. Also thinking if there's another way to tackle the basic requirement.

@evanlucas
Copy link
Contributor

I'm leaning towards -1 on this. Not sure if it is worth the risk.

@jchip
Copy link
Author

jchip commented Mar 17, 2017

OK, thanks. I was thinking limiting to node binary's global install, because if any malicious code can get code in there, you are pretty toasted anyways. Imagine someone just modify npm's code.

@ofrobots
Copy link
Contributor

Previous discussion: #881.

@Fishrock123
Copy link
Contributor

I agree with @bnoordhuis & @jasnell - not a fan of the idea. :/

@jchip
Copy link
Author

jchip commented Mar 17, 2017

@ofrobots 😄

Anyways, this is what I am playing with, I think limiting to the global installed node_modules is a reasonable measure to address security concerns?

  function preloadModules() {
    var preloads = process._preload_modules;
    var isSetUidRoot = false;

    if (process.platform !== "win32") {
      isSetUidRoot = process.getuid() !== process.geteuid() || 
        process.getgid() !== process.getegid();
    }

    if (!isSetUidRoot && process.env.NODE_REQUIRE) {
      const path = NativeModule.require('path');
      const nodeRequires = process.env.NODE_REQUIRE.split(path.delimiter);
      const globalNm = path.join(process.argv[0], "../../lib/node_modules");
      preloads = (preloads || []).concat(nodeRequires.filter(function (n) {
        return n.startsWith(globalNm);
      }));
    }

    if (preloads) {
      NativeModule.require('module')._preloadModules(preloads);
    }
  }

@jasnell
Copy link
Member

jasnell commented Mar 17, 2017

There's potentially a different approach that could be taken here. I'm not convinced that it's a good idea, but it's possible. First, however, I have to say that I do not like the idea of preloaded modules being applied automatically without an explicit opt-in. There is way too much opportunity for abuse.

That said, we could potentially have something like ~/.node_preload directory that contains zero or more modules intended to be preloaded. For instance:

~/.node_preload/foo.js
~/.node_preload/bar/index.js
~/.node_preload/bar/package.json

On POSIX systems, this directory should be owner R/W only (e.g. 0600).

Then when the -r command line argument is used with a specific value (e.g. -r -) all of the modules in the users preload directory would be preloaded.

  • This addresses the setuid root issue because only modules in the /root/.node_preload directory would be preloaded.
  • This simplifies the use of -r by eliminating the need to pass in each preload module
  • The child_process fork method could potentially be modified to inherit the -r argument when used in this way to allow those to be inherited by children.

As I'm writing this up, we could potentially include support for ./node_modules/.preload/* to allow dependencies to install their own preload hooks using the same basic mechanism.

Again, this is just a brainstorm. I'm no where near convinced that this is something we should do.

@jchip
Copy link
Author

jchip commented Mar 17, 2017

@jasnell Thanks for the ideas. I was contemplating with quite a few things including something like ~/.node_preload.

I even did this: (placing the following script in /usr/local/bin/node and modifying PATH to point to that only). However, I found that some JS app that invokes node again somehow probably uses process.argv[0] to find node's binary instead and it's by passing my script.

#!/bin/bash
real_node_bin=$(which -a node | grep -v "$0" | head -1)
$real_node_bin -r /usr/local/lib/node/preload.js $*

My goal is just a persistent cross invocation -r.

Regarding security concerns, I am all for that. In that regard, then even ~/.node_preload is susceptible because malicious app could inject or modify code there.

I think limiting scope to global node_modules is fairly safe, because if any malicious code can modify that, then all is lost, because nothing is stopping them from modifying npm's code, or even just replace node's binary with a wrapper script, never mind env var.

@jchip
Copy link
Author

jchip commented Mar 17, 2017

Another thought, any attack that's able to gain enough priv to modify env globally to make this an issue, they can just change PATH to point to another malicious node wrapper script.

@jasnell
Copy link
Member

jasnell commented Mar 17, 2017

@jchip ... one way to play around with various scenarios here would be to create a wrapper script for the node binary that does basically what you want but shells out to the appropriate node -r whatever to actually launch. Try a number of different configurations to see which approach works the best as far as limiting the security risks.

@jchip
Copy link
Author

jchip commented Mar 17, 2017

I think for any attack to exploit something, the first requirement is to get the malicious code to the system first, in memory or FS.

So regardless of how we specify what to preload, a malicious code has to be installed first.

If we depend on something like ~/.node_preload/*, or ~/.node_preloadrc, in which we specify the list of preload modules, instead of an env var, in some way, I think it's easier for an attack to exploit that than env var, because it's likely you need less priv to modify a file than to modify env globally.

So I think limiting to global node_modules is the most reasonable to safe guard against malicious code getting into the system in the first place.

I understand that doing this is in generally not something great, but given that the -r option was accepted I guess there is some consensus that preload is OK.

It'd be nice if the preload always work cross invocations though and I am trying to address that.

@jchip
Copy link
Author

jchip commented Mar 17, 2017

I am going to update the PR with the things I've mentioned:

  • rename env var to NODE_PRELOAD base on previous discussions
  • limit NODE_PRELOAD modules to global node_modules only.
  • limit NODE_PRELOAD to non setuid root on non-win32 platforms.

I hope this will give everyone something concrete to ponder over. I hope we can come to something that's OK with everyone to support cross invocation preloads.

@jchip jchip changed the title module: check env NODE_REQUIRE for preload modules module: check env NODE_PRELOAD for preload modules Mar 17, 2017
@jchip
Copy link
Author

jchip commented Mar 17, 2017

also, if env var is generally frown upon for everyone, as I mentioned, we can have ~/.node_preloadrc that's a text file with modules to preload. However, to me, that actually requires less priv to mess with than env var.

@jchip jchip force-pushed the master branch 5 times, most recently from 3193ec1 to be56876 Compare March 17, 2017 22:35
Adds a new feature that checks the environment variable NODE_PRELOAD
that should be a : (or ; on windows) delimited string of modules inside
the global node_modules dir to preload, the same as those that can be
specified by the -r command line option except this is limited to
global node_modules. 

Each module can be the absolute path of the module or just the name
of the module under the global node_modules dir.

Implements: nodejs#11853
if (process._preload_modules) {
NativeModule.require('module')._preloadModules(process._preload_modules);
var allPreloads = process._preload_modules;
var isSetUidRoot = false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is going to happen, this var should be called isSetUid

@addaleax
Copy link
Member

I can’t say I haven’t wished for this to exist, but I’m not too excited about this either :/

var isSetUidRoot = false;

if (process.platform !== 'win32') {
isSetUidRoot = process.getuid() !== process.geteuid() ||
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why aren't you using SafeGetenv()?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure how. Can you tell me please?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look for how OPENSSL_CONF is loaded from env, in such a way that its ignored if the IDs aren't safe, much as you are manually trying to check here.

}
};

const globalNm = path.join(process.argv[0], '../../lib/node_modules/');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't make any sense to disallow requiring a packages own dependencies, please remove this. Resolution should be identical to -r, obscure special cases decrease security, not increase (and control over environment and fs as this check assumes is complete control of an executable already via LD_PRELOAD).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment I am waiting for an approach that everyone can at least not put a -1 on first. If @nodejs/ctc have an approach that's acceptable, I'd implement that.

@sam-github
Copy link
Contributor

I generally support NODEOPT, not a env var per command line flag, but the functionality this adds is critical, it will be very important to be able to do NODE_PRELOAD=node-report on a misbehaving docker container (for example).

@gibfahn
Copy link
Member

gibfahn commented Mar 22, 2017

@sam-github if there were to be a NODEOPT env var, what would it look like? Would you have NODEOPT="-r node-report?

Also is there an issue to discuss adding such an environment variable? I'd be +1 on that. If we're going to have variables, let's just have one and keep it simple.

@sam-github
Copy link
Contributor

@gibfahn conversation moved to #11997, I'm sure you saw, and previous conversation was at #881 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
lib / src Issues and PRs related to general changes in the lib or src directory. semver-minor PRs that contain new features and should be released in the next minor version.
Projects
None yet
Development

Successfully merging this pull request may close these issues.