Skip to content

Commit d54e45a

Browse files
committed
implemented certificate pinning
We use the `assert_fingerprint` flag when creating the urllib3 connection. The fingerprint is a hash of the pem encoded certificate of the server. fixes #386
1 parent 29f12ce commit d54e45a

File tree

7 files changed

+109
-3
lines changed

7 files changed

+109
-3
lines changed

docs/configuration.asciidoc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,9 +582,23 @@ This disables most of the tracing functionality, but can be useful to debug poss
582582

583583
By default, the agent verifies the SSL certificate if you use an HTTPS connection to the APM server.
584584
Verification can be disabled by changing this setting to `False`.
585+
This setting is ignored when <<config-server-cert,`server_cert`>> is set.
585586

586587
NOTE: SSL certificate verification is only available in Python 2.7.9+ and Python 3.4.3+.
587588

589+
[float]
590+
[[config-server-cert]]
591+
=== `ELASTIC_APM_SERVER_CERT`
592+
593+
[options="header"]
594+
|============
595+
| Environment | Django/Flask | Default
596+
| `ELASTIC_APM_SERVER_CERT` | `SERVER_CERT` | `None`
597+
|============
598+
599+
If you have configured your APM Server with a self signed TLS certificate, or you
600+
just wish to pin the server certificate, you can specify the path to the PEM-encoded
601+
certificate via the `ELASTIC_APM_SERVER_CERT` configuration.
588602

589603
[float]
590604
[[config-django-specific]]

elasticapm/conf/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,18 @@ def __call__(self, value, field_name):
145145
return value
146146

147147

148+
class FileIsReadableValidator(object):
149+
def __call__(self, value, field_name):
150+
value = os.path.normpath(value)
151+
if not os.path.exists(value):
152+
raise ConfigurationError("{} does not exist".format(value), field_name)
153+
elif not os.path.isfile(value):
154+
raise ConfigurationError("{} is not a file".format(value), field_name)
155+
elif not os.access(value):
156+
raise ConfigurationError("{} is not accessible".format(value), field_name)
157+
return value
158+
159+
148160
class _ConfigBase(object):
149161
_NO_VALUE = object() # sentinel object
150162

@@ -188,6 +200,7 @@ class Config(_ConfigBase):
188200
secret_token = _ConfigValue("SECRET_TOKEN")
189201
debug = _BoolConfigValue("DEBUG", default=False)
190202
server_url = _ConfigValue("SERVER_URL", default="http://localhost:8200", required=True)
203+
server_cert = _ConfigValue("SERVER_CERT", default=None, required=False, validators=[FileIsReadableValidator()])
191204
verify_server_cert = _BoolConfigValue("VERIFY_SERVER_CERT", default=True)
192205
include_paths = _ListConfigValue("INCLUDE_PATHS")
193206
exclude_paths = _ListConfigValue("EXCLUDE_PATHS", default=compat.get_default_library_patters())

elasticapm/transport/http.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# -*- coding: utf-8 -*-
2+
import hashlib
23
import logging
34
import os
45
import ssl
@@ -9,7 +10,7 @@
910

1011
from elasticapm.transport.base import TransportException
1112
from elasticapm.transport.http_base import AsyncHTTPTransportBase, HTTPTransportBase
12-
from elasticapm.utils import compat
13+
from elasticapm.utils import compat, read_pem_file
1314

1415
logger = logging.getLogger("elasticapm.transport.http")
1516

@@ -18,14 +19,20 @@ class Transport(HTTPTransportBase):
1819
def __init__(self, url, **kwargs):
1920
super(Transport, self).__init__(url, **kwargs)
2021
pool_kwargs = {"cert_reqs": "CERT_REQUIRED", "ca_certs": certifi.where(), "block": True}
21-
if not self._verify_server_cert:
22+
if self._server_cert:
23+
pool_kwargs.update(
24+
{"assert_fingerprint": self.cert_fingerprint, "assert_hostname": False, "cert_reqs": ssl.CERT_NONE}
25+
)
26+
del pool_kwargs["ca_certs"]
27+
elif not self._verify_server_cert:
2228
pool_kwargs["cert_reqs"] = ssl.CERT_NONE
2329
pool_kwargs["assert_hostname"] = False
2430
proxy_url = os.environ.get("HTTPS_PROXY", os.environ.get("HTTP_PROXY"))
2531
if proxy_url:
2632
self.http = urllib3.ProxyManager(proxy_url, **pool_kwargs)
2733
else:
2834
self.http = urllib3.PoolManager(**pool_kwargs)
35+
self._cert_fingerprint = None
2936

3037
def send(self, data):
3138
response = None
@@ -66,6 +73,16 @@ def send(self, data):
6673
if response:
6774
response.close()
6875

76+
@property
77+
def cert_fingerprint(self):
78+
if self._server_cert:
79+
with open(self._server_cert, "rb") as f:
80+
cert_data = read_pem_file(f)
81+
digest = hashlib.sha256()
82+
digest.update(cert_data)
83+
return digest.hexdigest()
84+
return None
85+
6986

7087
class AsyncTransport(AsyncHTTPTransportBase, Transport):
7188
async_mode = True

elasticapm/transport/http_base.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@
55

66
class HTTPTransportBase(Transport):
77
def __init__(
8-
self, url, verify_server_cert=True, compress_level=5, metadata=None, headers=None, timeout=None, **kwargs
8+
self,
9+
url,
10+
verify_server_cert=True,
11+
compress_level=5,
12+
metadata=None,
13+
headers=None,
14+
timeout=None,
15+
server_cert=None,
16+
**kwargs
917
):
1018
self._url = url
1119
self._verify_server_cert = verify_server_cert
20+
self._server_cert = server_cert
1221
self._timeout = timeout
1322
self._headers = {
1423
k.encode("ascii")

elasticapm/utils/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
:copyright: (c) 2010 by the Sentry Team, see AUTHORS for more details.
99
:license: BSD, see LICENSE for more details.
1010
"""
11+
import base64
1112
import os
1213
from functools import partial
1314

@@ -95,3 +96,14 @@ def get_url_dict(url):
9596
if query:
9697
url_dict["search"] = encoding.keyword_field("?" + query)
9798
return url_dict
99+
100+
101+
def read_pem_file(file_obj):
102+
cert = b""
103+
for line in file_obj:
104+
if line.startswith(b"-----BEGIN CERTIFICATE-----"):
105+
break
106+
for line in file_obj:
107+
if not line.startswith(b"-----END CERTIFICATE-----"):
108+
cert += line.strip()
109+
return base64.b64decode(cert)

tests/transports/test_urllib3.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import os
2+
13
import mock
24
import pytest
35
import urllib3.poolmanager
6+
from pytest_localserver.https import DEFAULT_CERTIFICATE
47
from urllib3.exceptions import MaxRetryError, TimeoutError
58
from urllib3_mock import Responses
69

@@ -106,3 +109,23 @@ def test_ssl_verify_disable(waiting_httpsserver):
106109
transport = Transport(waiting_httpsserver.url, verify_server_cert=False)
107110
url = transport.send(compat.b("x"))
108111
assert url == "https://example.com/foo"
112+
113+
114+
def test_ssl_cert_pinning(waiting_httpsserver):
115+
waiting_httpsserver.serve_content(code=202, content="", headers={"Location": "https://example.com/foo"})
116+
transport = Transport(waiting_httpsserver.url, server_cert=DEFAULT_CERTIFICATE, verify_server_cert=True)
117+
url = transport.send(compat.b("x"))
118+
assert url == "https://example.com/foo"
119+
120+
121+
def test_ssl_cert_pinning_fails(waiting_httpsserver):
122+
waiting_httpsserver.serve_content(code=202, content="", headers={"Location": "https://example.com/foo"})
123+
transport = Transport(
124+
waiting_httpsserver.url,
125+
server_cert=os.path.join(os.path.dirname(__file__), "wrong_cert.pem"),
126+
verify_server_cert=True,
127+
)
128+
with pytest.raises(TransportException) as exc_info:
129+
transport.send(compat.b("x"))
130+
131+
assert "Fingerprints did not match" in exc_info.value.args[0]

tests/transports/wrong_cert.pem

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIC9DCCAdygAwIBAgIRAPrpYLvUsk2GY0O0HrSy1tMwDQYJKoZIhvcNAQELBQAw
3+
EjEQMA4GA1UEChMHQWNtZSBDbzAeFw0xODExMjIwNjQyNDNaFw0xOTExMjIwNjQy
4+
NDNaMBIxEDAOBgNVBAoTB0FjbWUgQ28wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
5+
ggEKAoIBAQDzcGv/FzFuZTFZ882wWJwYhkah+LiVwXfcN3ZkkzXGI5emz2ghBhkx
6+
RXymZVXj7nX8KktXgPjIF+JUSuDexlp20dcy4Xq9Kfn/zZ025S3l+JqmByboGdU0
7+
cCl7t6nUvySPvVRVWxHuByVCHEWKU+ELR4zlVmREgdHlYj6UGeYsou4pUmYrQkrF
8+
Iw1o+LqTtQ70nUtr82u7qG6i76h0gUYI0bRxmkAwW2kSAsNSM2Hgqou+I+zX0T9v
9+
X6HGsh4pTU6XVJ/r+klBOZM0wXNh9lRQx2+3J8mTxKadXVrBCry333OIRIO9Vv56
10+
9pEGNHsUqvWMZhpKs7f323MNkMdIu+olAgMBAAGjRTBDMA4GA1UdDwEB/wQEAwIF
11+
oDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMA4GA1UdEQQHMAWC
12+
A2ZvbzANBgkqhkiG9w0BAQsFAAOCAQEAb9hSwjlOzSRWJ4BdJfuSBUjqRUTpulGX
13+
gGzNKH9PZM8wlpRh/Xiv09jN08XpW4pmnDFOMGZnYk4OR3et4pRmlJ2v8yPRsydD
14+
pr4BrKMykY+jsngmsp7tzZBt+vHACyqplD8K8SivIuxsXrbUu9ekkMemv0G82TmO
15+
ZUCerakCm8sojmQOTfb7ZqAfZifnGwTRi+6y3TCkwIupTL3l/S8E42L7l8gg+xGU
16+
5nYYHVgyZroEuoJtGVmvakJJpGLcEzD2ai4X212qKC1dp9cjzfWgWxImn9jivYqy
17+
cxsI6aaSYdZaM2JkmtnDLV0auBs0r8SN2nluFxxEStpK/zxn8SH5Sw==
18+
-----END CERTIFICATE-----

0 commit comments

Comments
 (0)