Denial of Service (crash) via uncaught exception in TLS SNICallback during SNI processing

HIGH
nodejs/node
Commit: 2e2abc6e8957
Affected: Node.js 25.x prior to 25.9.0
2026-04-05 10:18 UTC

Description

The commit fixes a vulnerability where SNICallback invocation in loadSNI() could throw synchronously and remain uncaught, allowing a remote attacker to crash a TLS server by sending a crafted TLS ClientHello with a crafted server_name. The code previously invoked owner._SNICallback(...) without guarding against exceptions; the fix wraps the invocation in a try/catch and routes any error to owner.destroy(), completing CVE-2026-21637. This matches the triage and diff changes, and the test updates verify proper tlsClientError emission.

Proof of Concept

PoC to reproduce on pre-fix Node.js:\n\nServer (vulnerable):\nconst tls = require('tls');\nconst fs = require('fs');\nconst server = tls.createServer({\n key: fs.readFileSync('server-key.pem'),\n cert: fs.readFileSync('server-cert.pem'),\n SNICallback: (servername, cb) => {\n if (servername === 'evil.example.com') throw new Error('boom');\n cb(null, tls.createSecureContext({ key: fs.readFileSync('server-key.pem'), cert: fs.readFileSync('server-cert.pem') }));\n }\n});\nserver.listen(8443, () => console.log('listening'));\n\nClient:\nconst tls = require('tls');\nconst client = tls.connect({ port: 8443, host: 'localhost', servername: 'evil.example.com', rejectUnauthorized: false }, () => {\n client.end();\n});\nclient.on('error', (e) => console.error('client error', e));\n\nExpected: The server process will crash due to an uncaught exception thrown from SNICallback.\n\nNote: In the patched version, the exception is caught by the surrounding try/catch and the server terminates the connection gracefully via owner.destroy(), avoiding a crash.

Commit Details

Author: Matteo Collina

Date: 2026-02-17 13:26 UTC

Message:

tls: wrap SNICallback invocation in try/catch Wrap the owner._SNICallback() invocation in loadSNI() with try/catch to route exceptions through owner.destroy() instead of letting them become uncaught exceptions. This completes the fix from CVE-2026-21637 which added try/catch protection to callALPNCallback, onPskServerCallback, and onPskClientCallback but missed loadSNI(). Without this fix, a remote unauthenticated attacker can crash any Node.js TLS server whose SNICallback may throw on unexpected input by sending a single TLS ClientHello with a crafted server_name value. Fixes: https://hackerone.com/reports/3556769 Refs: https://hackerone.com/reports/3473882 CVE-ID: CVE-2026-21637 PR-URL: https://github.com/nodejs-private/node-private/pull/819 Reviewed-By: Robert Nagy <ronagy@icloud.com> Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com> CVE-ID: CVE-2026-21637

Triage Assessment

Vulnerability Type: Denial of Service (crash) / crash due to uncaught exception in TLS SNICallback

Confidence: HIGH

Reasoning:

Commit wraps SNICallback invocation in loadSNI() with a try/catch and routes exceptions to owner.destroy(), addressing CVE-2026-21637. Previously, an exception thrown by SNICallback could be uncaught, allowing a remote attacker to crash a TLS server by sending a crafted ClientHello/server_name. The changes complete the fix by ensuring errors in SNICallback are handled safely.

Verification Assessment

Vulnerability Type: Denial of Service (crash) via uncaught exception in TLS SNICallback during SNI processing

Confidence: HIGH

Affected Versions: Node.js 25.x prior to 25.9.0

Code Diff

diff --git a/lib/internal/tls/wrap.js b/lib/internal/tls/wrap.js index 308180d483908d..d89e501432968a 100644 --- a/lib/internal/tls/wrap.js +++ b/lib/internal/tls/wrap.js @@ -210,23 +210,27 @@ function loadSNI(info) { return requestOCSP(owner, info); let once = false; - owner._SNICallback(servername, (err, context) => { - if (once) - return owner.destroy(new ERR_MULTIPLE_CALLBACK()); - once = true; + try { + owner._SNICallback(servername, (err, context) => { + if (once) + return owner.destroy(new ERR_MULTIPLE_CALLBACK()); + once = true; - if (err) - return owner.destroy(err); + if (err) + return owner.destroy(err); - if (owner._handle === null) - return owner.destroy(new ERR_SOCKET_CLOSED()); + if (owner._handle === null) + return owner.destroy(new ERR_SOCKET_CLOSED()); - // TODO(indutny): eventually disallow raw `SecureContext` - if (context) - owner._handle.sni_context = context.context || context; + // TODO(indutny): eventually disallow raw `SecureContext` + if (context) + owner._handle.sni_context = context.context || context; - requestOCSP(owner, info); - }); + requestOCSP(owner, info); + }); + } catch (err) { + owner.destroy(err); + } } diff --git a/test/parallel/test-tls-psk-alpn-callback-exception-handling.js b/test/parallel/test-tls-psk-alpn-callback-exception-handling.js index e87b68d778035c..881215672ecd0d 100644 --- a/test/parallel/test-tls-psk-alpn-callback-exception-handling.js +++ b/test/parallel/test-tls-psk-alpn-callback-exception-handling.js @@ -332,4 +332,94 @@ describe('TLS callback exception handling', () => { await promise; }); + + // Test 7: SNI callback throwing should emit tlsClientError + it('SNICallback throwing emits tlsClientError', async (t) => { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + SNICallback: (servername, cb) => { + throw new Error('Intentional SNI callback error'); + }, + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Intentional SNI callback error'); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + servername: 'evil.attacker.com', + rejectUnauthorized: false, + }); + + client.on('error', () => {}); + + await promise; + }); + + // Test 8: SNI callback with validation error should emit tlsClientError + it('SNICallback validation error emits tlsClientError', async (t) => { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + SNICallback: (servername, cb) => { + // Simulate common developer pattern: throw on unknown servername + if (servername !== 'expected.example.com') { + throw new Error(`Unknown servername: ${servername}`); + } + cb(null, null); + }, + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.ok(err.message.includes('Unknown servername')); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + servername: 'unexpected.domain.com', + rejectUnauthorized: false, + }); + + client.on('error', () => {}); + + await promise; + }); });
← Back to Alerts View on GitHub →