Skip to content

Commit 90f3eac

Browse files
committed
implemented certificate pinning (#405)
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 closes #405
1 parent 8ad77b4 commit 90f3eac

File tree

8 files changed

+121
-4
lines changed

8 files changed

+121
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
[Check the diff](https://github.com/elastic/apm-agent-python/compare/v4.1.0...master)
55

66
* Implemented a new transport queue, which should avoid certain deadlock scenarios (#411)
7+
* Implemented server certificate pinning (#405)
78
* Moved context.url to context.http.url for requests/urllib3 spans (#393, #394)
89
* Added support for using route as transaction name in Django 2.2+ (#86, #396)
910
* Added some randomness to time between requests to APM Server (#426)

docs/configuration.asciidoc

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

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

589590
NOTE: SSL certificate verification is only available in Python 2.7.9+ and Python 3.4.3+.
590591

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

592606
[float]
593607
[[config-django-specific]]

elasticapm/conf/__init__.py

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

167167

168+
class FileIsReadableValidator(object):
169+
def __call__(self, value, field_name):
170+
value = os.path.normpath(value)
171+
if not os.path.exists(value):
172+
raise ConfigurationError("{} does not exist".format(value), field_name)
173+
elif not os.path.isfile(value):
174+
raise ConfigurationError("{} is not a file".format(value), field_name)
175+
elif not os.access(value):
176+
raise ConfigurationError("{} is not accessible".format(value), field_name)
177+
return value
178+
179+
168180
class _ConfigBase(object):
169181
_NO_VALUE = object() # sentinel object
170182

@@ -208,6 +220,7 @@ class Config(_ConfigBase):
208220
secret_token = _ConfigValue("SECRET_TOKEN")
209221
debug = _BoolConfigValue("DEBUG", default=False)
210222
server_url = _ConfigValue("SERVER_URL", default="http://localhost:8200", required=True)
223+
server_cert = _ConfigValue("SERVER_CERT", default=None, required=False, validators=[FileIsReadableValidator()])
211224
verify_server_cert = _BoolConfigValue("VERIFY_SERVER_CERT", default=True)
212225
include_paths = _ListConfigValue("INCLUDE_PATHS")
213226
exclude_paths = _ListConfigValue("EXCLUDE_PATHS", default=compat.get_default_library_patters())

elasticapm/transport/http.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
3131
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
3232

33+
import hashlib
3334
import logging
3435
import os
3536
import ssl
@@ -40,7 +41,7 @@
4041

4142
from elasticapm.transport.base import TransportException
4243
from elasticapm.transport.http_base import AsyncHTTPTransportBase, HTTPTransportBase
43-
from elasticapm.utils import compat
44+
from elasticapm.utils import compat, read_pem_file
4445

4546
logger = logging.getLogger("elasticapm.transport.http")
4647

@@ -49,7 +50,12 @@ class Transport(HTTPTransportBase):
4950
def __init__(self, url, **kwargs):
5051
super(Transport, self).__init__(url, **kwargs)
5152
pool_kwargs = {"cert_reqs": "CERT_REQUIRED", "ca_certs": certifi.where(), "block": True}
52-
if not self._verify_server_cert:
53+
if self._server_cert:
54+
pool_kwargs.update(
55+
{"assert_fingerprint": self.cert_fingerprint, "assert_hostname": False, "cert_reqs": ssl.CERT_NONE}
56+
)
57+
del pool_kwargs["ca_certs"]
58+
elif not self._verify_server_cert:
5359
pool_kwargs["cert_reqs"] = ssl.CERT_NONE
5460
pool_kwargs["assert_hostname"] = False
5561
proxy_url = os.environ.get("HTTPS_PROXY", os.environ.get("HTTP_PROXY"))
@@ -97,6 +103,16 @@ def send(self, data):
97103
if response:
98104
response.close()
99105

106+
@property
107+
def cert_fingerprint(self):
108+
if self._server_cert:
109+
with open(self._server_cert, "rb") as f:
110+
cert_data = read_pem_file(f)
111+
digest = hashlib.sha256()
112+
digest.update(cert_data)
113+
return digest.hexdigest()
114+
return None
115+
100116

101117
class AsyncTransport(AsyncHTTPTransportBase, Transport):
102118
async_mode = True

elasticapm/transport/http_base.py

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

3737
class HTTPTransportBase(Transport):
3838
def __init__(
39-
self, url, verify_server_cert=True, compress_level=5, metadata=None, headers=None, timeout=None, **kwargs
39+
self,
40+
url,
41+
verify_server_cert=True,
42+
compress_level=5,
43+
metadata=None,
44+
headers=None,
45+
timeout=None,
46+
server_cert=None,
47+
**kwargs
4048
):
4149
self._url = url
4250
self._verify_server_cert = verify_server_cert
51+
self._server_cert = server_cert
4352
self._timeout = timeout
4453
self._headers = {
4554
k.encode("ascii")

elasticapm/utils/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
2929
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
3030

31-
31+
import base64
3232
import os
3333
from functools import partial
3434

@@ -116,3 +116,14 @@ def get_url_dict(url):
116116
if query:
117117
url_dict["search"] = encoding.keyword_field("?" + query)
118118
return url_dict
119+
120+
121+
def read_pem_file(file_obj):
122+
cert = b""
123+
for line in file_obj:
124+
if line.startswith(b"-----BEGIN CERTIFICATE-----"):
125+
break
126+
for line in file_obj:
127+
if not line.startswith(b"-----END CERTIFICATE-----"):
128+
cert += line.strip()
129+
return base64.b64decode(cert)

tests/transports/test_urllib3.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,12 @@
2929
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
3030

3131

32+
import os
33+
3234
import mock
3335
import pytest
3436
import urllib3.poolmanager
37+
from pytest_localserver.https import DEFAULT_CERTIFICATE
3538
from urllib3.exceptions import MaxRetryError, TimeoutError
3639
from urllib3_mock import Responses
3740

@@ -165,3 +168,35 @@ def test_ssl_verify_disable(waiting_httpsserver):
165168
assert url == "https://example.com/foo"
166169
finally:
167170
transport.close()
171+
172+
173+
def test_ssl_cert_pinning(waiting_httpsserver):
174+
waiting_httpsserver.serve_content(code=202, content="", headers={"Location": "https://example.com/foo"})
175+
transport = Transport(waiting_httpsserver.url, server_cert=DEFAULT_CERTIFICATE, verify_server_cert=True)
176+
try:
177+
url = transport.send(compat.b("x"))
178+
assert url == "https://example.com/foo"
179+
finally:
180+
transport.close()
181+
182+
183+
def test_ssl_cert_pinning_fails(waiting_httpsserver):
184+
if compat.PY3:
185+
waiting_httpsserver.serve_content(code=202, content="", headers={"Location": "https://example.com/foo"})
186+
url = waiting_httpsserver.url
187+
else:
188+
# if we use the local test server here, execution blocks somewhere deep in OpenSSL on Python 2.7, presumably
189+
# due to a threading issue that has been fixed in later versions. To avoid that, we have to commit a minor
190+
# cardinal sin here and do an outside request to https://example.com (which will also fail the fingerprint
191+
# assertion).
192+
#
193+
# May the Testing Goat have mercy on our souls.
194+
url = "https://example.com"
195+
transport = Transport(
196+
url, server_cert=os.path.join(os.path.dirname(__file__), "wrong_cert.pem"), verify_server_cert=True
197+
)
198+
with pytest.raises(TransportException) as exc_info:
199+
transport.send(compat.b("x"))
200+
transport.close()
201+
202+
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)