HTTPS certificate non-validation vulnerability in Node.js
Today Node.js announced and released a security fix for CVE-2021-22939, along with two other high severity issues. They've rated this vulnerability as 'low severity', but I think it's worth a closer look, as (imo) this really understates the risk here, and the potentially widespread impact.
In practice, this poses a risk to anybody making TLS connections from Node.js, e.g. anybody making HTTPS requests. Not all usage is vulnerable, but many common use cases are, it's not easy to guarantee that your code is 100% secure, and all Node.js versions since at least v8.0.0 are affected. If you're using TLS/HTTPS in Node.js, you should update ASAP.
I reported this issue to Node myself a couple of weeks ago, after running into it during my own development testing HTTP Toolkit. Let's talk through why this is a problem, how it works, and what you should do about it.
Everything here applies to TLS in general, but I'm going to focus on HTTPS specifically, since it's by far the most likely use case, and it's simpler and clearer.
What's the problem?
Here's an example of common but vulnerable code (TypeScript types included for clarity):
const https = require('https');
// Any convenient wrapper or library around the HTTPS module. It takes a URL, and
// extra optional parameters, including a `verifyCertificates` option, which can
// be set to `false` to disable cert verification when necessary.
function makeRequest(url: string, options: { verifyCertificates?: boolean } = {}) {
// [...Do some custom logic...]
// At some point make a request, using the optional verification option:
return https.get(url, {
rejectUnauthorized: options.verifyCertificates
});
}
// Later usage looks like it's making a secure HTTPS request, but in fact the certificate
// is not being verified at all, so you could be talking to *anybody*:
makeRequest("https://google.com");
The key here is rejectUnauthorized
. This Node.js option configures whether the request will check that the server's certificate is valid. If this is disabled, then all HTTPS protections are silently disabled. Anybody who can touch your HTTPS traffic can impersonate any server, to inspect or edit any traffic they like. In security terms this is normally described as "Very Bad".
The Node.js documentation for this option says:
If not false, the server certificate is verified against the list of supplied CAs. [...] Default: true.
I.e. you can actively disable this verification if you need to, but by default it's always enabled unless you explicitly pass false
.
Unfortunately, this wasn't true.
In reality, falsey values including undefined
, will also disable certificate verification. That's a problem because it's extremely easy to introduce falsey values in JavaScript. In addition, every other API will treat undefined
the same as 'no parameter provided', and so use the default (true
), which would indeed securely validating the server's certificate. That's how every other Node.js API I've tested works, and how syntactic support for default parameters is defined.
That turns code that passes undefined
from something that most developers would assume is perfectly safe and secure, and which the documentation explicitly says is safe, into something that's invisibly disabled fundamental security protections.
This means that anybody accidentally passing undefined
to rejectUnauthorized
is unknowingly not verifying the server's TLS certificate, and all the protections of HTTPS have been silently disabled.
When certificate verification is disabled like this, anything goes. You can use a self-signed certificate you just made up, use a real certificate signed for the wrong hostname, use expired certificates, revoked certificates, or even many invalid certificates.
That allows any malicious party who can get between you and the target server to pretend to be that server, and so take all your traffic in both directions and inspect and/or modify the proxied traffic before it's sent on to the real server.
Node.js won't show any warnings or clues that this is happening, and when there's no malicious parties involved everything will work exactly like normal, making this an otherwise invisible vulnerability.
Falling into this undefined
trap is easy, because of how people frequently build options objects like these in JavaScript. A common convention is to define all the properties, referencing options from elsewhere that may or may not be defined. This isn't something you'd often do when doing an HTTP(S) request from scratch in your own code, but it's very common pattern when building a library or smaller wrapper around the raw HTTPS APIs.
Any code like this is usually vulnerable:
https.request({
// ...
rejectUnauthorized: options.anOption
});
This is vulnerable because values on options
objects are optional by definition (so will usually be undefined), while rejectUnauthorized
does not behave like a normal option, and should never be undefined
.
This needn't necessarily be as simple as this. It's quite possible that internal libraries will generate values for rejectUnauthorized
based on other parameters (allowing self-signed certificates for specific hostnames for example) so there's a variety of ways to run into this.
In practice, this is common - suffice to say I'm aware of npm modules with millions of weekly downloads who follow this pattern today, and there's plenty of examples you can find on GitHub too. I'll avoid pointing at specifics before those are fully resolved, but I intend to coordinate with vulnerable packages I'm aware of to update this code, and applications which are running on one of the latest Node.js releases are safe regardless.
How can an attacker exploit this?
Exploiting this is trivial if you can get on the network path of a request from a vulnerable application. That usually means anybody on your local network could exploit this (e.g. on the same wifi while you use a vulnerable Node.js CLI tool) or anybody who handles your traffic upstream, for example your ISP, any proxy, reverse proxy services like CloudFlare, and so on.
Whilst that is a challenge, attackers on the path between you and the server you're talking to are exactly what HTTPS is trying to prevent, and the only reason it exists. Those protections are important, and the vast majority of software you use assumes that they're in place, and builds other security mechanisms on top of that foundation.
Exploiting this in reality requires three steps:
- Be on the path between a vulnerable client and an HTTPS server they want to talk to (for an ISP or proxy this is always true, for local networks this is reliably achievable using various techniques like ARP spoofing or evil twin wifi)
- Pretend to be the target server (accept the TLS connection when you see it, generate a random certificate yourself for the TLS handshake, and vulnerable code will always accept it as a real valid certificate regardless)
- Do something with the intercepted traffic (proxy it to the real server untouched, but inspected, inject your own responses, or proxy it while changing the request and response data)
Step 2 and 3 are extremely easy, and libraries like Mockttp (which I maintain, for testing HTTP(S) request traffic) can do it for you automatically in a couple of lines. Step 1 is harder, but not much harder in many environments.
The Node.js clients at risk are likely to be either CLI tools, or backend services making requests to APIs. For vulnerable clients, any such API keys are exposed, and all API requests & responses are potentially visible & editable by 3rd parties en route. For most non-trivial API usage, that is Very Bad.
What's the fix?
For Node.js itself, it's a very simple fix: the TLS module needs to explicitly check for false
, which they now do. This was already done for server verification of client certificates a while back (when the documentation was updated) but seemingly never completed for the (far more commonly used) client verification of server certificates.
For downstream developers, there's two things you can do:
- Update to the latest Node.js version
- Ensure all your code and your dependencies code always sets
rejectUnauthorized
explicitly to eithertrue
(by default) orfalse
(only where definitely necessary).
To test if code is vulnerable, try making a request to a known-bad HTTPS service. Badssl.com hosts a selection of these covering various types of bad HTTPS configurations, for example expired.badssl.com. Unfortunately, due to the nature of this, you need to ensure that that works with various
In the example code above, makeRequest("https://expired.badssl.com")
will work, sending the request with no errors. Using one of the fixed versions of node released today, or when fixing the code itself, it will throw an error instead.
Why is this a low severity issue?
Good question! If you're not interested in how security reporting works, this might not be interesting, but it is important, because these severity scores affect how much attention vulnerabilities get, and how quickly systems are secured.
If you're not aware, vulnerabilities are generally scored using CVSS (Common Vulnerability Scoring System), which takes a series of parameters like "Confidentiality Impact" and "Privileges Required", and combines them together to give an overall severity from 0.0 (no issue) to 10.0 (critical disaster). The set of parameters together describe the details of how the vulnerability is exploited and its potential impact of vulnerable systems.
These scores don't related to how many systems are affected, or the chance of a random person on the street being impacted, or anything like this - a Node.js vulnerability is scored no higher than the equivalent bug in a tiny rarely-used FORTRAN package. These scores only aim to give a score of the potential risk to the systems that are vulnerable, so that maintainers of those systems understand their exposure.
In my opinion, using the standard CVSS definitions, a good measure of the real severity is:
- Attack Vector: Network (you can attack remotely, if you're between the client and their target server)
- Attack Complexity: High (you do need to get between the client & the target server)
- Privileges Required: None (you don't need a user account in the vulnerable server or application)
- User Interaction: None (attackers can exploit this with zero user action involved)
- Scope: Unchanged (this usually only affects the vulnerable application)
- Confidentiality impact: High (an attacker can inspect all HTTPS traffic)
- Integrity impact: High (an attacker can arbitrarily change any HTTPS traffic)
- Availability impact: None (generously - there are advanced attacks where you could use this to make a vulnerable application unavailable, but most attacks won't)
The definitions of these are standardized, and this is a textbook example of many of them (interception of network traffic is literally the example of "Attack Complexity: High" taken straight from the standard).
Putting the above into the calculator rates this instead at 7.4 out of 10 (High severity). That's in line with many very similar vulnerabilities in the past - for example in npm modules, Ruby modules, Java modules and Wordpress - and that's a much clearer representation of the real risk here, in my opinion.
(If there's something I'm missing though that limits the risk or increases the challenge to exploit this, I'd love to hear about it! Get in touch)
I'm not sure of the full reasons why Node is treating this as a low severity (1.9) issue, but I suspect it's due to a simple misunderstanding of the overall exploitability, or the Node team consider protecting against undefined
options like this to be out of scope, even though they're widely used and actively supported. I've attempted to resolve this myself, but unsuccessfully.
Credit to them though, while I do disagree with this decision, they have still quickly triaged the report, found the cause, and shipped a fix for the issue.
Vulnerability timeline
- July 26th: I find the issue and file a report.
- July 28th: The Node team acknowledge the issue and find the likely cuplrit.
- August 5th: The Node team announce an upcoming security release.
- August 9th: A fix is committed.
- August 11th: Node.js v16.6.2, v14.17.5 and v12.22.5 are released with fixes for this issue and others.
Wrapping up
You might be vulnerable to this issue: there's plenty of code in the wild that clearly is, and it's very easy to become vulnerable if you've written your own HTTP request utility function or similar.
If you are vulnerable, this is potentially easy to exploit and the impact is very significant.
Fortunately, this is easy to fix. Update Node wherever you can to v16.6.2, v14.17.5 or v12.22.5 now, and update any other code that passes a potentially undefined value to rejectUnauthorized
to ensure it's a boolean (defaulting to true) where possible too, just in case.
Have any thoughts or feedback? Get in touch on Twitter or send me a message and let me know.
Want to inspect & debug Node.js HTTPS for yourself, for debugging & testing, with no vulnerabilities required? Try out HTTP Toolkit.
Published 2 years ago by Tim Perry