Repository: elastic/elasticsearch-js
Pull request #1499 (2021-07-22 12:27:35)

Support CA fingerprint validation
As titled.

Usage: 
```js
const client = new Client({
  node: 'https://localhost:9200',
  caFingerprint: 'string'
})
```

TODO:
- [x] More unit test
- [x] Docs
- [ ] ~~Integration test with Elasticsearch~~


delvedor: > Appreciate that this is node throwing an error but could we set rejectUnauthorized to false when caFingerprint option is set on the client to avoid this? Is there another option you can think of?

I'm fine to setting `rejectUnauthorized` by default when `caFingerprint` is configured, I'll do it :)
jkakavas: > As discussed offline, let's leave this as opt in and let the consumer decide whether to disable rejectUnauthorized or not for now.

Just validating my understanding that Kibana will set this so that the users won't need to configure something extra. Can this be set per connection and only set it for the initial call to the enroll API ? We wouldn't want to set this globally off for Kibana
thomheymann: > > As discussed offline, let's leave this as opt in and let the consumer decide whether to disable rejectUnauthorized or not for now.
> 
> Just validating my understanding that Kibana will set this so that the users won't need to configure something extra. Can this be set per connection and only set it for the initial call to the enroll API ? We wouldn't want to set this globally off for Kibana

Correct, Kibana will disable `rejectUnauthorized` for the enrolment call so that we can connect to Elasticsearch until we have established trust. 

All other requests to Elasticsearch will keep that security check enabled. 

Patch:
diff --git a/docs/basic-config.asciidoc b/docs/basic-config.asciidoc
index e4e1d301..c800b38c 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 529046e3..819e3a64 100644
--- a/docs/connecting.asciidoc
+++ b/docs/connecting.asciidoc
@@ -176,6 +176,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]]
diff --git a/docs/transport.asciidoc b/docs/transport.asciidoc
index f4fc722c..308e7098 100644
--- a/docs/transport.asciidoc
+++ b/docs/transport.asciidoc
@@ -32,3 +32,10 @@ class MyTransport extends Transport {
 }
 ----
 
+==== Supported content types
+
+- `application/json`, in this case the transport will return a plain JavaScript object
+- `text/plain`, in this case the transport will return a plain string
+- `application/vnd.mapbox-vector-tile`, in this case the transport will return a Buffer
+- `application/vnd.elasticsearch+json`, in this case the transport will return a plain JavaScript object
+
diff --git a/index.d.ts b/index.d.ts
index e7d00efe..0dae7748 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 60c44014..11b60a56 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 933a6a8e..6b5c6cb7 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 6eda7c53..88a154ae 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/Transport.d.ts b/lib/Transport.d.ts
index 912dd96d..2313bf23 100644
--- a/lib/Transport.d.ts
+++ b/lib/Transport.d.ts
@@ -155,8 +155,8 @@ export default class Transport {
   _nextSniff: number;
   _isSniffing: boolean;
   constructor(opts: TransportOptions);
-  request(params: TransportRequestParams, options?: TransportRequestOptions): TransportRequestPromise<ApiResponse>;
-  request(params: TransportRequestParams, options?: TransportRequestOptions, callback?: (err: ApiError, result: ApiResponse) => void): TransportRequestCallback;
+  request<TResponse = Record<string, any>, TContext = Context>(params: TransportRequestParams, options?: TransportRequestOptions): TransportRequestPromise<ApiResponse<TResponse, TContext>>;
+  request<TResponse = Record<string, any>, TContext = Context>(params: TransportRequestParams, options?: TransportRequestOptions, callback?: (err: ApiError, result: ApiResponse<TResponse, TContext>) => void): TransportRequestCallback;
   getConnection(opts: TransportGetConnectionOptions): Connection | null;
   sniff(opts?: TransportSniffOptions, callback?: (...args: any[]) => void): void;
 }
diff --git a/lib/Transport.js b/lib/Transport.js
index cd2d7584..3e6d9fd3 100644
--- a/lib/Transport.js
+++ b/lib/Transport.js
@@ -237,6 +237,7 @@ class Transport {
 
       const contentEncoding = (result.headers['content-encoding'] || '').toLowerCase()
       const isCompressed = contentEncoding.indexOf('gzip') > -1 || contentEncoding.indexOf('deflate') > -1
+      const isVectorTile = (result.headers['content-type'] || '').indexOf('application/vnd.mapbox-vector-tile') > -1
 
       /* istanbul ignore else */
       if (result.headers['content-length'] !== undefined) {
@@ -255,8 +256,9 @@ class Transport {
       }
       // if the response is compressed, we must handle it
       // as buffer for allowing decompression later
-      let payload = isCompressed ? [] : ''
-      const onData = isCompressed
+      // while if it's a vector tile, we should return it as buffer
+      let payload = isCompressed || isVectorTile ? [] : ''
+      const onData = isCompressed || isVectorTile
         ? chunk => { payload.push(chunk) }
         : chunk => { payload += chunk }
       const onEnd = err => {
@@ -272,7 +274,7 @@ class Transport {
         if (isCompressed) {
           unzip(Buffer.concat(payload), onBody)
         } else {
-          onBody(null, payload)
+          onBody(null, isVectorTile ? Buffer.concat(payload) : payload)
         }
       }
 
@@ -281,7 +283,7 @@ class Transport {
         onEnd(new Error('Response aborted while reading the body'))
       }
 
-      if (!isCompressed) {
+      if (!isCompressed && !isVectorTile) {
         response.setEncoding('utf8')
       }
 
@@ -297,7 +299,9 @@ class Transport {
         this.emit('response', err, result)
         return callback(err, result)
       }
-      if (Buffer.isBuffer(payload)) {
+
+      const isVectorTile = (result.headers['content-type'] || '').indexOf('application/vnd.mapbox-vector-tile') > -1
+      if (Buffer.isBuffer(payload) && !isVectorTile) {
         payload = payload.toString()
       }
       const isHead = params.method === 'HEAD'
diff --git a/lib/pool/BaseConnectionPool.js b/lib/pool/BaseConnectionPool.js
index 2b308115..80e80a31 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 c1ebbdad..7b3f62f9 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 {


Test patch:
diff --git a/test/acceptance/sniff.test.js b/test/acceptance/sniff.test.js
index 5dfaa3f7..d18e8a2a 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 d9a26c11..fbc45dc8 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 a951a9e6..5ba1c494 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/unit/transport.test.js b/test/unit/transport.test.js
index c617bc3a..e1a6aaa9 100644
--- a/test/unit/transport.test.js
+++ b/test/unit/transport.test.js
@@ -2689,3 +2689,76 @@ test('The callback with a sync error should be called in the next tick - ndjson'
   t.type(transportReturn.catch, 'function')
   t.type(transportReturn.abort, 'function')
 })
+
+test('Support mapbox vector tile', t => {
+  t.plan(2)
+  const mvtContent = 'GoMCCgRtZXRhEikSFAAAAQACAQMBBAAFAgYDBwAIBAkAGAMiDwkAgEAagEAAAP8//z8ADxoOX3NoYXJkcy5mYWlsZWQaD19zaGFyZHMuc2tpcHBlZBoSX3NoYXJkcy5zdWNjZXNzZnVsGg1fc2hhcmRzLnRvdGFsGhlhZ2dyZWdhdGlvbnMuX2NvdW50LmNvdW50GhdhZ2dyZWdhdGlvbnMuX2NvdW50LnN1bRoTaGl0cy50b3RhbC5yZWxhdGlvbhoQaGl0cy50b3RhbC52YWx1ZRoJdGltZWRfb3V0GgR0b29rIgIwACICMAIiCRkAAAAAAAAAACIECgJlcSICOAAogCB4Ag=='
+
+  function handler (req, res) {
+    res.setHeader('Content-Type', 'application/vnd.mapbox-vector-tile')
+    res.end(Buffer.from(mvtContent, 'base64'))
+  }
+
+  buildServer(handler, ({ port }, server) => {
+    const pool = new ConnectionPool({ Connection })
+    pool.addConnection(`http://localhost:${port}`)
+
+    const transport = new Transport({
+      emit: () => {},
+      connectionPool: pool,
+      serializer: new Serializer(),
+      maxRetries: 3,
+      requestTimeout: 30000,
+      sniffInterval: false,
+      sniffOnStart: false
+    })
+    skipProductCheck(transport)
+
+    transport.request({
+      method: 'GET',
+      path: '/hello'
+    }, (err, { body }) => {
+      t.error(err)
+      t.same(body.toString('base64'), Buffer.from(mvtContent, 'base64').toString('base64'))
+      server.stop()
+    })
+  })
+})
+
+test('Compressed mapbox vector tile', t => {
+  t.plan(2)
+  const mvtContent = 'GoMCCgRtZXRhEikSFAAAAQACAQMBBAAFAgYDBwAIBAkAGAMiDwkAgEAagEAAAP8//z8ADxoOX3NoYXJkcy5mYWlsZWQaD19zaGFyZHMuc2tpcHBlZBoSX3NoYXJkcy5zdWNjZXNzZnVsGg1fc2hhcmRzLnRvdGFsGhlhZ2dyZWdhdGlvbnMuX2NvdW50LmNvdW50GhdhZ2dyZWdhdGlvbnMuX2NvdW50LnN1bRoTaGl0cy50b3RhbC5yZWxhdGlvbhoQaGl0cy50b3RhbC52YWx1ZRoJdGltZWRfb3V0GgR0b29rIgIwACICMAIiCRkAAAAAAAAAACIECgJlcSICOAAogCB4Ag=='
+
+  function handler (req, res) {
+    const body = gzipSync(Buffer.from(mvtContent, 'base64'))
+    res.setHeader('Content-Type', 'application/vnd.mapbox-vector-tile')
+    res.setHeader('Content-Encoding', 'gzip')
+    res.setHeader('Content-Length', Buffer.byteLength(body))
+    res.end(body)
+  }
+
+  buildServer(handler, ({ port }, server) => {
+    const pool = new ConnectionPool({ Connection })
+    pool.addConnection(`http://localhost:${port}`)
+
+    const transport = new Transport({
+      emit: () => {},
+      connectionPool: pool,
+      serializer: new Serializer(),
+      maxRetries: 3,
+      requestTimeout: 30000,
+      sniffInterval: false,
+      sniffOnStart: false
+    })
+    skipProductCheck(transport)
+
+    transport.request({
+      method: 'GET',
+      path: '/hello'
+    }, (err, { body }) => {
+      t.error(err)
+      t.same(body.toString('base64'), Buffer.from(mvtContent, 'base64').toString('base64'))
+      server.stop()
+    })
+  })
+})
diff --git a/test/utils/buildServer.js b/test/utils/buildServer.js
index b47b2fec..ef907c05 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