镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Commit 66e72ec

Browse files
mcollinaaduh95
authored andcommitted
tls: apply the CA of SNI contexts without a certificate
`CertCbDone()` only installed an SNI context's CA store and client CA list when `setSniContext()` succeeded, which requires the context to have a certificate. A context added with `server.addContext(host, { ca })` was therefore ignored for client certificate verification, and clients for that host were verified against the default context's CA instead. Apply the SNI context's CA whenever it configured one through `addCACert()`, even without a certificate. A context with neither a certificate nor its own CA still keeps the default trust store, rather than switching to the bundled root certificates. Also report a failure of `setSniContext()` instead of silently continuing with the default credentials. Refs: https://hackerone.com/reports/4103553 Signed-off-by: Matteo Collina <hello@matteocollina.com> PR-URL: nodejs-private/node-private#1020 Reviewed-By: Tim Perry <pimterry@gmail.com> CVE-ID: CVE-2026-123867
1 parent 940cae5 commit 66e72ec

4 files changed

Lines changed: 107 additions & 5 deletions

File tree

‎src/crypto/crypto_context.cc‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1731,6 +1731,7 @@ inline void SecureContext::Reset() {
17311731
ctx_.reset();
17321732
cert_.reset();
17331733
issuer_.reset();
1734+
has_own_ca_ = false;
17341735
}
17351736

17361737
SecureContext::~SecureContext() {
@@ -2066,6 +2067,7 @@ void SecureContext::SetAllowPartialTrustChain(
20662067
void SecureContext::SetCACert(const BIOPointer& bio) {
20672068
ClearErrorOnReturn clear_error_on_return;
20682069
AddCACertificates(env(), ctx_.get(), bio, &own_cert_store_cache_);
2070+
has_own_ca_ = true;
20692071
}
20702072

20712073
void SecureContext::AddCACert(const FunctionCallbackInfo<Value>& args) {

‎src/crypto/crypto_context.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ class SecureContext final : public BaseObject {
5252
static SecureContext* Create(Environment* env);
5353

5454
const ncrypto::SSLCtxPointer& ctx() const { return ctx_; }
55+
// True if CA certificates were added with addCACert(), as opposed to the
56+
// context relying on the default root store.
57+
bool has_own_ca() const { return has_own_ca_; }
5558

5659
// Non-const ctx() that allows for non-default initialization of
5760
// the SecureContext.
@@ -204,6 +207,7 @@ class SecureContext final : public BaseObject {
204207
bool client_cert_engine_provided_ = false;
205208
ncrypto::EnginePointer private_key_engine_;
206209
#endif // !OPENSSL_NO_ENGINE
210+
bool has_own_ca_ = false;
207211

208212
unsigned char ticket_key_name_[16];
209213
unsigned char ticket_key_aes_[16];

‎src/crypto/crypto_tls.cc‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1722,11 +1722,19 @@ void TLSWrap::CertCbDone(const FunctionCallbackInfo<Value>& args) {
17221722
// Store the SNI context for later use.
17231723
w->sni_context_ = BaseObjectPtr<SecureContext>(sc);
17241724

1725-
if (w->ssl_.setSniContext(w->sni_context_->ctx()) && !w->SetCACerts(sc)) {
1726-
// Not clear why sometimes we throw error, and sometimes we call
1727-
// onerror(). Both cause .destroy(), but onerror does a bit more.
1728-
unsigned long err = ERR_get_error(); // NOLINT(runtime/int)
1729-
return ThrowCryptoError(env, err, "CertCbDone");
1725+
// An SNI context without a certificate keeps the default context's
1726+
// certificate, but a `ca` it configured must still replace the default
1727+
// trust store for client certificates. A context with neither keeps the
1728+
// default context's trust store.
1729+
const bool has_cert = SSL_CTX_get0_certificate(sc->ctx().get()) != nullptr;
1730+
if (has_cert || sc->has_own_ca()) {
1731+
if ((has_cert && !w->ssl_.setSniContext(w->sni_context_->ctx())) ||
1732+
!w->SetCACerts(sc)) {
1733+
// Not clear why sometimes we throw error, and sometimes we call
1734+
// onerror(). Both cause .destroy(), but onerror does a bit more.
1735+
unsigned long err = ERR_get_error(); // NOLINT(runtime/int)
1736+
return ThrowCryptoError(env, err, "CertCbDone");
1737+
}
17301738
}
17311739
// setSniContext copies the cert via SSL_use_certificate which does not
17321740
// carry over pre-compressed certificate data (comp_cert). If the new
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
if (!common.hasCrypto)
5+
common.skip('missing crypto');
6+
7+
// An SNI context that sets `ca` but no certificate must still replace the
8+
// default trust store for client certificates on that host. A context that
9+
// sets neither keeps the default trust store.
10+
11+
const assert = require('assert');
12+
const tls = require('tls');
13+
const fixtures = require('../common/fixtures');
14+
15+
const serverCreds = {
16+
key: fixtures.readKey('agent2-key.pem'),
17+
cert: fixtures.readKey('agent2-cert.pem'),
18+
};
19+
const ca1 = fixtures.readKey('ca1-cert.pem');
20+
const ca2 = fixtures.readKey('ca2-cert.pem');
21+
const clients = {
22+
agent1: { // Issued by CA1.
23+
key: fixtures.readKey('agent1-key.pem'),
24+
cert: fixtures.readKey('agent1-cert.pem'),
25+
},
26+
agent3: { // Issued by CA2.
27+
key: fixtures.readKey('agent3-key.pem'),
28+
cert: fixtures.readKey('agent3-cert.pem'),
29+
},
30+
};
31+
32+
const cases = [
33+
{ name: 'ca only', context: { ca: ca2 }, accepted: 'agent3' },
34+
{ name: 'cert, key and ca', context: { ...serverCreds, ca: ca2 },
35+
accepted: 'agent3' },
36+
{ name: 'no ca or cert', context: { ciphers: 'DEFAULT' },
37+
accepted: 'agent1' },
38+
];
39+
40+
function connect(port, client, version) {
41+
return new Promise((resolve) => {
42+
const socket = tls.connect({
43+
port,
44+
servername: 'protected.example',
45+
...clients[client],
46+
rejectUnauthorized: false,
47+
minVersion: version,
48+
maxVersion: version,
49+
});
50+
let data = '';
51+
socket.setEncoding('utf8');
52+
socket.on('data', (chunk) => data += chunk);
53+
socket.on('error', () => {});
54+
socket.on('close', () => resolve(data));
55+
});
56+
}
57+
58+
async function test(version, { name, context, accepted }) {
59+
const server = tls.createServer({
60+
...serverCreds,
61+
ca: ca1,
62+
requestCert: true,
63+
rejectUnauthorized: true,
64+
minVersion: version,
65+
maxVersion: version,
66+
}, common.mustCall((socket) => {
67+
assert.strictEqual(socket.servername, 'protected.example');
68+
socket.end(socket.getPeerCertificate().subject.CN);
69+
}));
70+
server.on('tlsClientError', () => {});
71+
server.addContext('protected.example', context);
72+
await new Promise((resolve) => server.listen(0, resolve));
73+
74+
const { port } = server.address();
75+
for (const client of Object.keys(clients)) {
76+
const expected = client === accepted ? client : '';
77+
assert.strictEqual(await connect(port, client, version), expected,
78+
`${version}, ${name}: ${client}`);
79+
}
80+
server.close();
81+
}
82+
83+
(async () => {
84+
for (const version of ['TLSv1.2', 'TLSv1.3']) {
85+
for (const c of cases)
86+
await test(version, c);
87+
}
88+
})().then(common.mustCall());

0 commit comments

Comments
 (0)