Support CA fingerprint validation (#1499)

Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com>
Co-authored-by: Ioannis Kakavas <ioannis@elastic.co>
This commit is contained in:
Tomas Della Vedova
2021-08-02 11:20:31 +02:00
committed by delvedor
parent 563b7746cd
commit a48ebc9442
12 changed files with 337 additions and 4 deletions

View File

@ -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
})
----

View File

@ -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

1
index.d.ts vendored
View File

@ -118,6 +118,7 @@ interface ClientOptions {
password?: string;
};
disablePrototypePoisoningProtection?: boolean | 'proto' | 'constructor';
caFingerprint?: string;
}
declare class Client {

View File

@ -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',

1
lib/Connection.d.ts vendored
View File

@ -40,6 +40,7 @@ export interface ConnectionOptions {
roles?: ConnectionRoles;
auth?: BasicAuth | ApiKeyAuth;
proxy?: string | URL;
caFingerprint?: string;
}
interface ConnectionRoles {

View File

@ -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 }

View File

@ -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)

1
lib/pool/index.d.ts vendored
View File

@ -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 {

View File

@ -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,

View File

@ -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')
}
})

View File

@ -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)
})

View File

@ -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