diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d315e55c..8f867449 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -181,6 +181,8 @@ jobs: POSTGRES_IAM_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_USER_IAM_NODE POSTGRES_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_PASS POSTGRES_DB:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_DB + POSTGRES_CAS_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CAS_CONNECTION_NAME + POSTGRES_CAS_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/POSTGRES_CAS_PASS SQLSERVER_CONNECTION_NAME:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_CONNECTION_NAME SQLSERVER_USER:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_USER SQLSERVER_PASS:${{ vars.GOOGLE_CLOUD_PROJECT }}/SQLSERVER_PASS @@ -212,6 +214,8 @@ jobs: POSTGRES_IAM_USER: "${{ steps.secrets.outputs.POSTGRES_IAM_USER }}" POSTGRES_PASS: "${{ steps.secrets.outputs.POSTGRES_PASS }}" POSTGRES_DB: "${{ steps.secrets.outputs.POSTGRES_DB }}" + POSTGRES_CAS_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CAS_CONNECTION_NAME }}" + POSTGRES_CAS_PASS: "${{ steps.secrets.outputs.POSTGRES_CAS_PASS }}" SQLSERVER_CONNECTION_NAME: "${{ steps.secrets.outputs.SQLSERVER_CONNECTION_NAME }}" SQLSERVER_USER: "${{ steps.secrets.outputs.SQLSERVER_USER }}" SQLSERVER_PASS: "${{ steps.secrets.outputs.SQLSERVER_PASS }}" @@ -234,6 +238,8 @@ jobs: POSTGRES_IAM_USER: "${{ steps.secrets.outputs.POSTGRES_IAM_USER }}" POSTGRES_PASS: "${{ steps.secrets.outputs.POSTGRES_PASS }}" POSTGRES_DB: "${{ steps.secrets.outputs.POSTGRES_DB }}" + POSTGRES_CAS_CONNECTION_NAME: "${{ steps.secrets.outputs.POSTGRES_CAS_CONNECTION_NAME }}" + POSTGRES_CAS_PASS: "${{ steps.secrets.outputs.POSTGRES_CAS_PASS }}" SQLSERVER_CONNECTION_NAME: "${{ steps.secrets.outputs.SQLSERVER_CONNECTION_NAME }}" SQLSERVER_USER: "${{ steps.secrets.outputs.SQLSERVER_USER }}" SQLSERVER_PASS: "${{ steps.secrets.outputs.SQLSERVER_PASS }}" diff --git a/src/cloud-sql-instance.ts b/src/cloud-sql-instance.ts index 877cf59c..496cab57 100644 --- a/src/cloud-sql-instance.ts +++ b/src/cloud-sql-instance.ts @@ -75,6 +75,8 @@ export class CloudSQLInstance { public port = 3307; public privateKey?: string; public serverCaCert?: SslCert; + public serverCaMode = ''; + public dnsName = ''; constructor({ ipType, @@ -193,6 +195,8 @@ export class CloudSQLInstance { const host = selectIpAddress(metadata.ipAddresses, this.ipType); const privateKey = rsaKeys.privateKey; const serverCaCert = metadata.serverCaCert; + this.serverCaMode = metadata.serverCaMode; + this.dnsName = metadata.dnsName; const currentValues = { ephemeralCert: this.ephemeralCert, diff --git a/src/connector.ts b/src/connector.ts index fc85466c..4c306618 100644 --- a/src/connector.ts +++ b/src/connector.ts @@ -206,6 +206,8 @@ export class Connector { port, privateKey, serverCaCert, + serverCaMode, + dnsName, } = cloudSqlInstance; if ( @@ -223,6 +225,8 @@ export class Connector { port, privateKey, serverCaCert, + serverCaMode, + dnsName, }); tlsSocket.once('error', async () => { await cloudSqlInstance.forceRefresh(); diff --git a/src/socket.ts b/src/socket.ts index cc90c4b4..6c757ad4 100644 --- a/src/socket.ts +++ b/src/socket.ts @@ -26,10 +26,19 @@ interface SocketOptions { instanceInfo: InstanceConnectionInfo; privateKey: string; serverCaCert: SslCert; + serverCaMode: string; + dnsName: string; } -export function validateCertificate(instanceInfo: InstanceConnectionInfo) { +export function validateCertificate( + instanceInfo: InstanceConnectionInfo, + serverCaMode: string, + dnsName: string +) { return (hostname: string, cert: tls.PeerCertificate): Error | undefined => { + if (serverCaMode === 'GOOGLE_MANAGED_CAS_CA') { + return tls.checkServerIdentity(dnsName, cert); + } if (!cert || !cert.subject) { return new CloudSQLConnectorError({ message: 'No certificate to verify', @@ -54,6 +63,8 @@ export function getSocket({ instanceInfo, privateKey, serverCaCert, + serverCaMode, + dnsName, }: SocketOptions): tls.TLSSocket { const socketOpts = { host, @@ -64,7 +75,11 @@ export function getSocket({ key: privateKey, minVersion: 'TLSv1.3', }), - checkServerIdentity: validateCertificate(instanceInfo), + checkServerIdentity: validateCertificate( + instanceInfo, + serverCaMode, + dnsName + ), }; const tlsSocket = tls.connect(socketOpts); tlsSocket.setKeepAlive(true, DEFAULT_KEEP_ALIVE_DELAY_MS); diff --git a/src/sqladmin-fetcher.ts b/src/sqladmin-fetcher.ts index 394dc390..152e011e 100644 --- a/src/sqladmin-fetcher.ts +++ b/src/sqladmin-fetcher.ts @@ -27,6 +27,8 @@ import {AuthTypes} from './auth-types'; export interface InstanceMetadata { ipAddresses: IpAddresses; serverCaCert: SslCert; + serverCaMode: string; + dnsName: string; } interface RequestBody { @@ -216,6 +218,8 @@ export class SQLAdminFetcher { cert: serverCaCert.cert, expirationTime: serverCaCert.expirationTime, }, + serverCaMode: res.data.serverCaMode || '', + dnsName: res.data.dnsName || '', }; } diff --git a/system-test/pg-connect.cjs b/system-test/pg-connect.cjs index f397b22f..601b07ce 100644 --- a/system-test/pg-connect.cjs +++ b/system-test/pg-connect.cjs @@ -65,3 +65,31 @@ t.test('open IAM connection and retrieves standard pg tables', async t => { await client.end(); connector.close(); }); + +t.test( + 'open connection to CAS-based CA instance and retrieves standard pg tables', + async t => { + const connector = new Connector(); + const clientOpts = await connector.getOptions({ + instanceConnectionName: process.env.POSTGRES_CAS_CONNECTION_NAME, + ipType: 'PUBLIC', + authType: 'PASSWORD', + }); + const client = new Client({ + ...clientOpts, + user: process.env.POSTGRES_USER, + password: process.env.POSTGRES_CAS_PASS, + database: process.env.POSTGRES_DB, + }); + client.connect(); + + const { + rows: [result], + } = await client.query('SELECT NOW();'); + const returnedDate = result['now']; + t.ok(returnedDate.getTime(), 'should have valid returned date object'); + + await client.end(); + connector.close(); + } +); diff --git a/system-test/pg-connect.mjs b/system-test/pg-connect.mjs index ff91bac0..4b7fec05 100644 --- a/system-test/pg-connect.mjs +++ b/system-test/pg-connect.mjs @@ -65,3 +65,31 @@ t.test('open IAM connection and retrieves standard pg tables', async t => { await client.end(); connector.close(); }); + +t.test( + 'open connection to CAS-based CA instance and retrieves standard pg tables', + async t => { + const connector = new Connector(); + const clientOpts = await connector.getOptions({ + instanceConnectionName: process.env.POSTGRES_CAS_CONNECTION_NAME, + ipType: 'PUBLIC', + authType: 'PASSWORD', + }); + const client = new Client({ + ...clientOpts, + user: process.env.POSTGRES_USER, + password: process.env.POSTGRES_CAS_PASS, + database: process.env.POSTGRES_DB, + }); + client.connect(); + + const { + rows: [result], + } = await client.query('SELECT NOW();'); + const returnedDate = result['now']; + t.ok(returnedDate.getTime(), 'should have valid returned date object'); + + await client.end(); + connector.close(); + } +); diff --git a/system-test/pg-connect.ts b/system-test/pg-connect.ts index 3371c393..de10d0b2 100644 --- a/system-test/pg-connect.ts +++ b/system-test/pg-connect.ts @@ -67,3 +67,29 @@ t.test('open IAM connection and retrieves standard pg tables', async t => { await client.end(); connector.close(); }); + +t.test( + 'open connection to CAS-based CA instance and retrieves standard pg tables', + async t => { + const connector = new Connector(); + const clientOpts = await connector.getOptions({ + instanceConnectionName: String(process.env.POSTGRES_CAS_CONNECTION_NAME), + }); + const client = new Client({ + ...clientOpts, + user: String(process.env.POSTGRES_USER), + password: String(process.env.POSTGRES_CAS_PASS), + database: String(process.env.POSTGRES_DB), + }); + client.connect(); + + const { + rows: [result], + } = await client.query('SELECT NOW();'); + const returnedDate = result['now']; + t.ok(returnedDate.getTime(), 'should have valid returned date object'); + + await client.end(); + connector.close(); + } +); diff --git a/test/socket.ts b/test/socket.ts index c7748591..1c97b8ab 100644 --- a/test/socket.ts +++ b/test/socket.ts @@ -41,6 +41,8 @@ t.test('getSocket', async t => { cert: CA_CERT, expirationTime: '2033-01-06T10:00:00.232Z', }, + serverCaMode: 'GOOGLE_MANAGED_INTERNAL_CA', + dnsName: 'abcde.12345.us-central1.sql.goog', }); socket.on('secureConnect', () => { @@ -61,11 +63,15 @@ t.test('getSocket', async t => { t.test('validateCertificate no cert', async t => { t.match( - validateCertificate({ - projectId: 'my-project', - regionId: 'region-id', - instanceId: 'my-instance', - })('hostname', {} as tls.PeerCertificate), + validateCertificate( + { + projectId: 'my-project', + regionId: 'region-id', + instanceId: 'my-instance', + }, + 'GOOGLE_MANAGED_INTERNAL_CA', + 'abcde.12345.us-central1.sql.goog' + )('hostname', {} as tls.PeerCertificate), {code: 'ENOSQLADMINVERIFYCERT'}, 'should return a missing cert to verify error' ); @@ -78,11 +84,15 @@ t.test('validateCertificate mismatch', async t => { }, } as tls.PeerCertificate; t.match( - validateCertificate({ - projectId: 'my-project', - regionId: 'region-id', - instanceId: 'my-instance', - })('hostname', cert), + validateCertificate( + { + projectId: 'my-project', + regionId: 'region-id', + instanceId: 'my-instance', + }, + 'GOOGLE_MANAGED_INTERNAL_CA', + 'abcde.12345.us-central1.sql.goog' + )('hostname', cert), { message: 'Certificate had CN other-project:other-instance, expected my-project:my-instance', @@ -91,3 +101,45 @@ t.test('validateCertificate mismatch', async t => { 'should return a missing cert to verify error' ); }); + +t.test('validateCertificate mismatch CAS CA', async t => { + const cert = { + subjectaltname: 'DNS:abcde.12345.us-central1.sql.goog', + } as tls.PeerCertificate; + t.match( + validateCertificate( + { + projectId: 'my-project', + regionId: 'region-id', + instanceId: 'my-instance', + }, + 'GOOGLE_MANAGED_CAS_CA', + 'bad.dns.us-central1.sql.goog' + )('hostname', cert), + { + message: + "Hostname/IP does not match certificate's altnames: Host: bad.dns.us-central1.sql.goog. is not in the cert's altnames: DNS:abcde.12345.us-central1.sql.goog", + code: 'ERR_TLS_CERT_ALTNAME_INVALID', + }, + 'should return an invalid altname error' + ); +}); + +t.test('validateCertificate valid CAS CA', async t => { + const cert = { + subjectaltname: 'DNS:abcde.12345.us-central1.sql.goog', + } as tls.PeerCertificate; + t.match( + validateCertificate( + { + projectId: 'my-project', + regionId: 'region-id', + instanceId: 'my-instance', + }, + 'GOOGLE_MANAGED_CAS_CA', + 'abcde.12345.us-central1.sql.goog' + )('hostname', cert), + undefined, + 'DNS name matches SAN in cert' + ); +}); diff --git a/test/sqladmin-fetcher.ts b/test/sqladmin-fetcher.ts index 7b53071a..f3ba2545 100644 --- a/test/sqladmin-fetcher.ts +++ b/test/sqladmin-fetcher.ts @@ -116,6 +116,7 @@ const mockSQLAdminGetInstanceMetadata = ( pscEnabled: true, region: regionId, serverCaCert: serverCaCertResponse(instanceId), + serverCaMode: 'GOOGLE_MANAGED_INTERNAL_CA', ...overrides, }, }); @@ -189,6 +190,8 @@ t.test('getInstanceMetadata', async t => { cert: '-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----', expirationTime: '2033-01-06T10:00:00.232Z', }, + serverCaMode: 'GOOGLE_MANAGED_INTERNAL_CA', + dnsName: 'abcde.12345.us-central1.sql.goog', }, 'should return expected instance metadata object' );