Node.js reference architecture

Making your Node.js applications secure is an essential part of the development of Node.js modules and applications. Security practices apply to both the code itself and your software development process. This installment of the ongoing Node.js reference architecture series focuses on some of the key security elements that JavaScript developers should address.

Follow the series:

    This article covers eight key elements of building security into your software development process to make your Node.js applications and modules robust:

    1. Choosing dependencies
    2. Managing access and content of public and private data stores such as npm and GitHub
    3. Writing defensive code
    4. Limiting required execution privileges
    5. Support for logging and monitoring
    6. Externalizing secrets
    7. Maintaining a secure and up-to-date foundation for deployed applications
    8. Maintaining individual modules

    Although this is not necessarily an exhaustive list, these are commonly the focus of the Red Hat and IBM teams.

    1. Choosing third-party dependencies

    Most Node.js applications and modules have third-party dependencies, many of which contain security vulnerabilities. Although open source teams usually fix the vulnerabilities soon after discovery, there are still gaps in time before an application developer learns about the vulnerability and puts the fixed library into production. Attackers might exploit the compromised program during those times. So it is important to choose dependencies carefully and regularly evaluate if they remain the right choices for you.

    A couple of helpful tips in this area are:

    • Determine that a dependency is necessary before integrating it into your application. Is using the modules instead of your code saving development and maintenance time?
    • Avoid code one-liners.
    • If you have a choice of dependencies, use one that has only a few or no dependencies of its own.
    • Choose dependencies that already have a high level of usage based on statistics, such as GitHub stars and npm. These tend to be maintained well.

    Find more in-depth guidance on managing dependencies in the reference architecture's choosing and vetting dependencies section.

    2. Managing access and content of public and private data stores

    Modern development flows often use public and private data stores, including npm and GitHub. We recommend the following management practices:

    • Enable two-factor authentication (2FA) to ensure the integrity of the committed code and published assets. GitHub, for instance, now requires a developer who logs in to verify their identity through a code sent to their device.
    • Use files such as .npmignore and .gitignore to avoid accidentally publishing secrets. These are hidden files consulted by programs (npm and Git, respectively). If you list a file with your secrets in one of these hidden files, npm and Git will never check it into the source repository. Of course, you must have a separate process to manage the secrets. There are many services available to help you.

    A .npmrc file is often needed for npm installations, particularly if you have private modules. Avoid leaking information in the .npmrc file when building containers by using one of these options:

    • Use two-stage builds, where you build one image with all the tools for the application and a second to create a stripped-down image. In addition to saving memory and disk space, the two-stage build allows you to omit the .npmrc file from the final image that goes into production.
    • Avoid adding the secrets to any image in the build process. Instead, you can securely mount secrets into containers during the build process, as explained in the article How to sneak secrets into your containers. In particular, Buildah has built-in functions to make it easier to mount files with secrets.
    • The least preferred method:  Delete the .npmrc file from the final image and compress images to flatten layers.

    3. Writing defensive code

    Secure coding often calls for special training and cannot be summarized in simple precepts. Nevertheless, you can eliminate many common vulnerabilities by following the recommendations in this section. There is a more extensive list in the Secure Development Process section of the reference architecture.

    Avoid global state

    Using global variables makes it easy to leak information between requests accidentally. With global variables, data from one web visitor might be in memory when a second visitor sends a request. Potential impacts include corrupting the request or revealing private information to another visitor.

    Each request should encapsulate its data. If you need global data, such as statistics about the traffic you are handling, store it in an external database. This solution is preferable to global variables because the data in the database is persistent.

    Set the NODE_ENV environment variable to production

    Some packages consult the NODE_ENV environment variable to decide whether they need to lock things down or share less information. Therefore, setting the variable to production is the safest setting and should be used all the time. The application developer, not the package, should determine what information to display.

    Validate user input

    Unvalidated input can result in attacks such as command injection, SQL injection, and denial of service, disrupting your service and corrupting data. Always validate user input before implementing it within your application code. Make sure you validate input on the server even if you validate on the client side (browser or mobile application) because an attacker could send requests directly to the APIs without using the client.

    Include good exception handling

    Basic practices for handling exceptions include:

    • Check at a high level for missed exceptions and handle them gracefully. Make sure to have a default handler for Express and other web frameworks to avoid displaying errors with the stack trace to the visitor.
    • Listen to errors when using EventEmitters.
    • Check for errors passed into asynchronous calls.

    Avoid complex regular expressions

    Regular expressions help with text parsing tasks, such as ensuring that a visitor submitted their email address or phone number in an acceptable format or checking input for suspicious characters that could signal an attack. Unfortunately, if a regular expression is complex, it can take a long time to run. In fact, some regexes run essentially forever on certain kinds of text.

    Even worse, although your regular expression might operate reasonably under most input, a malicious attacker could provide content that triggers an endless run. The article Regular expression Denial of Service - ReDoS explains this type of vulnerability.

    The takeaway is to be careful about the complexity of any regular expression you use.  When checking text input, avoid regular expressions or use only simple ones that check for issues such as invalid characters.

    Limit the attack surface

    Some helpful ways to limit the available attack surface are:

    • Expose only the APIs needed to support the intended operations. For example, when using Express, remove any unnecessary routes.
    • Group all external endpoints under a prefix (i.e., /api). This makes it easier to expose only APIs intended to be external in the ingress configuration.
    • Don't rewrite paths to the root (/).
    • Use authentication to limit access. When possible, integrate an organizational identity and access control provider instead of implementing your own.

    4. Limiting required execution privileges

    Design your applications to run with the minimum privileges required. Ensure that your applications can run as a non-root user, especially when deployed within containers. The user and group under which the application runs should have access only to a minimal set of files and resources. For more container recommendations, check out part five of this series:  Building good containers.

    5. Support for logging and monitoring

    Logging sensitive or suspicious actions will make it easier for monitoring tools to collect and analyze the data. See the logging section of the reference architecture for recommended monitoring packages.

    6. Externalizing secrets

    Secrets (i.e., passwords) should be defined externally and made available to the application at runtime through secure means. Make sure you don't commit secrets in code repositories or build them into container images.

    The article GitOps secret management provides a good overview of the techniques and components used to manage externalized secrets. The article also refers to additional articles on the topic.

    More specific to Node.js deployments, consider using the dotenv package, which is popular among our team. We also contribute to kube-service-bindings to support the Service Binding Specification for Kubernetes.

    One of the leading tools for managing externalized secrets is node-vault. Teams involved in deployments with the IBM cloud find the IBM Cloud Secrets Manager Node.js SDK helpful.

    7. Maintaining a secure and up-to-date foundation for deployed applications

    A Node.js application is on top of several components. You must keep this foundation secure and up to date throughout your application's lifetime, even if no code changes within your application.

    The key elements include secure and up-to-date:

    • base container images
    • Node.js runtime
    • dependencies

    Based on the team's experience, here are some recommended tips:

    • Take advantage of container images that come with Node.js already bundled in. The maintainers usually release an update after fixing a CVE reported against the Node.js runtime or any other components within the container. This is one of the reasons the team members often use the ubi/nodejs container images.
    • If you build Node.js binaries into a base image, subscribe to and read the nodejs-sec mailing list. This low-volume mailing list provides advance notice of security releases and will give you the earliest warning to update your Node.js version.
    • If you use common dependencies across many projects, create a dependency image from which each project reads. While this centralization is suitable for build times, as outlined in the dependency image section of the reference architecture, it also helps reduce the total work required for dependency updates when shared across numerous projects.

    For a more exhaustive list of tips, check out the Secure Development Process section of the reference architecture.

    8. Maintaining individual modules

    When you maintain modules in GitHub, enable Snyk integration and review the pull requests it creates.

    It is also important to test and ensure the module runs and passes tests on the latest Long Term Support (LTS) version of Node.js. Automated testing reduces risk when Node.js security releases require updates.

    Coming next

    We cover new topics regularly as part of the Node.js reference architecture series. The next installment covers key questions that Node.js developers need to understand about accessibility.

    We invite you to visit the Node.js reference architecture repository on GitHub, where you will see the work we have done and look forward to future topics.

    To learn more about what Red Hat is up to on the Node.js front, check out our Node.js page.

    Last updated: January 10, 2024