Github  Printable

Dynamism when you need it

Background

Node.js code is composed of CommonJS modules that are linked together by the builtin require function, or import statements (used by TypeScript) that typically transpile to require (modulo experimental features).

require itself calls Module._load (code) to resolve and load code. "The Node.js Way" explains this flow well.

Unlike import, require is dynamic: a runtime value can specify the name of a module to load. (The EcmaScript committee is considering a dynamic import operator, but we have not included that in this analysis.)

This dynamism is powerful and flexible and enables varied use cases like the following:

  • Lazy loading. Waiting to load a dependency until it is definitely needed.
    const infrequentlyUsedAPI = (function () {
      const dependency = require('dependency');
      return function infrequentlyUsedAPI() {
        // Use dependency
      };
    }());
    
  • Loading plugins based on a configuration object.
    function Service(config) {
      (config.plugins || []).forEach(
          (pluginName) => {
            require(pluginName).initPlugin(this);
          });
    }
    
  • Falling back to an alternate service provider if the first choice isn't available:
    const KNOWN_SERVICE_PROVIDERS = ['foo-widget', 'bar-widget'];
    const serviceProviderName = KNOWN_SERVICE_PROVIDERS.find(
       (name) => {
         try {
           require.resolve(name);
           return true;
         } catch (_) {
           return false;
         }
       });
    const serviceProvider = require(serviceProviderName);
    
  • Taking advantage of an optional dependency when it is available.
    let optionalDependency = null;
    try {
      optionalDependency = require('optionalDependency');
    } catch (_) {
      // Oh well.
    }
    
  • Loading a handler for a runtime value based on a naming convention.
    function handle(request) {
      const handlerName = request.type + '-handler';  // Documented convention
      let handler;
      try {
        handler = require(handlerName);
      } catch (e) {
        throw new Error(
            'Expected handler ' + handlerName
            + ' for requests with type ' + request.type);
      }
      return handler.handle(request);
    }
    
  • Introspecting over module metadata.
    const version = require('./package.json').version;
    

During rapid development, file-system monitors can restart a node project when source files change, and the application stitches itself together without the complex compiler and build system integration that statically compiled languages use to do incremental recompilation.

Problem

Threats: DEX RCE UIR

The node_modules directory does not keep production code separate from test code. If test code can be required in production, then an attacker may find it far easier to execute a wide variety of other attacks. See UIR for more details on this.

Node applications rely on dynamic uses of require and changes that break any of these use cases would require coordinating large scale changes to existing code, tools, and development practices threatening developer experience.

Requiring developers to pick and choose which source files are production and which are test would either:

  • Require them to scrutinize source files not only for their project but also for deep dependencies with which they are unfamiliar leading to poor developer experience.
  • Whitelist without scrutiny leading to the original security problem.
  • Lead them to not use available modules to solve problems and instead roll their own leading to poor developer experience, and possibly LQC problems.

We need to ensure that only source code written with production constraints in mind loads in production without increasing the burden on developers.

When the behavior of code in production is markedly different from that on a developer's workstation, developers lose confidence that they can avoid bugs in production by testing locally which may lead to poor developer experience and lower quality code.

Success Criteria

We would have prevented abuse of require if:

  • Untrusted inputs could not cause require to load a non-production source file,
  • and/or no non-production source files are reachable by require,
  • and/or loading a non-production source file has no adverse effect.

We would have successfully prevented abuse of eval, new Function and related operators if:

  • Untrusted inputs cannot reach an eval operator,
  • and/or untrusted inputs that reach them cause no adverse affects,
  • and/or security specialists could whitelist uses of eval operators that are necessary for the functioning of the larger system and compatible with the system's security goals.

In both cases, converting dynamic operators to static before untrusted inputs reach the system reduces the attack surface. Requiring large-scale changes to existing npm modules or requiring large scale rewrites of code that uses using them constitutes compromises DEX.

Current practices

Some development teams use webpack or similar tools to statically bundle server-side modules, and provide flexible transpilation pipelines. That's a great way to do things, but solving security problems only for teams with development practices mature enough to deploy via webpack risks preaching to the choir.

Webpack, in its minimal configuration, does not attempt to skip test files (code). Teams with an experienced webpack user can use it to great effect, but it is not an out-of-the-box solution.

Webpacking does not prevent calls to require(...) with unintended arguments, but greatly reduces the chance that they will load non-production code. As long as the server process cannot read JS files other than those in the bundle, then a webpacked server is safe from UIR. This may not be the case if the production machine has npm modules globally installed, and the server process is not running in a chroot jail.

A Possible Solution

We present one possible solution to demonstrate that tackling this problem is feasible.

If we can compute the entire set of require-able sources when dealing only with inputs from trusted sources, then we can ensure that the node runtime only loads those sources even when exposed to untrusted inputs.

We propose these changes:

  • A two phase approach to prevent abuse of require.
    1. Tweaks to the node module loader that make it easy to dynamically bundle a release candidate.
    2. Tweaks to the node module loader in production to restrict code loads based on source content hashes from the bundling phase.
  • Two different strategies for preventing abuse of eval.
    • JavaScript idioms that can allow many uses of eval to load as modules and to bundle as above.
    • Using JavaScript engine callbacks to allow uses of eval by approved modules.

results matching ""

    No results matching ""