[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

Copyright © Eclipse Foundation AISBL. All rights reserved.     Privacy Policy | Terms of Use | Copyright Agent