Skip to content

implemented certificate pinning #405

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/configuration.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -585,9 +585,23 @@ This disables most of the tracing functionality, but can be useful to debug poss

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

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

[float]
[[config-server-cert]]
=== `ELASTIC_APM_SERVER_CERT`

[options="header"]
|============
| Environment | Django/Flask | Default
| `ELASTIC_APM_SERVER_CERT` | `SERVER_CERT` | `None`
|============

If you have configured your APM Server with a self signed TLS certificate, or you
just wish to pin the server certificate, you can specify the path to the PEM-encoded
certificate via the `ELASTIC_APM_SERVER_CERT` configuration.

[float]
[[config-django-specific]]
Expand Down
13 changes: 13 additions & 0 deletions elasticapm/conf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,18 @@ def __call__(self, value, field_name):
return value


class FileIsReadableValidator(object):
def __call__(self, value, field_name):
value = os.path.normpath(value)
if not os.path.exists(value):
raise ConfigurationError("{} does not exist".format(value), field_name)
elif not os.path.isfile(value):
raise ConfigurationError("{} is not a file".format(value), field_name)
elif not os.access(value):
raise ConfigurationError("{} is not accessible".format(value), field_name)
return value


class _ConfigBase(object):
_NO_VALUE = object() # sentinel object

Expand Down Expand Up @@ -208,6 +220,7 @@ class Config(_ConfigBase):
secret_token = _ConfigValue("SECRET_TOKEN")
debug = _BoolConfigValue("DEBUG", default=False)
server_url = _ConfigValue("SERVER_URL", default="http://localhost:8200", required=True)
server_cert = _ConfigValue("SERVER_CERT", default=None, required=False, validators=[FileIsReadableValidator()])
verify_server_cert = _BoolConfigValue("VERIFY_SERVER_CERT", default=True)
include_paths = _ListConfigValue("INCLUDE_PATHS")
exclude_paths = _ListConfigValue("EXCLUDE_PATHS", default=compat.get_default_library_patters())
Expand Down
20 changes: 18 additions & 2 deletions elasticapm/transport/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

import hashlib
import logging
import os
import ssl
Expand All @@ -40,7 +41,7 @@

from elasticapm.transport.base import TransportException
from elasticapm.transport.http_base import AsyncHTTPTransportBase, HTTPTransportBase
from elasticapm.utils import compat
from elasticapm.utils import compat, read_pem_file

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

Expand All @@ -49,7 +50,12 @@ class Transport(HTTPTransportBase):
def __init__(self, url, **kwargs):
super(Transport, self).__init__(url, **kwargs)
pool_kwargs = {"cert_reqs": "CERT_REQUIRED", "ca_certs": certifi.where(), "block": True}
if not self._verify_server_cert:
if self._server_cert:
pool_kwargs.update(
{"assert_fingerprint": self.cert_fingerprint, "assert_hostname": False, "cert_reqs": ssl.CERT_NONE}
)
del pool_kwargs["ca_certs"]
elif not self._verify_server_cert:
pool_kwargs["cert_reqs"] = ssl.CERT_NONE
pool_kwargs["assert_hostname"] = False
proxy_url = os.environ.get("HTTPS_PROXY", os.environ.get("HTTP_PROXY"))
Expand Down Expand Up @@ -97,6 +103,16 @@ def send(self, data):
if response:
response.close()

@property
def cert_fingerprint(self):
if self._server_cert:
with open(self._server_cert, "rb") as f:
cert_data = read_pem_file(f)
digest = hashlib.sha256()
digest.update(cert_data)
return digest.hexdigest()
return None


class AsyncTransport(AsyncHTTPTransportBase, Transport):
async_mode = True
Expand Down
11 changes: 10 additions & 1 deletion elasticapm/transport/http_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,19 @@

class HTTPTransportBase(Transport):
def __init__(
self, url, verify_server_cert=True, compress_level=5, metadata=None, headers=None, timeout=None, **kwargs
self,
url,
verify_server_cert=True,
compress_level=5,
metadata=None,
headers=None,
timeout=None,
server_cert=None,
**kwargs
):
self._url = url
self._verify_server_cert = verify_server_cert
self._server_cert = server_cert
self._timeout = timeout
self._headers = {
k.encode("ascii")
Expand Down
13 changes: 12 additions & 1 deletion elasticapm/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE


import base64
import os
from functools import partial

Expand Down Expand Up @@ -116,3 +116,14 @@ def get_url_dict(url):
if query:
url_dict["search"] = encoding.keyword_field("?" + query)
return url_dict


def read_pem_file(file_obj):
cert = b""
for line in file_obj:
if line.startswith(b"-----BEGIN CERTIFICATE-----"):
break
for line in file_obj:
if not line.startswith(b"-----END CERTIFICATE-----"):
cert += line.strip()
return base64.b64decode(cert)
35 changes: 35 additions & 0 deletions tests/transports/test_urllib3.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


import os

import mock
import pytest
import urllib3.poolmanager
from pytest_localserver.https import DEFAULT_CERTIFICATE
from urllib3.exceptions import MaxRetryError, TimeoutError
from urllib3_mock import Responses

Expand Down Expand Up @@ -165,3 +168,35 @@ def test_ssl_verify_disable(waiting_httpsserver):
assert url == "https://example.com/foo"
finally:
transport.close()


def test_ssl_cert_pinning(waiting_httpsserver):
waiting_httpsserver.serve_content(code=202, content="", headers={"Location": "https://example.com/foo"})
transport = Transport(waiting_httpsserver.url, server_cert=DEFAULT_CERTIFICATE, verify_server_cert=True)
try:
url = transport.send(compat.b("x"))
assert url == "https://example.com/foo"
finally:
transport.close()


def test_ssl_cert_pinning_fails(waiting_httpsserver):
if compat.PY3:
waiting_httpsserver.serve_content(code=202, content="", headers={"Location": "https://example.com/foo"})
url = waiting_httpsserver.url
else:
# if we use the local test server here, execution blocks somewhere deep in OpenSSL on Python 2.7, presumably
# due to a threading issue that has been fixed in later versions. To avoid that, we have to commit a minor
# cardinal sin here and do an outside request to https://example.com (which will also fail the fingerprint
# assertion).
#
# May the Testing Goat have mercy on our souls.
url = "https://example.com"
transport = Transport(
url, server_cert=os.path.join(os.path.dirname(__file__), "wrong_cert.pem"), verify_server_cert=True
)
with pytest.raises(TransportException) as exc_info:
transport.send(compat.b("x"))
transport.close()

assert "Fingerprints did not match" in exc_info.value.args[0]
18 changes: 18 additions & 0 deletions tests/transports/wrong_cert.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC9DCCAdygAwIBAgIRAPrpYLvUsk2GY0O0HrSy1tMwDQYJKoZIhvcNAQELBQAw
EjEQMA4GA1UEChMHQWNtZSBDbzAeFw0xODExMjIwNjQyNDNaFw0xOTExMjIwNjQy
NDNaMBIxEDAOBgNVBAoTB0FjbWUgQ28wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
ggEKAoIBAQDzcGv/FzFuZTFZ882wWJwYhkah+LiVwXfcN3ZkkzXGI5emz2ghBhkx
RXymZVXj7nX8KktXgPjIF+JUSuDexlp20dcy4Xq9Kfn/zZ025S3l+JqmByboGdU0
cCl7t6nUvySPvVRVWxHuByVCHEWKU+ELR4zlVmREgdHlYj6UGeYsou4pUmYrQkrF
Iw1o+LqTtQ70nUtr82u7qG6i76h0gUYI0bRxmkAwW2kSAsNSM2Hgqou+I+zX0T9v
X6HGsh4pTU6XVJ/r+klBOZM0wXNh9lRQx2+3J8mTxKadXVrBCry333OIRIO9Vv56
9pEGNHsUqvWMZhpKs7f323MNkMdIu+olAgMBAAGjRTBDMA4GA1UdDwEB/wQEAwIF
oDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMA4GA1UdEQQHMAWC
A2ZvbzANBgkqhkiG9w0BAQsFAAOCAQEAb9hSwjlOzSRWJ4BdJfuSBUjqRUTpulGX
gGzNKH9PZM8wlpRh/Xiv09jN08XpW4pmnDFOMGZnYk4OR3et4pRmlJ2v8yPRsydD
pr4BrKMykY+jsngmsp7tzZBt+vHACyqplD8K8SivIuxsXrbUu9ekkMemv0G82TmO
ZUCerakCm8sojmQOTfb7ZqAfZifnGwTRi+6y3TCkwIupTL3l/S8E42L7l8gg+xGU
5nYYHVgyZroEuoJtGVmvakJJpGLcEzD2ai4X212qKC1dp9cjzfWgWxImn9jivYqy
cxsI6aaSYdZaM2JkmtnDLV0auBs0r8SN2nluFxxEStpK/zxn8SH5Sw==
-----END CERTIFICATE-----