Node.js Security Best Practices

Intent

This document intends to extend the current threat model and provide extensive guidelines on how to secure a Node.js application.

Document Content

  • Best practices: A simplified condensed way to see the best practices. We can use this issue or this guideline as the starting point. It is important to note that this document is specific to Node.js, if you are looking for something broad, consider OSSF Best Practices.
  • Attacks explained: illustrate and document in plain English with some code example (if possible) the attacks that we are mentioning in the threat model.
  • Third-Party Libraries: define threats (typosquatting attacks, malicious packages...) and best practices regarding node modules dependencies, etc...

Threat List

Denial of Service of HTTP server (CWE-400)

This is an attack where the application becomes unavailable for the purpose it was designed due to the way it processes incoming HTTP requests. These requests need not be deliberately crafted by a malicious actor: a misconfigured or buggy client can also send a pattern of requests to the server that result in a denial of service.

HTTP requests are received by the Node.js HTTP server and handed over to the application code via the registered request handler. The server does not parse the content of the request body. Therefore any DoS caused by the contents of the body after they are handed over to the request handler is not a vulnerability in Node.js itself, since it's the responsibility of the application code to handle it correctly.

Ensure that the WebServer handle socket errors properly, for instance, when a server is created without a error handling, it will be vulnerable to DoS

const net = require('net');
 
const server = net.createServer(function (socket) {
  // socket.on('error', console.error) // this prevents the server to crash
  socket.write('Echo server\r\n');
  socket.pipe(socket);
});
 
server.listen(5000, '0.0.0.0');

If a bad request is performed the server could crash.

An example of a DoS attack that is not caused by the request's contents is Slowloris. In this attack, HTTP requests are sent slowly and fragmented, one fragment at a time. Until the full request is delivered, the server will keep resources dedicated to the ongoing request. If enough of these requests are sent at the same time, the amount of concurrent connections will soon reach its maximum resulting in a denial of service. This is how the attack depends not on the request's contents, but on the timing and pattern of the requests being sent to the server.

Mitigations

  • Use a reverse proxy to receive and forward requests to the Node.js application. Reverse proxies can provide caching, load balancing, IP blacklisting, etc. which reduce the probability of a DoS attack being effective.
  • Correctly configure the server timeouts, so that connections that are idle or where requests are arriving too slowly can be dropped. See the different timeouts in http.Server, particularly headersTimeout, requestTimeout, timeout, and keepAliveTimeout.
  • Limit the number of open sockets per host and in total. See the http docs, particularly agent.maxSockets, agent.maxTotalSockets, agent.maxFreeSockets and server.maxRequestsPerSocket.

DNS Rebinding (CWE-346)

This is an attack that can target Node.js applications being run with the debugging inspector enabled using the --inspect switch.

Since websites opened in a web browser can make WebSocket and HTTP requests, they can target the debugging inspector running locally. This is usually prevented by the same-origin policy implemented by modern browsers, which forbids scripts from reaching resources from different origins (meaning a malicious website cannot read data requested from a local IP address).

However, through DNS rebinding, an attacker can temporarily control the origin for their requests so that they seem to originate from a local IP address. This is done by controlling both a website and the DNS server used to resolve its IP address. See DNS Rebinding wiki for more details.

Mitigations

  • Disable inspector on SIGUSR1 signal by attaching a process.on(‘SIGUSR1’, …) listener to it.
  • Do not run the inspector protocol in production.

Exposure of Sensitive Information to an Unauthorized Actor (CWE-552)

All the files and folders included in the current directory are pushed to the npm registry during the package publication.

There are some mechanism to control this behavior by defining a blocklist with .npmignore and .gitignore or by defining an allowlist in the package.json

Mitigations

  • Using npm publish --dry-run list all the files to publish. Ensure to review the content before publishing the package.
  • It’s also important to create and maintain ignore files such as .gitignore and .npmignore. Throughout these files, you can specify which files/folders should not be published. The files property in package.json allows the inverse operation -- allowed list.
  • In case of an exposure, make sure to unpublish the package.

HTTP Request Smuggling (CWE-444)

This is an attack that involves two HTTP servers (usually a proxy and a Node.js application). A client sends an HTTP request that goes first through the front-end server (the proxy) and then is redirected to the back-end server (the application). When the front-end and back-end interpret ambiguous HTTP requests differently, there is potential for an attacker to send a malicious message that won't be seen by the front-end but will be seen by the back-end, effectively "smuggling" it past the proxy server.

See the CWE-444 for a more detailed description and examples.

Due to the fact that this attack depends on Node.js interpreting HTTP requests differently from an (arbitrary) HTTP server, a successful attack can be due to a vulnerability in Node.js, the front-end server, or both. If the way the request is interpreted by Node.js is consistent with the HTTP specification (see RFC7230), then it is not considered a vulnerability in Node.js.

Mitigations

  • Do not use the insecureHTTPParser option when creating a HTTP Server.
  • Configure the front-end server to normalize ambiguous requests.
  • Continuously monitor for new HTTP request smuggling vulnerabilities in both Node.js and the front-end server of choice.
  • Use HTTP/2 end to end and disable HTTP downgrading if possible.

Information Exposure through Timing Attacks (CWE-208)

This is an attack that allows the attacker to learn potentially sensitive information by, for example, measuring how long it takes for the application to respond to a request. This attack is not specific to Node.js and can target almost all runtimes.

The attack is possible whenever the application uses a secret in a timing-sensitive operation (e.g., branch). Consider handling authentication in typical application. Here, a basic authentication method includes email and password as credentials. User information is retrieved from the input user has supplied from ideally a DBMS. Upon retrieving user information, the password is compared within the user information retrieved from the database. Using the built-in string comparison takes a longer time for the same length values. This comparison, when run for an acceptable amount unwillingly increases the response time of the request. By comparing the request response times, an attacker can guess the length and the value of the password in large quantity of requests.

Mitigations

  • The crypto API exposes a function timingSafeEqual to compare actual and expected sensitive values using a constant-time algorithm.

  • For password comparison, you can use the scrypt available also on the native crypto module.

  • More generally, avoid using secrets in variable-time operations. This includes branching on secrets and, when the attacker could be co-located on the same infrastructure (e.g., same cloud machine), using a secret as an index into memory. Writing constant-time code in JavaScript is hard (partly because of the JIT). For crypto applications, use the built-in crypto APIs or WebAssembly (for algorithms not implemented in natively).

Malicious Third-Party Modules (CWE-1357)

Currently, in Node.js, any package can access powerful resources such as network access. Furthermore, because they also have access to the file system, they can send any data to anywhere.

All code running into a node process has the ability to load and run additional arbitrary code by using eval()(or its equivalents). All code with file system write access may achieve the same thing by writing to new or existing files which are loaded.

Node.js has an experimental¹ policy mechanism to declare the loaded resource as untrusted or trusted. However, this policy is not enabled by default. Be sure to pin dependency versions and run automatic checks for vulnerabilities using common workflows or npm scripts. Before installing a package make sure that this package is maintained and includes all the content you expected in. Be careful, the Github source code is not always the same as the published one, validate it in the node_modules.

Supply chain attacks

A supply chain attack on a Node.js application happens when one of its dependencies (either direct or transitive) is compromised. This can happen either due to the application being too lax on the specification of the dependencies (allowing for unwanted updates) and/or common typos in the specification (vulnerable to typosquatting).

An attacker that takes control of an upstream package can publish a new version with malicious code in it. If a Node.js application depends on that package without being strict on which version is safe to use, the package can be automatically updated to the latest malicious version, compromising the application.

Dependencies specified in the package.json file can have an exact version number or a range. However, when pinning a dependency to an exact version, its transitive dependencies are not themselves pinned. This still leaves the application vulnerable to unwanted/unexpected updates.

Possible attack vectors:

  • Typosquatting attacks
  • Lockfile poisoning
  • Compromised maintainers
  • Malicious Packages
  • Dependency Confusions

Mitigations

  • Prevent npm from executing arbitrary scripts with --ignore-scripts
    • Additionally, you can disable it globally with npm config set ignore-scripts true
  • Pin dependency versions to a specific immutable version, not a version that is a range or from a mutable source.
  • Use lockfiles, which pin every dependency (direct and transitive).
  • Automate checks for new vulnerabilities using CI, with tools like [npm-audit][].
    • Tools such as Socket can be used to analyze packages with static analysis to find risky behaviors such as network or filesystem access.
  • Use npm ci instead of npm install. This enforces the lockfile so that inconsistencies between it and the package.json file cause an error (instead of silently ignoring the lockfile in favor of package.json).
  • Carefully check the package.json file for errors/typos in the names of the dependencies.

Memory Access Violation (CWE-284)

Memory-based or heap-based attacks depends on a combination of memory management errors and an exploitable memory allocator. Like all runtimes, Node.js is vulnerable to these attacks if your projects runs on a shared machine. Using a secure heap is useful for preventing sensitive information from leaking due to pointer overruns and underruns.

Unfortunately, secure heap is not available on Windows. More information can be found on Node.js secure-heap documentation.

Mitigations

  • Use --secure-heap=n depending on your application where n is the allocated maximum byte size.
  • Do not run your production app in a shared machine.

Monkey Patching (CWE-349)

Monkey patching refers to the modification of properties in runtime aiming to change the existing behavior. Example:

// eslint-disable-next-line no-extend-native
Array.prototype.push = function (item) {
  // overriding the global [].push
};

Mitigations

The --frozen-intrinsics flag enables experimental¹ frozen intrinsics, which means all the built-in JavaScript objects and functions are recursively frozen. Therefore, the following snippet will not override the default behavior of Array.prototype.push

// eslint-disable-next-line no-extend-native
Array.prototype.push = function (item) {
  // overriding the global [].push
};
 
// Uncaught:
// TypeError <Object <Object <[Object: null prototype] {}>>>:
// Cannot assign to read only property 'push' of object ''

However, it’s important to mention you can still define new globals and replace existing globals using globalThis

> globalThis.foo = 3; foo; // you can still define new globals
3
> globalThis.Array = 4; Array; // However, you can also replace existing globals
4

Therefore, Object.freeze(globalThis) can be used to guarantee no globals will be replaced.

Prototype Pollution Attacks (CWE-1321)

Prototype pollution refers to the possibility to modify or inject properties into Javascript language items by abusing the usage of __proto_, _constructor, prototype, and other properties inherited from built-in prototypes.

const a = { a: 1, b: 2 };
const data = JSON.parse('{"__proto__": { "polluted": true}}');
 
const c = Object.assign({}, a, data);
console.log(c.polluted); // true
 
// Potential DoS
const data2 = JSON.parse('{"__proto__": null}');
const d = Object.assign(a, data2);
d.hasOwnProperty('b'); // Uncaught TypeError: d.hasOwnProperty is not a function

This is a potential vulnerability inherited from the JavaScript language.

Examples:

Mitigations

  • Avoid insecure recursive merges, see CVE-2018-16487.
  • Implement JSON Schema validations for external/untrusted requests.
  • Create Objects without prototype by using Object.create(null).
  • Freezing the prototype: Object.freeze(MyObject.prototype).
  • Disable the Object.prototype.__proto__ property using --disable-proto flag.
  • Check that the property exists directly on the object, not from the prototype using Object.hasOwn(obj, keyFromObj).
  • Avoid using methods from Object.prototype.

Uncontrolled Search Path Element (CWE-427)

Node.js loads modules following the Module Resolution Algorithm. Therefore, it assumes the directory in which a module is requested (require) is trusted.

By that, it means the following application behavior is expected. Assuming the following directory structure:

  • app/
    • server.js
    • auth.js
    • auth

If server.js uses require('./auth') it will follow the module resolution algorithm and load auth instead of auth.js.

Mitigations

Using the experimental¹ policy mechanism with integrity checking can avoid the above threat. For the directory described above, one can use the following policy.json

{
  "resources": {
    "./app/auth.js": {
      "integrity": "sha256-iuGZ6SFVFpMuHUcJciQTIKpIyaQVigMZlvg9Lx66HV8="
    },
    "./app/server.js": {
      "dependencies": {
        "./auth": "./app/auth.js"
      },
      "integrity": "sha256-NPtLCQ0ntPPWgfVEgX46ryTNpdvTWdQPoZO3kHo0bKI="
    }
  }
}

Therefore, when requiring the auth module, the system will validate the integrity and throw an error if doesn’t match the expected one.

» node --experimental-policy=policy.json app/server.js
node:internal/policy/sri:65
      throw new ERR_SRI_PARSE(str, str[prevIndex], prevIndex);
      ^
 
SyntaxError [ERR_SRI_PARSE]: Subresource Integrity string "sha256-iuGZ6SFVFpMuHUcJciQTIKpIyaQVigMZlvg9Lx66HV8=%" had an unexpected "%" at position 51
    at new NodeError (node:internal/errors:393:5)
    at Object.parse (node:internal/policy/sri:65:13)
    at processEntry (node:internal/policy/manifest:581:38)
    at Manifest.assertIntegrity (node:internal/policy/manifest:588:32)
    at Module._compile (node:internal/modules/cjs/loader:1119:21)
    at Module._extensions..js (node:internal/modules/cjs/loader:1213:10)
    at Module.load (node:internal/modules/cjs/loader:1037:32)
    at Module._load (node:internal/modules/cjs/loader:878:12)
    at Module.require (node:internal/modules/cjs/loader:1061:19)
    at require (node:internal/modules/cjs/helpers:99:18) {
  code: 'ERR_SRI_PARSE'
}

Note, it's always recommended the use of --policy-integrity to avoid policy mutations.

Experimental Features in Production

The use of experimental features in production isn't recommended. Experimental features can suffer breaking changes if needed, and their functionality isn't securely stable. Although, feedback is highly appreciated.

OpenSSF Tools

The OpenSSF is leading several initiatives that can be very useful, especially if you plan to publish an npm package. These initiatives include:

  • OpenSSF Scorecard Scorecard evaluates open source projects using a series of automated security risk checks. You can use it to proactively assess vulnerabilities and dependencies in your code base and make informed decisions about accepting vulnerabilities.
  • OpenSSF Best Practices Badge Program Projects can voluntarily self-certify by describing how they comply with each best practice. This will generate a badge that can be added to the project.
맨 위로