Skip to content

Commit 7686e25

Browse files
pimterryaduh95
authored andcommitted
tls: don't allow TLS resumption to change SNI
Signed-off-by: Tim Perry <pimterry@gmail.com> PR-URL: nodejs-private/node-private#1030 Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com> CVE-ID: CVE-2026-123872
1 parent 69cca87 commit 7686e25

5 files changed

Lines changed: 146 additions & 3 deletions

File tree

‎src/crypto/crypto_context.cc‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2213,8 +2213,11 @@ void SecureContext::SetSessionIdContext(
22132213
reinterpret_cast<const unsigned char*>(*sessionIdContext);
22142214
unsigned int sid_ctx_len = sessionIdContext.length();
22152215

2216-
if (SSL_CTX_set_session_id_context(sc->ctx_.get(), sid_ctx, sid_ctx_len) == 1)
2216+
int ok = SSL_CTX_set_session_id_context(sc->ctx_.get(), sid_ctx, sid_ctx_len);
2217+
if (ok == 1) {
2218+
sc->session_id_context_.assign(*sessionIdContext, sid_ctx_len);
22172219
return;
2220+
}
22182221

22192222
Local<String> message;
22202223

‎src/crypto/crypto_context.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class SecureContext final : public BaseObject {
5555
// True if CA certificates were added with addCACert(), as opposed to the
5656
// context relying on the default root store.
5757
bool has_own_ca() const { return has_own_ca_; }
58+
const std::string& session_id_context() const { return session_id_context_; }
5859

5960
// Non-const ctx() that allows for non-default initialization of
6061
// the SecureContext.
@@ -192,6 +193,7 @@ class SecureContext final : public BaseObject {
192193
// Non-owning cache for SSL_CTX_get_cert_store(ctx_.get())
193194
X509_STORE* own_cert_store_cache_ = nullptr;
194195
bool has_own_ca_ = false;
196+
std::string session_id_context_;
195197

196198
unsigned char ticket_key_name_[16];
197199
unsigned char ticket_key_aes_[16];

‎src/crypto/crypto_tls.cc‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,21 @@ SSL_SESSION* GetSessionCallback(
121121
// the handshake is suspended here and resumed once JS has answered.
122122
ClientHelloResult EarlyClientHelloCallback(const ClientHelloContext& hello) {
123123
TLSWrap* w = static_cast<TLSWrap*>(SSL_get_app_data(hello.ssl()));
124+
125+
auto servername = hello.servername();
126+
// If we can't parse the hello SNI, reject it explicitly:
127+
if (!servername.has_value()) {
128+
hello.set_alert(SSL_AD_DECODE_ERROR);
129+
return ClientHelloResult::kFail;
130+
}
131+
132+
// Session resumption must only be allowed for sessions with exactly the same
133+
// SNI. We use the SNI to derive the session id, so no handshake can resume
134+
// from a mismatched SNI value. Mismatches fall back to full handshakes:
135+
if (!w->BindSessionsToServerName(*servername)) {
136+
return ClientHelloResult::kFail;
137+
}
138+
124139
if (!w->should_suspend_for_client_hello()) {
125140
return ClientHelloResult::kContinue;
126141
}
@@ -1569,8 +1584,8 @@ int TLSWrap::SelectSNIContextCallback(SSL* s, int* ad, void* arg) {
15691584
Local<Value> ctx = p->object()->Get(env->context(), env->sni_context_string())
15701585
.FromMaybe(Local<Value>());
15711586

1572-
if (ctx.IsEmpty() || !ctx->IsObject()) [[unlikely]]
1573-
return SSL_TLSEXT_ERR_NOACK;
1587+
// Acknowlege the name so it's correctly stored on the session:
1588+
if (ctx.IsEmpty() || !ctx->IsObject()) return SSL_TLSEXT_ERR_OK;
15741589

15751590
if (!env->secure_context_constructor_template()->HasInstance(ctx)) {
15761591
// Failure: incorrect SNI context object
@@ -1590,6 +1605,24 @@ int TLSWrap::SelectSNIContextCallback(SSL* s, int* ad, void* arg) {
15901605
return SSL_TLSEXT_ERR_OK;
15911606
}
15921607

1608+
bool TLSWrap::BindSessionsToServerName(std::string_view servername) {
1609+
if (sc_->session_id_context().empty()) return true;
1610+
1611+
// Server names can't contain NULs, so a NUL keeps the parts apart.
1612+
std::string input = sc_->session_id_context();
1613+
input += '\0';
1614+
input += servername;
1615+
// Hash this to guarantee the value fits in 32 bytes while keeping
1616+
// values distinct:
1617+
auto digest = ncrypto::hashDigest(
1618+
{reinterpret_cast<const unsigned char*>(input.data()), input.size()},
1619+
ncrypto::Digest::SHA256);
1620+
return digest && SSL_set_session_id_context(
1621+
ssl_.get(),
1622+
static_cast<const unsigned char*>(digest.get()),
1623+
digest.size()) == 1;
1624+
}
1625+
15931626
int TLSWrap::SetCACerts(SecureContext* sc) {
15941627
int err = SSL_set1_verify_cert_store(ssl_.get(),
15951628
SSL_CTX_get_cert_store(sc->ctx().get()));

‎src/crypto/crypto_tls.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
#include <openssl/ssl.h>
3434

3535
#include <string>
36+
#include <string_view>
3637
#include <vector>
3738

3839
namespace node {
@@ -94,6 +95,7 @@ class TLSWrap : public AsyncWrap,
9495
flags_.awaiting_new_session = on;
9596
}
9697
inline void enable_session_callbacks() { flags_.session_callbacks = true; }
98+
bool BindSessionsToServerName(std::string_view servername);
9799
inline bool is_server() const { return kind_ == Kind::kServer; }
98100
inline bool is_client() const { return kind_ == Kind::kClient; }
99101
inline bool is_awaiting_new_session() const {
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
'use strict';
2+
3+
// A resumed session skips authentication, so it may only be resumed for the
4+
// server name it was authenticated for. Otherwise a client trusted by one host
5+
// could resume into another. Asking for a mismatched name gets a full
6+
// handshake, which authenticates the client from scratch like any other.
7+
8+
const common = require('../common');
9+
10+
if (!common.hasCrypto)
11+
common.skip('missing crypto');
12+
13+
const assert = require('node:assert');
14+
const { isBoringSSL } = require('../common/crypto');
15+
const tls = require('node:tls');
16+
const fixtures = require('../common/fixtures');
17+
18+
// What the server reported for each connection it accepted, in order.
19+
const seen = [];
20+
21+
const server = tls.createServer({
22+
key: fixtures.readKey('agent2-key.pem'),
23+
cert: fixtures.readKey('agent2-cert.pem'),
24+
}, (socket) => {
25+
seen.push({ servername: socket.servername, reused: socket.isSessionReused() });
26+
socket.end();
27+
});
28+
29+
// Connects TLS with options, resolves with the raw issued session when the
30+
// conn closes. We need the raw internal session so we can trick Node into
31+
// (ab)using this for the wrong hostname on resumed connections later.
32+
function connect(options) {
33+
return new Promise((resolve) => {
34+
let session;
35+
const socket = tls.connect({
36+
port: server.address().port,
37+
rejectUnauthorized: false,
38+
...options,
39+
});
40+
const { onnewsession } = socket._handle;
41+
socket._handle.onnewsession = function(id, raw) {
42+
session ??= raw;
43+
return onnewsession.call(this, id, raw);
44+
};
45+
socket.on('session', () => {});
46+
socket.on('close', () => resolve(session));
47+
socket.resume();
48+
});
49+
}
50+
51+
// Connecting by IP sends no server name.
52+
const noName = { host: '127.0.0.1' };
53+
54+
server.listen(0, common.mustCall(async () => {
55+
for (const maxVersion of ['TLSv1.2', 'TLSv1.3']) {
56+
seen.length = 0;
57+
const named = await connect({ servername: 'agent1.example', maxVersion });
58+
const unnamed = await connect({ ...noName, maxVersion });
59+
60+
// Resuming as the same name, or with no name both times, is fine.
61+
await connect({ servername: 'agent1.example', maxVersion, session: named });
62+
await connect({ ...noName, maxVersion, session: unnamed });
63+
64+
// Asking for a different name gets a full handshake instead.
65+
await connect({ servername: 'agent3.example', maxVersion, session: named });
66+
// Going from no SNI to any name gets a full handshake instead.
67+
await connect({ servername: 'agent1.example', maxVersion, session: unnamed });
68+
69+
assert.deepStrictEqual(seen, [
70+
{ servername: 'agent1.example', reused: false },
71+
{ servername: false, reused: false },
72+
{ servername: 'agent1.example', reused: true },
73+
{ servername: false, reused: true },
74+
{ servername: 'agent3.example', reused: false },
75+
{ servername: 'agent1.example', reused: false },
76+
], maxVersion);
77+
}
78+
79+
// BoringSSL doesn't support TLS 1.3 external PSK.
80+
if (!isBoringSSL) {
81+
// Make sure fresh PSK & PSK resumption work correctly too - this is risky as
82+
// TLS1.3 PSK works by pretending to be resumption (but it's not).
83+
const secret = Buffer.alloc(32, 1);
84+
const ciphers = 'TLS_AES_128_GCM_SHA256';
85+
const psk = tls.createServer({ ciphers, pskCallback: () => secret },
86+
common.mustCall((socket) => {
87+
assert.strictEqual(socket.servername, 'psk.example');
88+
socket.end();
89+
}, 2));
90+
await new Promise((resolve) => psk.listen(0, resolve));
91+
const pskOptions = {
92+
port: psk.address().port,
93+
servername: 'psk.example',
94+
ciphers,
95+
pskCallback: () => ({ psk: secret, identity: 'client' }),
96+
};
97+
const ticket = await connect(pskOptions);
98+
assert.ok(ticket);
99+
await connect({ ...pskOptions, session: ticket });
100+
psk.close();
101+
}
102+
server.close();
103+
}));

0 commit comments

Comments
 (0)