I recently discovered an important security issue in Socket.IO—a zero-day vulnerability that allows a man-in-the-middle attack on TLS-protected communication between a Socket.IO client and a Socket.IO server. I find this issue rather interesting because it shows how unfortunate design decisions can unintentionally lead to insecure default configuration. This also highlights the dangers of not following secure design principles.
In 1975, Saltzer and Schroeder published The Protection of Information in Computer Systems, the now-famous paper where they outlined eight examples of design principles for secure protection mechanisms. One of these is the principle of fail-safe defaults. By refusing access as the default situation and only granting access under certain conditions, an error in the permission check leads to a “safe state” (e.g., access denied), and the issue will likely be quickly discovered when a legitimate request is denied.
In the opposite approach, if an error occurs when access is granted by default, unless explicitly prohibited, it leads to an insecure state where access can be granted to unauthorized entities and the error may not even be discovered. Although this principle is discussed in the context of access decisions in their paper, it is equally relevant in many other similar contexts.
So, what does this secure design principle have to do with Node.js and Socket.IO? Node.js is an open source JavaScript runtime that has gained a lot of popularity over the last couple of years. It is often used to host web applications and services, with the server-side code written in JavaScript. Most applications leverage several open source modules developed by third-parties and distributed through npm, which is the package manager for Node.js.
One of the most depended-upon packages is Socket.IO, which is a framework for event-driven communication in real-time. It can be used for things such as pushing data to clients for real-time updates (e.g., charts and graphs), streaming binary data, and providing instant messaging and collaboration features.
The Socket.IO server runs on top of Node.js, but the Socket.IO client can run on Node.js as well as in browsers. Socket.IO uses XHR (XmlHttpRequest) polling as the initial transport protocol, but upgrades to WebSockets when they are supported. Using Socket.IO client in a server-side application is typical for microservices infrastructure, where one server-side component is talking to another server-side component. In this case, one of the components will be running a Socket.IO server, while others will be Socket.IO clients.
What I found is that when running Socket.IO as a client in Node.js with default configuration, it doesn’t reject connections to TLS-enabled Socket.IO servers with untrusted or invalid certificates. This means that by default, Socket.IO clients in Node.js will not authenticate the server they connect to when TLS is used. The connection can therefore be intercepted by a malicious entity performing a man-in-the-middle attack, resulting in loss of confidentiality and data integrity.
As I ran on the latest version of Node.js and Socket.IO, I found this behavior surprising since Node.js has been validating the server certificate by default since version 0.9.2 released in 2012. Looking closer into the source code, I found that this insecure default behavior is not intentional; rather, it is due to some unfortunate design decisions in the Node.js TLS module and Socket.IO client code. The reason can be found in the subcomponent engine.io-client where the TLS connect option rejectUnauthorized is set to null when it’s not defined in the options passed to the socket:
this.rejectUnauthorized = opts.rejectUnauthorized === undefined ? null : opts.rejectUnauthorized;
Unfortunately, when this option reaches the TLS module in Node.js, it overwrites the default value of rejectUnauthorized from true to null, which evaluates to false when checked in the if-statement handling certificate validation errors. This leads to the client establishing connections to any TLS server, regardless of whether a valid certificate was provided or not.
Although it can be argued that Socket.IO uses the TLS API in Node.js incorrectly by sending null instead of true, I believe the root of the problem is that the TLS module in Node.js doesn’t follow the principle of fail-safe defaults.
As the name of the option rejectUnauthorized implies, certificate validation is designed in a way that makes the TLS client accept any certificate without validation unless this option is set to true. The consequence of this is that errors and mistakes, such as the one in Socket.IO, lead to an insecure state.
A better approach would be to follow the principle of fail-safe defaults and design the TLS client in such a way that it always validates the server certificate by default, and only allows exceptions to validation when an explicit option is set. Having an option called something like acceptUntrustedCertificates to reverse the logic would lead to a “safe by default” practice.
To ensure that making mistakes that lead to an insecure state is hard, Node.js could go even further and do something similar to what Facebook has done in React.js. Here, the property dangerouslySetInnerHTML not only warns the developer by its name, it also requires the developer to pass it an object with a specific key instead of just a string, which prevents any harm if the developer unintentionally passes unvalidated and untrusted data directly to the property.
The issue of insecure default processing and insecure default value of rejectUnauthorized has been privately disclosed to the Node.js security team and Guillermo Rauch of Socket.IO respectively. A public issue to address processing of rejectUnauthorized in the TLS module has been opened on Node.js issue tracker at GitHub, and a fix of default value used in Socket.IO has been published in the primary branch of engine.io-client.
Engine.io-client 1.6.9, which is used in Socket.IO 1.4.6, is released on npm and provides a fix for this issue. Upgrading to this version is highly recommended. The advice below is left to help resolve this issue for situations where an upgrade is not possible or not yet made.
While waiting for a new release with this issue resolved, users can take steps to mitigate this risk by explicitly setting rejectUnauthorized: true in the options passed to io.connect() when running a Socket.IO client in Node.js. This causes the client to emit a Connect Error: {"type":"TransportError","description":503} if the TLS-enabled Socket.IO server is not using a valid certificate, instead of silently accepting the untrusted certificate.
Disclosure timeline is as follows:
Upgrade to the latest version of Socket.IO to get engine.io-client 1.6.9, which resolves this issue.
The lesson we can learn from this is that we mustn’t forget the ancient wisdom captured in the secure design principles when moving to new technologies. Saltzer and Schroeder published their work over 40 years ago, but it is still as relevant today in modern technologies such as Node.js and Socket.IO. In summary, we should strive to make it as easy as possible for developers to use an API securely, and avoid design patterns where mistakes unintentionally may lead to insecure states.