Skip to content

Commit d492f7c

Browse files
authored
feat: Update TLS validation to use both SAN and CN fields. (#446)
This updates the logic used by the connector to validate server certificates. When connecting to the instance, the connector's TLS validator will first check the SAN field, and then if that fails check the CN field in the certificate for the instance name. This will enable the connector to work smoothly with both legacy and newer instances. To summarize the deviations from standard TLS hostname verification: Historically, Cloud SQL creates server certificates with the instance name in the Subject.CN field in the format "my-project:my-instance". The connector is expected to check that the instance name that the connector was configured to dial matches the server certificate Subject.CN field. Thus, the Subject.CN field for most Cloud SQL instances does not contain a well-formed DNS Name. This breaks standard TLS hostname verification. Also, there are times when the instance metadata reports that an instance has a DNS name, but that DNS name does not yet appear in the SAN records of the server certificate. The client should fall back to validating the hostname using the instance name in the Subject.CN field. See also: GoogleCloudPlatform/cloud-sql-go-connector#979
1 parent ef4e570 commit d492f7c

File tree

2 files changed

+187
-20
lines changed

2 files changed

+187
-20
lines changed

src/socket.ts

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,35 +30,92 @@ interface SocketOptions {
3030
serverName: string;
3131
}
3232

33+
/**
34+
* validateCertificate implements custom TLS verification logic to gracefully and securely
35+
* handle deviations from standard TLS hostname verification in existing Cloud SQL instance server
36+
* certificates.
37+
*
38+
*
39+
* @param instanceInfo the instance connection name
40+
* @param instanceDnsName the instance's DNS name according to instance metadata
41+
* @param serverName the dns name from the connector configuration.
42+
*
43+
* This is run AFTER the NodeJS TLS library has validated the CA for the
44+
* server certificate.
45+
*
46+
* <p>This is the verification algorithm:
47+
*
48+
* <ol>
49+
* <li>Verify the server cert CA, using the CA certs from the instance metadata. Reject the
50+
* certificate if the CA is invalid.
51+
* <li>Check that the server cert contains a SubjectAlternativeName matching the DNS name in the
52+
* connector configuration OR the DNS Name from the instance metadata
53+
* <li>If the SubjectAlternativeName does not match, and if the server cert Subject.CN field is
54+
* not empty, check that the Subject.CN field contains the instance name. Reject the
55+
* certificate if both the #2 SAN check and #3 CN checks fail.
56+
* </ol>
57+
*
58+
* <p>To summarize the deviations from standard TLS hostname verification:
59+
*
60+
* <p>Historically, Cloud SQL creates server certificates with the instance name in the Subject.CN
61+
* field in the format "my-project:my-instance". The connector is expected to check that the
62+
* instance name that the connector was configured to dial matches the server certificate Subject.CN
63+
* field. Thus, the Subject.CN field for most Cloud SQL instances does not contain a well-formed DNS
64+
* Name. This breaks standard TLS hostname verification.
65+
*
66+
* <p>Also, there are times when the instance metadata reports that an instance has a DNS name, but
67+
* that DNS name does not yet appear in the SAN records of the server certificate. The client should
68+
* fall back to validating the hostname using the instance name in the Subject.CN field.
69+
*/
3370
export function validateCertificate(
3471
instanceInfo: InstanceConnectionInfo,
3572
instanceDnsName: string,
3673
serverName: string
3774
) {
3875
return (hostname: string, cert: tls.PeerCertificate): Error | undefined => {
76+
if (!cert) {
77+
return new CloudSQLConnectorError({
78+
message: 'Certificate missing',
79+
code: 'ENOSQLADMINVERIFYCERT',
80+
});
81+
}
82+
3983
if (!instanceDnsName) {
40-
// Legacy CA Mode
41-
if (!cert || !cert.subject) {
42-
return new CloudSQLConnectorError({
43-
message: 'No certificate to verify',
44-
code: 'ENOSQLADMINVERIFYCERT',
45-
});
46-
}
47-
const expectedCN = `${instanceInfo.projectId}:${instanceInfo.instanceId}`;
48-
if (cert.subject.CN !== expectedCN) {
49-
return new CloudSQLConnectorError({
50-
message: `Certificate had CN ${cert.subject.CN}, expected ${expectedCN}`,
51-
code: 'EBADSQLADMINVERIFYCERT',
52-
});
53-
}
54-
return undefined;
84+
return checkCn(instanceInfo, cert);
5585
} else {
56-
// Standard TLS Verify Full hostname verification using SAN
57-
return tls.checkServerIdentity(serverName, cert);
86+
const err = tls.checkServerIdentity(serverName, cert);
87+
if (err) {
88+
const cnErr = checkCn(instanceInfo, cert);
89+
if (cnErr) {
90+
return err;
91+
}
92+
}
5893
}
94+
95+
return undefined;
5996
};
6097
}
6198

99+
function checkCn(
100+
instanceInfo: InstanceConnectionInfo,
101+
cert: tls.PeerCertificate
102+
) {
103+
const expectedCN = `${instanceInfo.projectId}:${instanceInfo.instanceId}`;
104+
if (!cert.subject || !cert.subject.CN) {
105+
return new CloudSQLConnectorError({
106+
message: `Certificate missing CN, expected ${expectedCN}`,
107+
code: 'ENOSQLADMINVERIFYCERT',
108+
});
109+
}
110+
if (cert.subject.CN && cert.subject.CN !== expectedCN) {
111+
return new CloudSQLConnectorError({
112+
message: `Certificate had CN ${cert.subject.CN}, expected ${expectedCN}`,
113+
code: 'EBADSQLADMINVERIFYCERT',
114+
});
115+
}
116+
return undefined;
117+
}
118+
62119
export function getSocket({
63120
ephemeralCert,
64121
host,
@@ -78,6 +135,8 @@ export function getSocket({
78135
key: privateKey,
79136
minVersion: 'TLSv1.3',
80137
}),
138+
// This checks the provided serverName against the server certificate. It
139+
// is called after the TLS CA chain of is validated.
81140
checkServerIdentity: validateCertificate(
82141
instanceInfo,
83142
instanceDnsName,

test/socket.ts

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import t from 'tap';
1717
import {getSocket, validateCertificate} from '../src/socket';
1818
import {CA_CERT, CLIENT_CERT, CLIENT_KEY} from './fixtures/certs';
1919
import {setupTLSServer} from './fixtures/setup-tls-server';
20+
import {parseInstanceConnectionName} from '../src/parse-instance-connection-name';
2021

2122
t.test('getSocket', async t => {
2223
// the mock tls server for this test will use a custom port in order
@@ -72,7 +73,9 @@ t.test('validateCertificate no cert', async t => {
7273
null,
7374
'abcde.12345.us-central1.sql.goog'
7475
)('hostname', {} as tls.PeerCertificate),
75-
{code: 'ENOSQLADMINVERIFYCERT'},
76+
{
77+
code: 'ENOSQLADMINVERIFYCERT',
78+
},
7679
'should return a missing cert to verify error'
7780
);
7881
});
@@ -94,8 +97,6 @@ t.test('validateCertificate mismatch', async t => {
9497
'abcde.12345.us-central1.sql.goog'
9598
)('hostname', cert),
9699
{
97-
message:
98-
'Certificate had CN other-project:other-instance, expected my-project:my-instance',
99100
code: 'EBADSQLADMINVERIFYCERT',
100101
},
101102
'should return a missing cert to verify error'
@@ -143,3 +144,110 @@ t.test('validateCertificate valid CAS CA', async t => {
143144
'DNS name matches SAN in cert'
144145
);
145146
});
147+
148+
t.test('validateCertificate cases', async t => {
149+
const tcs = [
150+
{
151+
desc: 'cn match',
152+
icn: 'myProject:myRegion:myInstance',
153+
cn: 'myProject:myInstance',
154+
valid: true,
155+
},
156+
{
157+
desc: 'cn no match',
158+
icn: 'myProject:myRegion:badInstance',
159+
cn: 'myProject:myInstance',
160+
valid: false,
161+
},
162+
{
163+
desc: 'cn empty',
164+
icn: 'myProject:myRegion:myInstance',
165+
san: 'db.example.com',
166+
valid: false,
167+
},
168+
{
169+
desc: 'san match',
170+
serverName: 'db.example.com',
171+
icn: 'myProject:myRegion:myInstance',
172+
san: 'db.example.com',
173+
valid: true,
174+
},
175+
{
176+
desc: 'san no match',
177+
serverName: 'bad.example.com',
178+
icn: 'myProject:myRegion:myInstance',
179+
san: 'db.example.com',
180+
valid: false,
181+
},
182+
{
183+
desc: 'san empty match',
184+
serverName: 'empty.example.com',
185+
icn: 'myProject:myRegion:myInstance',
186+
cn: '',
187+
valid: false,
188+
},
189+
{
190+
desc: 'san match with cn present',
191+
serverName: 'db.example.com',
192+
icn: 'myProject:myRegion:myInstance',
193+
san: 'db.example.com',
194+
cn: 'myProject:myInstance',
195+
valid: true,
196+
},
197+
{
198+
desc: 'san no match fallback to cn',
199+
serverName: 'db.example.com',
200+
icn: 'myProject:myRegion:myInstance',
201+
san: 'other.example.com',
202+
cn: 'myProject:myInstance',
203+
valid: true,
204+
},
205+
{
206+
desc: 'san empty match fallback to cn',
207+
serverName: 'db.example.com',
208+
icn: 'myProject:myRegion:myInstance',
209+
cn: 'myProject:myInstance',
210+
valid: true,
211+
},
212+
{
213+
desc: 'san no match fallback to cn and fail',
214+
serverName: 'db.example.com',
215+
icn: 'myProject:myRegion:badInstance',
216+
san: 'other.example.com',
217+
cn: 'myProject:myInstance',
218+
valid: false,
219+
},
220+
];
221+
222+
await Promise.all(
223+
tcs.map(tc =>
224+
t.test(tc.desc + ' cas', async t => {
225+
runCertificateNameTest(t, tc);
226+
})
227+
)
228+
);
229+
});
230+
231+
function runCertificateNameTest(t, tc) {
232+
const cert = {
233+
subject: {CN: tc.cn},
234+
subjectaltname: tc.san ? 'DNS:' + tc.san : undefined,
235+
} as tls.PeerCertificate;
236+
237+
const instanceInfo = parseInstanceConnectionName(tc.icn);
238+
instanceInfo.domainName = tc.san;
239+
240+
const err = validateCertificate(
241+
instanceInfo,
242+
tc.san,
243+
tc.serverName
244+
)(tc.serverName, cert);
245+
246+
if (tc.valid) {
247+
t.match(err, undefined, 'want no error');
248+
} else {
249+
if (!err) {
250+
t.fail('want error, got no error');
251+
}
252+
}
253+
}

0 commit comments

Comments
 (0)