From a48ebc94428c9a8ff4e5d22c425a0ba14e79ad98 Mon Sep 17 00:00:00 2001 From: Tomas Della Vedova Date: Mon, 2 Aug 2021 11:20:31 +0200 Subject: [PATCH] Support CA fingerprint validation (#1499) Co-authored-by: Aleh Zasypkin Co-authored-by: Ioannis Kakavas --- docs/basic-config.asciidoc | 5 +- docs/connecting.asciidoc | 23 ++++++ index.d.ts | 1 + index.js | 14 ++++ lib/Connection.d.ts | 1 + lib/Connection.js | 50 +++++++++++- lib/pool/BaseConnectionPool.js | 3 + lib/pool/index.d.ts | 1 + test/acceptance/sniff.test.js | 1 + test/unit/client.test.js | 86 ++++++++++++++++++++ test/unit/connection.test.js | 139 ++++++++++++++++++++++++++++++++- test/utils/buildServer.js | 17 +++- 12 files changed, 337 insertions(+), 4 deletions(-) diff --git a/docs/basic-config.asciidoc b/docs/basic-config.asciidoc index e4e1d3013..c800b38c0 100644 --- a/docs/basic-config.asciidoc +++ b/docs/basic-config.asciidoc @@ -255,6 +255,10 @@ const client = new Client({ |`boolean`, `'proto'`, `'constructor'` - By the default the client will protect you against prototype poisoning attacks. Read https://web.archive.org/web/20200319091159/https://hueniverse.com/square-brackets-are-the-enemy-ff5b9fd8a3e8?gi=184a27ee2a08[this article] to learn more. If needed you can disable prototype poisoning protection entirely or one of the two checks. Read the `secure-json-parse` https://github.com/fastify/secure-json-parse[documentation] to learn more. + _Default:_ `false` +|`caFingerprint` +|`string` - If configured, verify that the fingerprint of the CA certificate that has signed the certificate of the server matches the supplied fingerprint. Only accepts SHA256 digest fingerprints. + +_Default:_ `null` + |=== [discrete] @@ -276,4 +280,3 @@ const client = new Client({ disablePrototypePoisoningProtection: true }) ---- - diff --git a/docs/connecting.asciidoc b/docs/connecting.asciidoc index 536b64747..539208457 100644 --- a/docs/connecting.asciidoc +++ b/docs/connecting.asciidoc @@ -177,6 +177,29 @@ const client = new Client({ }) ---- +[discrete] +[[auth-ca-fingerprint]] +==== CA fingerprint + +You can configure the client to only trust certificates that are signed by a specific CA certificate ( CA certificate pinning ) by providing a `caFingerprint` option. This will verify that the fingerprint of the CA certificate that has signed the certificate of the server matches the supplied value. +a `caFingerprint` option, which will verify the supplied certificate authority fingerprint. +You must configure a SHA256 digest. + +[source,js] +---- +const { Client } = require('@elastic/elasticsearch') +const client = new Client({ + node: 'https://example.com' + auth: { ... }, + // the fingerprint (SHA256) of the CA certificate that is used to sign the certificate that the Elasticsearch node presents for TLS. + caFingerprint: '20:0D:CA:FA:76:...', + ssl: { + // might be required if it's a self-signed certificate + rejectUnauthorized: false + } +}) +---- + [discrete] [[client-usage]] === Usage diff --git a/index.d.ts b/index.d.ts index 061fec764..83a1da088 100644 --- a/index.d.ts +++ b/index.d.ts @@ -118,6 +118,7 @@ interface ClientOptions { password?: string; }; disablePrototypePoisoningProtection?: boolean | 'proto' | 'constructor'; + caFingerprint?: string; } declare class Client { diff --git a/index.js b/index.js index 60c44014a..11b60a56d 100644 --- a/index.js +++ b/index.js @@ -102,6 +102,7 @@ class Client extends ESAPI { suggestCompression: false, compression: false, ssl: null, + caFingerprint: null, agent: null, headers: {}, nodeFilter: null, @@ -116,6 +117,10 @@ class Client extends ESAPI { disablePrototypePoisoningProtection: false }, opts) + if (options.caFingerprint !== null && isHttpConnection(opts.node || opts.nodes)) { + throw new ConfigurationError('You can\'t configure the caFingerprint with a http connection') + } + if (process.env.ELASTIC_CLIENT_APIVERSIONING === 'true') { options.headers = Object.assign({ accept: 'application/vnd.elasticsearch+json; compatible-with=7' }, options.headers) } @@ -146,6 +151,7 @@ class Client extends ESAPI { Connection: options.Connection, auth: options.auth, emit: this[kEventEmitter].emit.bind(this[kEventEmitter]), + caFingerprint: options.caFingerprint, sniffEnabled: options.sniffInterval !== false || options.sniffOnStart !== false || options.sniffOnConnectionFault !== false @@ -315,6 +321,14 @@ function getAuth (node) { } } +function isHttpConnection (node) { + if (Array.isArray(node)) { + return node.some((n) => new URL(n).protocol === 'http:') + } else { + return new URL(node).protocol === 'http:' + } +} + const events = { RESPONSE: 'response', REQUEST: 'request', diff --git a/lib/Connection.d.ts b/lib/Connection.d.ts index 933a6a8eb..6b5c6cb7d 100644 --- a/lib/Connection.d.ts +++ b/lib/Connection.d.ts @@ -40,6 +40,7 @@ export interface ConnectionOptions { roles?: ConnectionRoles; auth?: BasicAuth | ApiKeyAuth; proxy?: string | URL; + caFingerprint?: string; } interface ConnectionRoles { diff --git a/lib/Connection.js b/lib/Connection.js index 6eda7c539..88a154ae6 100644 --- a/lib/Connection.js +++ b/lib/Connection.js @@ -42,6 +42,7 @@ class Connection { this.headers = prepareHeaders(opts.headers, opts.auth) this.deadCount = 0 this.resurrectTimeout = 0 + this.caFingerprint = opts.caFingerprint this._openRequests = 0 this._status = opts.status || Connection.statuses.ALIVE @@ -123,10 +124,36 @@ class Connection { callback(new RequestAbortedError(), null) } + const onSocket = socket => { + /* istanbul ignore else */ + if (!socket.isSessionReused()) { + socket.once('secureConnect', () => { + const issuerCertificate = getIssuerCertificate(socket) + /* istanbul ignore next */ + if (issuerCertificate == null) { + onError(new Error('Invalid or malformed certificate')) + request.once('error', () => {}) // we need to catch the request aborted error + return request.abort() + } + + // Check if fingerprint matches + /* istanbul ignore else */ + if (this.caFingerprint !== issuerCertificate.fingerprint256) { + onError(new Error('Server certificate CA fingerprint does not match the value configured in caFingerprint')) + request.once('error', () => {}) // we need to catch the request aborted error + return request.abort() + } + }) + } + } + request.on('response', onResponse) request.on('timeout', onTimeout) request.on('error', onError) request.on('abort', onAbort) + if (this.caFingerprint != null) { + request.on('socket', onSocket) + } // Disables the Nagle algorithm request.setNoDelay(true) @@ -152,6 +179,7 @@ class Connection { request.removeListener('timeout', onTimeout) request.removeListener('error', onError) request.removeListener('abort', onAbort) + request.removeListener('socket', onSocket) cleanedListeners = true } } @@ -340,5 +368,25 @@ function prepareHeaders (headers = {}, auth) { return headers } +function getIssuerCertificate (socket) { + let certificate = socket.getPeerCertificate(true) + while (certificate && Object.keys(certificate).length > 0) { + // invalid certificate + if (certificate.issuerCertificate == null) { + return null + } + + // We have reached the root certificate. + // In case of self-signed certificates, `issuerCertificate` may be a circular reference. + if (certificate.fingerprint256 === certificate.issuerCertificate.fingerprint256) { + break + } + + // continue the loop + certificate = certificate.issuerCertificate + } + return certificate +} + module.exports = Connection -module.exports.internals = { prepareHeaders } +module.exports.internals = { prepareHeaders, getIssuerCertificate } diff --git a/lib/pool/BaseConnectionPool.js b/lib/pool/BaseConnectionPool.js index 2b3081153..80e80a318 100644 --- a/lib/pool/BaseConnectionPool.js +++ b/lib/pool/BaseConnectionPool.js @@ -36,6 +36,7 @@ class BaseConnectionPool { this._ssl = opts.ssl this._agent = opts.agent this._proxy = opts.proxy || null + this._caFingerprint = opts.caFingerprint || null } getConnection () { @@ -72,6 +73,8 @@ class BaseConnectionPool { if (opts.agent == null) opts.agent = this._agent /* istanbul ignore else */ if (opts.proxy == null) opts.proxy = this._proxy + /* istanbul ignore else */ + if (opts.caFingerprint == null) opts.caFingerprint = this._caFingerprint const connection = new this.Connection(opts) diff --git a/lib/pool/index.d.ts b/lib/pool/index.d.ts index c1ebbdad6..7b3f62f94 100644 --- a/lib/pool/index.d.ts +++ b/lib/pool/index.d.ts @@ -31,6 +31,7 @@ interface BaseConnectionPoolOptions { auth?: BasicAuth | ApiKeyAuth; emit: (event: string | symbol, ...args: any[]) => boolean; Connection: typeof Connection; + caFingerprint?: string; } interface ConnectionPoolOptions extends BaseConnectionPoolOptions { diff --git a/test/acceptance/sniff.test.js b/test/acceptance/sniff.test.js index 5dfaa3f76..d18e8a2a9 100644 --- a/test/acceptance/sniff.test.js +++ b/test/acceptance/sniff.test.js @@ -77,6 +77,7 @@ test('Should update the connection pool', t => { t.same(hosts[i], { url: new URL(nodes[id].url), id: id, + caFingerprint: null, roles: { master: true, data: true, diff --git a/test/unit/client.test.js b/test/unit/client.test.js index d9a26c110..fbc45dc82 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -26,6 +26,7 @@ const intoStream = require('into-stream') const { ConnectionPool, Transport, Connection, errors } = require('../../index') const { CloudConnectionPool } = require('../../lib/pool') const { Client, buildServer } = require('../utils') + let clientVersion = require('../../package.json').version if (clientVersion.includes('-')) { clientVersion = clientVersion.slice(0, clientVersion.indexOf('-')) + 'p' @@ -1498,3 +1499,88 @@ test('Bearer auth', t => { }) }) }) + +test('Check server fingerprint (success)', t => { + t.plan(1) + + function handler (req, res) { + res.end('ok') + } + + buildServer(handler, { secure: true }, ({ port, caFingerprint }, server) => { + const client = new Client({ + node: `https://localhost:${port}`, + caFingerprint + }) + + client.info((err, res) => { + t.error(err) + server.stop() + }) + }) +}) + +test('Check server fingerprint (failure)', t => { + t.plan(2) + + function handler (req, res) { + res.end('ok') + } + + buildServer(handler, { secure: true }, ({ port }, server) => { + const client = new Client({ + node: `https://localhost:${port}`, + caFingerprint: 'FO:OB:AR' + }) + + client.info((err, res) => { + t.ok(err instanceof errors.ConnectionError) + t.equal(err.message, 'Server certificate CA fingerprint does not match the value configured in caFingerprint') + server.stop() + }) + }) +}) + +test('caFingerprint can\'t be configured over http / 1', t => { + t.plan(2) + + try { + new Client({ // eslint-disable-line + node: 'http://localhost:9200', + caFingerprint: 'FO:OB:AR' + }) + t.fail('shuld throw') + } catch (err) { + t.ok(err instanceof errors.ConfigurationError) + t.equal(err.message, 'You can\'t configure the caFingerprint with a http connection') + } +}) + +test('caFingerprint can\'t be configured over http / 2', t => { + t.plan(2) + + try { + new Client({ // eslint-disable-line + nodes: ['http://localhost:9200'], + caFingerprint: 'FO:OB:AR' + }) + t.fail('should throw') + } catch (err) { + t.ok(err instanceof errors.ConfigurationError) + t.equal(err.message, 'You can\'t configure the caFingerprint with a http connection') + } +}) + +test('caFingerprint can\'t be configured over http / 3', t => { + t.plan(1) + + try { + new Client({ // eslint-disable-line + nodes: ['https://localhost:9200'], + caFingerprint: 'FO:OB:AR' + }) + t.pass('should not throw') + } catch (err) { + t.fail('shuld not throw') + } +}) diff --git a/test/unit/connection.test.js b/test/unit/connection.test.js index a951a9e6b..5ba1c494a 100644 --- a/test/unit/connection.test.js +++ b/test/unit/connection.test.js @@ -28,7 +28,8 @@ const hpagent = require('hpagent') const intoStream = require('into-stream') const { buildServer } = require('../utils') const Connection = require('../../lib/Connection') -const { TimeoutError, ConfigurationError, RequestAbortedError } = require('../../lib/errors') +const { TimeoutError, ConfigurationError, RequestAbortedError, ConnectionError } = require('../../lib/errors') +const { getIssuerCertificate } = Connection.internals test('Basic (http)', t => { t.plan(4) @@ -947,3 +948,139 @@ test('Abort with a slow body', t => { setImmediate(() => request.abort()) }) + +test('Check server fingerprint (success)', t => { + t.plan(2) + + function handler (req, res) { + res.end('ok') + } + + buildServer(handler, { secure: true }, ({ port, caFingerprint }, server) => { + const connection = new Connection({ + url: new URL(`https://localhost:${port}`), + caFingerprint + }) + + connection.request({ + path: '/hello', + method: 'GET' + }, (err, res) => { + t.error(err) + + let payload = '' + res.setEncoding('utf8') + res.on('data', chunk => { payload += chunk }) + res.on('error', err => t.fail(err)) + res.on('end', () => { + t.equal(payload, 'ok') + server.stop() + }) + }) + }) +}) + +test('Check server fingerprint (failure)', t => { + t.plan(2) + + function handler (req, res) { + res.end('ok') + } + + buildServer(handler, { secure: true }, ({ port }, server) => { + const connection = new Connection({ + url: new URL(`https://localhost:${port}`), + caFingerprint: 'FO:OB:AR' + }) + + connection.request({ + path: '/hello', + method: 'GET' + }, (err, res) => { + t.ok(err instanceof ConnectionError) + t.equal(err.message, 'Server certificate CA fingerprint does not match the value configured in caFingerprint') + server.stop() + }) + }) +}) + +test('getIssuerCertificate returns the root CA', t => { + t.plan(2) + const issuerCertificate = { + fingerprint256: 'BA:ZF:AZ', + subject: { + C: '1', + ST: '1', + L: '1', + O: '1', + OU: '1', + CN: '1' + }, + issuer: { + C: '1', + ST: '1', + L: '1', + O: '1', + OU: '1', + CN: '1' + } + } + issuerCertificate.issuerCertificate = issuerCertificate + + const socket = { + getPeerCertificate (bool) { + t.ok(bool) + return { + fingerprint256: 'FO:OB:AR', + subject: { + C: '1', + ST: '1', + L: '1', + O: '1', + OU: '1', + CN: '1' + }, + issuer: { + C: '2', + ST: '2', + L: '2', + O: '2', + OU: '2', + CN: '2' + }, + issuerCertificate + } + } + } + t.same(getIssuerCertificate(socket), issuerCertificate) +}) + +test('getIssuerCertificate detects invalid/malformed certificates', t => { + t.plan(2) + const socket = { + getPeerCertificate (bool) { + t.ok(bool) + return { + fingerprint256: 'FO:OB:AR', + subject: { + C: '1', + ST: '1', + L: '1', + O: '1', + OU: '1', + CN: '1' + }, + issuer: { + C: '2', + ST: '2', + L: '2', + O: '2', + OU: '2', + CN: '2' + } + // missing issuerCertificate + } + } + } + t.equal(getIssuerCertificate(socket), null) +}) diff --git a/test/utils/buildServer.js b/test/utils/buildServer.js index b47b2fec2..ef907c05f 100644 --- a/test/utils/buildServer.js +++ b/test/utils/buildServer.js @@ -19,6 +19,7 @@ 'use strict' +const crypto = require('crypto') const debug = require('debug')('elasticsearch-test') const stoppable = require('stoppable') @@ -35,6 +36,13 @@ const secureOpts = { cert: readFileSync(join(__dirname, '..', 'fixtures', 'https.cert'), 'utf8') } +const caFingerprint = getFingerprint(secureOpts.cert + .split('\n') + .slice(1, -1) + .map(line => line.trim()) + .join('') +) + let id = 0 function buildServer (handler, opts, cb) { const serverId = id++ @@ -58,7 +66,7 @@ function buildServer (handler, opts, cb) { server.listen(0, () => { const port = server.address().port debug(`Server '${serverId}' booted on port ${port}`) - resolve([Object.assign({}, secureOpts, { port }), server]) + resolve([Object.assign({}, secureOpts, { port, caFingerprint }), server]) }) }) } else { @@ -70,4 +78,11 @@ function buildServer (handler, opts, cb) { } } +function getFingerprint (content, inputEncoding = 'base64', outputEncoding = 'hex') { + const shasum = crypto.createHash('sha256') + shasum.update(content, inputEncoding) + const res = shasum.digest(outputEncoding) + return res.toUpperCase().match(/.{1,2}/g).join(':') +} + module.exports = buildServer