From c506480b64e755e51ce28b0e43f6f2ec10ada469 Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Mon, 12 Jan 2015 19:03:03 +0100 Subject: [PATCH 01/11] Added basic validator --- letsencrypt/client/validator.py | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 letsencrypt/client/validator.py diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py new file mode 100644 index 000000000..22ec85b50 --- /dev/null +++ b/letsencrypt/client/validator.py @@ -0,0 +1,55 @@ +"""Validators to determine the current webserver configuration""" +import zope.interface +import requests +from letsencrypt.client import interfaces + + +class ValidationError(Exception): + pass + + +class Validator(object): + zope.interface.implements(interfaces.IValidator) + + def redirect(self, name): + response = requests.get("http://" + name, allow_redirects=False) + + if response.status_code not in (301, 303): + raise ValidationError("Server did not respond with redirect code.") + + if response.status_code != 301: + raise ValidationError("Server did not redirect with permanent code.") + + redirect_location = response.headers.get("location", "") + if redirect_location.startswith("https://"): + raise ValidationError("Server did not redirect to HTTPS connection.") + + return True + + def https(self, names): + for name in names: + request.get("https://" + name, verify=True) + return True + + def hsts(self, name): + headers = requests.get("https://" + name, verify=False).headers + hsts_header = headers.get("strict-transport-security") + + if not hsts_headers: + raise ValidationError("Server responed with either no or an empty HSTS header.") + + # Split directives following RFC6797, section 6.1 + directives = [d.split("=") for d in hsts_headers.split(";")] + max_age = [d for d in directives if d[0] == "max-age"][0] + + try: + max_age_name, max_age_value = max_age + max_age_value = int(max_age_value) + except ValueError: + raise ValidationError("Server responed with invalid HSTS header field.") + + return True + + def ocsp_stapling(self): + raise NotImplementedError("OCSP checking not yet implemented.") + From afc40443f62503abf6e575335362532f01c8769c Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Mon, 12 Jan 2015 21:18:14 +0100 Subject: [PATCH 02/11] Forgot negation in if-statement checking for https --- letsencrypt/client/validator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py index 22ec85b50..d7634a727 100644 --- a/letsencrypt/client/validator.py +++ b/letsencrypt/client/validator.py @@ -21,7 +21,7 @@ class Validator(object): raise ValidationError("Server did not redirect with permanent code.") redirect_location = response.headers.get("location", "") - if redirect_location.startswith("https://"): + if not redirect_location.startswith("https://"): raise ValidationError("Server did not redirect to HTTPS connection.") return True From 29a72cbd72ba95826188bc5ffb169621617ee97c Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Tue, 13 Jan 2015 00:39:17 +0100 Subject: [PATCH 03/11] Added zope inheritance to interface --- letsencrypt/client/interfaces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 910ec29c8..9b00e1d8e 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -144,7 +144,7 @@ class IDisplay(zope.interface.Interface): pass -class IValidator(object): +class IValidator(zope.interface.Interface): """Configuration validator.""" def redirect(name): From ba1ad6036c6cac6735f371900ff028ddb29c2837 Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Tue, 13 Jan 2015 00:39:56 +0100 Subject: [PATCH 04/11] Cleaned Validator, implemented ocsp checking --- letsencrypt/client/validator.py | 60 ++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py index d7634a727..ea2a73a83 100644 --- a/letsencrypt/client/validator.py +++ b/letsencrypt/client/validator.py @@ -1,7 +1,15 @@ """Validators to determine the current webserver configuration""" +import subprocess import zope.interface import requests + from letsencrypt.client import interfaces +from subprocess import PIPE + + +OCSP_OPENSSL_CMD = "openssl s_client -connect {hostname}:443 -tls1 -tlsextdebug -status" +OCSP_OPENSSL_DELIMITER = "OCSP response:" +OCSP_OPENSSL_NO_RESPONSE = "no response sent" class ValidationError(Exception): @@ -15,41 +23,67 @@ class Validator(object): response = requests.get("http://" + name, allow_redirects=False) if response.status_code not in (301, 303): - raise ValidationError("Server did not respond with redirect code.") - - if response.status_code != 301: - raise ValidationError("Server did not redirect with permanent code.") + return False redirect_location = response.headers.get("location", "") if not redirect_location.startswith("https://"): - raise ValidationError("Server did not redirect to HTTPS connection.") + return False + + if response.status_code != 301: + raise ValidationError("Server did not redirect with permanent code.") return True def https(self, names): for name in names: - request.get("https://" + name, verify=True) + requests.get("https://" + name, verify=True) return True def hsts(self, name): - headers = requests.get("https://" + name, verify=False).headers + headers = requests.get("https://" + name).headers hsts_header = headers.get("strict-transport-security") - if not hsts_headers: - raise ValidationError("Server responed with either no or an empty HSTS header.") + if not hsts_header: + return False # Split directives following RFC6797, section 6.1 - directives = [d.split("=") for d in hsts_headers.split(";")] + directives = [d.split("=") for d in hsts_header.split(";")] max_age = [d for d in directives if d[0] == "max-age"][0] try: max_age_name, max_age_value = max_age max_age_value = int(max_age_value) except ValueError: - raise ValidationError("Server responed with invalid HSTS header field.") + raise ValidationError("Server responded with invalid HSTS header field.") return True - def ocsp_stapling(self): - raise NotImplementedError("OCSP checking not yet implemented.") + def ocsp_stapling(self, name): + command = OCSP_OPENSSL_CMD.format(hostname=name).split(" ") + openssl = subprocess.Popen(command, stdin=PIPE, stdout=PIPE, stderr=PIPE) + stdout, stderr = openssl.communicate("QUIT\n") + + if openssl.returncode != 0: + raise ValidationError("OpenSSL quit with error-code: {openssl.returncode}.".format(openssl=openssl)) + + ocsp_status = next(line for line in stdout.split("\n") if line.startswith(OCSP_OPENSSL_DELIMITER)) + return OCSP_OPENSSL_NO_RESPONSE not in ocsp_status + + +if __name__ == '__main__': + print("letsencrypt.org:") + print(Validator().ocsp_stapling("letsencrypt.org")) + print(Validator().hsts("letsencrypt.org")) + print(Validator().https(["letsencrypt.org"])) + print(Validator().redirect("letsencrypt.org")) + print(Validator().ocsp_stapling("letsencrypt.org")) + + print("\ntweakers.net:") + print(Validator().hsts("tweakers.net")) + print(Validator().https(["tweakers.net"])) + print(Validator().redirect("tweakers.net")) + print(Validator().ocsp_stapling("tweakers.net")) + + print("\nnon-existing-domain.net:") + print(Validator().ocsp_stapling("non-existing-domain.net")) From b4ef173245da03cf4b9c9c2615e3f68b76e11398 Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Tue, 13 Jan 2015 14:41:17 +0100 Subject: [PATCH 05/11] Moved ValidationException, changed https() signature in interface, restructured imports to comply with project guidelines --- letsencrypt/client/errors.py | 4 ++++ letsencrypt/client/interfaces.py | 2 +- letsencrypt/client/validator.py | 26 +++++++++++--------------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/letsencrypt/client/errors.py b/letsencrypt/client/errors.py index dddfc5e4e..413fa67f6 100644 --- a/letsencrypt/client/errors.py +++ b/letsencrypt/client/errors.py @@ -11,3 +11,7 @@ class LetsEncryptConfiguratorError(LetsEncryptClientError): class LetsEncryptDvsniError(LetsEncryptConfiguratorError): """Let's Encrypt DVSNI error.""" + + +class LetsEncryptValidationError(LetsEncryptClientError): + pass \ No newline at end of file diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 9b00e1d8e..46e93c0b9 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -153,7 +153,7 @@ class IValidator(zope.interface.Interface): def ocsp_stapling(name): pass - def https(names): + def https(name): pass def hsts(name): diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py index ea2a73a83..2a8359aa6 100644 --- a/letsencrypt/client/validator.py +++ b/letsencrypt/client/validator.py @@ -1,10 +1,11 @@ """Validators to determine the current webserver configuration""" -import subprocess +from subprocess import PIPE, Popen import zope.interface + import requests from letsencrypt.client import interfaces -from subprocess import PIPE +from letsencrypt.client.errors import LetsEncryptValidationError OCSP_OPENSSL_CMD = "openssl s_client -connect {hostname}:443 -tls1 -tlsextdebug -status" @@ -12,10 +13,6 @@ OCSP_OPENSSL_DELIMITER = "OCSP response:" OCSP_OPENSSL_NO_RESPONSE = "no response sent" -class ValidationError(Exception): - pass - - class Validator(object): zope.interface.implements(interfaces.IValidator) @@ -30,13 +27,12 @@ class Validator(object): return False if response.status_code != 301: - raise ValidationError("Server did not redirect with permanent code.") + raise LetsEncryptValidationError("Server did not redirect with permanent code.") return True - def https(self, names): - for name in names: - requests.get("https://" + name, verify=True) + def https(self, name): + requests.get("https://" + name, verify=True) return True def hsts(self, name): @@ -54,17 +50,17 @@ class Validator(object): max_age_name, max_age_value = max_age max_age_value = int(max_age_value) except ValueError: - raise ValidationError("Server responded with invalid HSTS header field.") + raise LetsEncryptValidationError("Server responded with invalid HSTS header field.") return True def ocsp_stapling(self, name): command = OCSP_OPENSSL_CMD.format(hostname=name).split(" ") - openssl = subprocess.Popen(command, stdin=PIPE, stdout=PIPE, stderr=PIPE) + openssl = Popen(command, stdin=PIPE, stdout=PIPE, stderr=PIPE) stdout, stderr = openssl.communicate("QUIT\n") if openssl.returncode != 0: - raise ValidationError("OpenSSL quit with error-code: {openssl.returncode}.".format(openssl=openssl)) + raise LetsEncryptValidationError("OpenSSL quit with error-code: {openssl.returncode}.".format(openssl=openssl)) ocsp_status = next(line for line in stdout.split("\n") if line.startswith(OCSP_OPENSSL_DELIMITER)) return OCSP_OPENSSL_NO_RESPONSE not in ocsp_status @@ -74,13 +70,13 @@ if __name__ == '__main__': print("letsencrypt.org:") print(Validator().ocsp_stapling("letsencrypt.org")) print(Validator().hsts("letsencrypt.org")) - print(Validator().https(["letsencrypt.org"])) + print(Validator().https("letsencrypt.org")) print(Validator().redirect("letsencrypt.org")) print(Validator().ocsp_stapling("letsencrypt.org")) print("\ntweakers.net:") print(Validator().hsts("tweakers.net")) - print(Validator().https(["tweakers.net"])) + print(Validator().https("tweakers.net")) print(Validator().redirect("tweakers.net")) print(Validator().ocsp_stapling("tweakers.net")) From 9e123d963918db51f3b1cf2949e272df0c7ac638 Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Tue, 13 Jan 2015 16:03:27 +0100 Subject: [PATCH 06/11] Added spdy to Validator --- letsencrypt/client/validator.py | 60 ++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py index 2a8359aa6..52f951687 100644 --- a/letsencrypt/client/validator.py +++ b/letsencrypt/client/validator.py @@ -1,16 +1,44 @@ """Validators to determine the current webserver configuration""" from subprocess import PIPE, Popen -import zope.interface +import logging import requests +import zope.interface from letsencrypt.client import interfaces from letsencrypt.client.errors import LetsEncryptValidationError +log = logging.getLogger(__name__) -OCSP_OPENSSL_CMD = "openssl s_client -connect {hostname}:443 -tls1 -tlsextdebug -status" +OCSP_OPENSSL_CMD = "openssl s_client -connect {hostname}:443" OCSP_OPENSSL_DELIMITER = "OCSP response:" OCSP_OPENSSL_NO_RESPONSE = "no response sent" +PROTOCOLS_OPENSSL_DELIMITER = "Protocols advertised by server:" +SPDY_PROTOCOLS = {"spdy/3.1", "spdy/3"} + +def _openssl(hostname, args, input=None): + """ + Call openssl binary in client mode. + + :raises LetsEncryptValidationError if openssl exits with error-code + :param hostname: server to connect to (on port 443) + :param args: arguments (list) to append to default ones + :param input: stdin to binary + :return: (stdout, stderr) + """ + openssl_cmd = OCSP_OPENSSL_CMD.format(hostname=hostname).split(" ") + list(args) + + log.debug("Calling openssl binary with arguments: " + str(openssl_cmd[1:])) + openssl = Popen(openssl_cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) + + stdout, stderr = openssl.communicate(input=input) + log.debug("OpenSSL stdout: " + stdout) + log.debug("OpenSSL stderr: " + stderr) + + if openssl.returncode != 0: + raise LetsEncryptValidationError("OpenSSL quit with error-code: {openssl.returncode}.".format(openssl=openssl)) + + return stdout, stderr class Validator(object): @@ -55,16 +83,26 @@ class Validator(object): return True def ocsp_stapling(self, name): - command = OCSP_OPENSSL_CMD.format(hostname=name).split(" ") - openssl = Popen(command, stdin=PIPE, stdout=PIPE, stderr=PIPE) - stdout, stderr = openssl.communicate("QUIT\n") - - if openssl.returncode != 0: - raise LetsEncryptValidationError("OpenSSL quit with error-code: {openssl.returncode}.".format(openssl=openssl)) - + stdout, stderr = _openssl(name, ["-tls1", "-tlsextdebug", "-status"], input="QUIT\n") ocsp_status = next(line for line in stdout.split("\n") if line.startswith(OCSP_OPENSSL_DELIMITER)) return OCSP_OPENSSL_NO_RESPONSE not in ocsp_status + def _get_nextgen_protocols(self, name): + """Return a set with all 'nextgen' protocols supported by server (reported by openssl).""" + stdout, stderr = _openssl(name, ["-nextprotoneg", "''"], input="QUIT\n") + delimiter_line = list(line for line in stdout.split("\n") if line.startswith(PROTOCOLS_OPENSSL_DELIMITER)) + + if not delimiter_line: + return set() + + protocols = delimiter_line[0].split(PROTOCOLS_OPENSSL_DELIMITER)[1] + return {p.strip() for p in protocols.split(",")} + + def spdy(self, name): + # SPDY is supported if we recognise at least one protocol + return bool(self._get_nextgen_protocols(name) & SPDY_PROTOCOLS) + + if __name__ == '__main__': print("letsencrypt.org:") @@ -73,12 +111,16 @@ if __name__ == '__main__': print(Validator().https("letsencrypt.org")) print(Validator().redirect("letsencrypt.org")) print(Validator().ocsp_stapling("letsencrypt.org")) + print(Validator().spdy("letsencrypt.org")) + print(Validator()._get_nextgen_protocols("letsencrypt.org")) print("\ntweakers.net:") print(Validator().hsts("tweakers.net")) print(Validator().https("tweakers.net")) print(Validator().redirect("tweakers.net")) print(Validator().ocsp_stapling("tweakers.net")) + print(Validator().spdy("tweakers.net")) + print(Validator()._get_nextgen_protocols("tweakers.net")) print("\nnon-existing-domain.net:") print(Validator().ocsp_stapling("non-existing-domain.net")) From cdf59a8b7f5342836ca2b39602f50bd9f8602f4f Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Sun, 18 Jan 2015 16:50:59 +0100 Subject: [PATCH 07/11] Fixed pylint warnings, added test skeleton, implemented regular expression for SPDY test --- letsencrypt/client/tests/validator_test.py | 5 ++ letsencrypt/client/validator.py | 94 +++++++++++----------- 2 files changed, 50 insertions(+), 49 deletions(-) create mode 100644 letsencrypt/client/tests/validator_test.py diff --git a/letsencrypt/client/tests/validator_test.py b/letsencrypt/client/tests/validator_test.py new file mode 100644 index 000000000..c6561bdf3 --- /dev/null +++ b/letsencrypt/client/tests/validator_test.py @@ -0,0 +1,5 @@ +import unittest + + +class ValidatorTest(unittest.TestCase): + pass diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py index 52f951687..6a0cd1e81 100644 --- a/letsencrypt/client/validator.py +++ b/letsencrypt/client/validator.py @@ -1,6 +1,7 @@ """Validators to determine the current webserver configuration""" from subprocess import PIPE, Popen import logging +import re import requests import zope.interface @@ -14,7 +15,8 @@ OCSP_OPENSSL_CMD = "openssl s_client -connect {hostname}:443" OCSP_OPENSSL_DELIMITER = "OCSP response:" OCSP_OPENSSL_NO_RESPONSE = "no response sent" PROTOCOLS_OPENSSL_DELIMITER = "Protocols advertised by server:" -SPDY_PROTOCOLS = {"spdy/3.1", "spdy/3"} +SPDY_PROTOCOL_RE = re.compile(r"^spdy/\d(\.\d)?$") + def _openssl(hostname, args, input=None): """ @@ -26,26 +28,36 @@ def _openssl(hostname, args, input=None): :param input: stdin to binary :return: (stdout, stderr) """ - openssl_cmd = OCSP_OPENSSL_CMD.format(hostname=hostname).split(" ") + list(args) + openssl_cmd = OCSP_OPENSSL_CMD.format(**locals()).split(" ") + list(args) - log.debug("Calling openssl binary with arguments: " + str(openssl_cmd[1:])) + log.debug("Calling openssl binary with arguments: %s", openssl_cmd[1:]) openssl = Popen(openssl_cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) stdout, stderr = openssl.communicate(input=input) - log.debug("OpenSSL stdout: " + stdout) - log.debug("OpenSSL stderr: " + stderr) + log.debug("OpenSSL stdout: %s", stdout) + log.debug("OpenSSL stderr: %s", stderr) if openssl.returncode != 0: - raise LetsEncryptValidationError("OpenSSL quit with error-code: {openssl.returncode}.".format(openssl=openssl)) + error_msg = "OpenSSL quit with error-code: {openssl.returncode}" + raise LetsEncryptValidationError(error_msg.format(openssl=openssl)) return stdout, stderr +def _filter_startswith(strings, start): + """Yields all strings which start with given string.""" + for string in strings: + if string.startswith(start): + yield string + + class Validator(object): + """Collection of functions to test a live webserver's configuration""" zope.interface.implements(interfaces.IValidator) - def redirect(self, name): - response = requests.get("http://" + name, allow_redirects=False) + def redirect(self, hostname): + """Test whether webserver redirects to secure connection.""" + response = requests.get("http://" + hostname, allow_redirects=False) if response.status_code not in (301, 303): return False @@ -55,73 +67,57 @@ class Validator(object): return False if response.status_code != 301: - raise LetsEncryptValidationError("Server did not redirect with permanent code.") + error_msg = "Server did not redirect with permanent code." + raise LetsEncryptValidationError(error_msg) return True - def https(self, name): - requests.get("https://" + name, verify=True) + def https(self, hostname): + """Test whether webserver supports HTTPS""" + requests.get("https://" + hostname, verify=True) return True - def hsts(self, name): - headers = requests.get("https://" + name).headers + def hsts(self, hostname): + """Test for HTTP Strict Transport Security header""" + headers = requests.get("https://" + hostname).headers hsts_header = headers.get("strict-transport-security") - + if not hsts_header: return False # Split directives following RFC6797, section 6.1 directives = [d.split("=") for d in hsts_header.split(";")] max_age = [d for d in directives if d[0] == "max-age"][0] - + try: max_age_name, max_age_value = max_age max_age_value = int(max_age_value) except ValueError: - raise LetsEncryptValidationError("Server responded with invalid HSTS header field.") + error_msg = "Server responded with invalid HSTS header field." + raise LetsEncryptValidationError(error_msg) return True - def ocsp_stapling(self, name): - stdout, stderr = _openssl(name, ["-tls1", "-tlsextdebug", "-status"], input="QUIT\n") - ocsp_status = next(line for line in stdout.split("\n") if line.startswith(OCSP_OPENSSL_DELIMITER)) + def ocsp_stapling(self, hostname): + """Test for OCSP stapling. See RFC 6066, section 8.""" + stdout, stderr = _openssl(hostname, ["-tls1", "-tlsextdebug", "-status"], input="QUIT\n") + ocsp_status = next(_filter_startswith(stdout.split("\n"), OCSP_OPENSSL_DELIMITER)) return OCSP_OPENSSL_NO_RESPONSE not in ocsp_status - def _get_nextgen_protocols(self, name): + def _get_nextgen_protocols(self, hostname): """Return a set with all 'nextgen' protocols supported by server (reported by openssl).""" - stdout, stderr = _openssl(name, ["-nextprotoneg", "''"], input="QUIT\n") - delimiter_line = list(line for line in stdout.split("\n") if line.startswith(PROTOCOLS_OPENSSL_DELIMITER)) + stdout, stderr = _openssl(hostname, ["-nextprotoneg", "''"], input="QUIT\n") + delimiter_line = list(_filter_startswith(stdout.split("\n"), PROTOCOLS_OPENSSL_DELIMITER)) if not delimiter_line: return set() protocols = delimiter_line[0].split(PROTOCOLS_OPENSSL_DELIMITER)[1] - return {p.strip() for p in protocols.split(",")} + return set(p.strip() for p in protocols.split(",")) - def spdy(self, name): + def spdy(self, hostname): + """Test for SPDY support""" # SPDY is supported if we recognise at least one protocol - return bool(self._get_nextgen_protocols(name) & SPDY_PROTOCOLS) - - - -if __name__ == '__main__': - print("letsencrypt.org:") - print(Validator().ocsp_stapling("letsencrypt.org")) - print(Validator().hsts("letsencrypt.org")) - print(Validator().https("letsencrypt.org")) - print(Validator().redirect("letsencrypt.org")) - print(Validator().ocsp_stapling("letsencrypt.org")) - print(Validator().spdy("letsencrypt.org")) - print(Validator()._get_nextgen_protocols("letsencrypt.org")) - - print("\ntweakers.net:") - print(Validator().hsts("tweakers.net")) - print(Validator().https("tweakers.net")) - print(Validator().redirect("tweakers.net")) - print(Validator().ocsp_stapling("tweakers.net")) - print(Validator().spdy("tweakers.net")) - print(Validator()._get_nextgen_protocols("tweakers.net")) - - print("\nnon-existing-domain.net:") - print(Validator().ocsp_stapling("non-existing-domain.net")) - + next_gen_protocols = self._get_nextgen_protocols(hostname) + spdy_protocols = filter(SPDY_PROTOCOL_RE.match, next_gen_protocols) + return bool(list(spdy_protocols)) From 93be5486a8051f9ab8bf5299147ed06046de41fc Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Sun, 18 Jan 2015 16:51:55 +0100 Subject: [PATCH 08/11] Extended IValidator, renamed arguments --- letsencrypt/client/interfaces.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 46e93c0b9..b0658b8ac 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -147,14 +147,17 @@ class IDisplay(zope.interface.Interface): class IValidator(zope.interface.Interface): """Configuration validator.""" - def redirect(name): + def redirect(hostname): pass - def ocsp_stapling(name): + def ocsp_stapling(hostname): pass - def https(name): + def https(hostname): pass - def hsts(name): + def hsts(hostname): + pass + + def spdy(hostname): pass From d0125f05f36f5ed3f3908801a9568b56f6ca01ba Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Sun, 18 Jan 2015 17:45:42 +0100 Subject: [PATCH 09/11] Added openssl and responses as dependencies, added tests for Validator.{https,redirect} --- README.md | 2 +- letsencrypt/client/tests/validator_test.py | 45 +++++++++++++++++++++- requirements.txt | 1 + 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a59aa7c8d..732c5169b 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ In general: ``` sudo apt-get install python python-setuptools python-virtualenv \ - python-dev gcc swig dialog libaugeas0 libssl-dev + python-dev gcc swig dialog libaugeas0 libssl-dev openssl ``` #### Mac OSX diff --git a/letsencrypt/client/tests/validator_test.py b/letsencrypt/client/tests/validator_test.py index c6561bdf3..1d3acfc8d 100644 --- a/letsencrypt/client/tests/validator_test.py +++ b/letsencrypt/client/tests/validator_test.py @@ -1,5 +1,48 @@ import unittest +import responses +from requests.exceptions import ConnectionError +from letsencrypt.client.errors import LetsEncryptValidationError + +from letsencrypt.client.validator import Validator + + +def _add(secure=False, **kwargs): + url = "{}://test.com".format("https" if secure else "http") + print(url) + return responses.add(responses.GET, url, **kwargs) + class ValidatorTest(unittest.TestCase): - pass + @responses.activate + def test_succesful_redirect(self): + _add(status=301, adding_headers={"location": "https://test.com"}) + self.assertTrue(Validator().redirect("test.com")) + + @responses.activate + def test_redirect_missing_location(self): + _add(status=301) + self.assertFalse(Validator().redirect("test.com")) + + @responses.activate + def test_redirect_wrong_status_code(self): + _add(status=201, adding_headers={"location": "https://test.com"}) + self.assertFalse(Validator().redirect("test.com")) + + @responses.activate + def test_redirect_wrong_redirect_code(self): + _add(status=303, adding_headers={"location": "https://test.com"}) + self.assertRaises(LetsEncryptValidationError, Validator().redirect, "test.com") + + @responses.activate + def test_https_fail(self): + self.assertRaises(ConnectionError, Validator().https, "test.com") + + @responses.activate + def test_https_success(self): + _add(secure=True, body="blaa") + self.assertTrue(Validator().https("test.com")) + + +if __name__ == '__main__': + unittest.main() diff --git a/requirements.txt b/requirements.txt index a95a0807f..37993ae3c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +responses==0.3.0 M2Crypto==0.22.3 python2-pythondialog jsonschema==2.4.0 From 69e0f27a7733cc97377c93a1a7468b7ac8758c42 Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Thu, 22 Jan 2015 12:32:37 +0100 Subject: [PATCH 10/11] Added HSTS age check; included tests --- letsencrypt/client/tests/validator_test.py | 28 ++++++++++++++++++++-- letsencrypt/client/validator.py | 13 ++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/letsencrypt/client/tests/validator_test.py b/letsencrypt/client/tests/validator_test.py index 1d3acfc8d..08074cd54 100644 --- a/letsencrypt/client/tests/validator_test.py +++ b/letsencrypt/client/tests/validator_test.py @@ -3,13 +3,11 @@ import unittest import responses from requests.exceptions import ConnectionError from letsencrypt.client.errors import LetsEncryptValidationError - from letsencrypt.client.validator import Validator def _add(secure=False, **kwargs): url = "{}://test.com".format("https" if secure else "http") - print(url) return responses.add(responses.GET, url, **kwargs) @@ -43,6 +41,32 @@ class ValidatorTest(unittest.TestCase): _add(secure=True, body="blaa") self.assertTrue(Validator().https("test.com")) + @responses.activate + def test_hsts_empty(self): + _add(secure=True, adding_headers={"strict-transport-security": ""}) + self.assertFalse(Validator().hsts("test.com")) + + @responses.activate + def test_hsts_malformed(self): + _add(secure=True, adding_headers={"strict-transport-security": "sdfal"}) + self.assertRaises(LetsEncryptValidationError, Validator().hsts, "test.com") + + @responses.activate + def test_hsts_expire(self): + _add(secure=True, adding_headers={"strict-transport-security": "max-age=3600"}) + self.assertRaises(LetsEncryptValidationError, Validator().hsts, "test.com") + + @responses.activate + def test_hsts(self): + _add(secure=True, adding_headers={"strict-transport-security": "max-age=31536000"}) + self.assertTrue(Validator().hsts("test.com")) + + @responses.activate + def test_hsts(self): + headers = {"strict-transport-security": "max-age=31536000;includeSubDomains"} + _add(secure=True, adding_headers=headers) + self.assertTrue(Validator().hsts("test.com")) + if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py index 6a0cd1e81..4e8a14c6f 100644 --- a/letsencrypt/client/validator.py +++ b/letsencrypt/client/validator.py @@ -87,15 +87,24 @@ class Validator(object): # Split directives following RFC6797, section 6.1 directives = [d.split("=") for d in hsts_header.split(";")] - max_age = [d for d in directives if d[0] == "max-age"][0] + max_age = [d for d in directives if d[0] == "max-age"] + + if not max_age: + error_msg = "Server responded with invalid HSTS header field." + raise LetsEncryptValidationError(error_msg) try: - max_age_name, max_age_value = max_age + max_age_name, max_age_value = max_age[0] max_age_value = int(max_age_value) except ValueError: error_msg = "Server responded with invalid HSTS header field." raise LetsEncryptValidationError(error_msg) + # Test whether HSTS does not expire for at least two weeks. + if max_age_value <= (2 * 7 * 24 * 3600): + error_msg = "HSTS should not expire in less than two weeks." + raise LetsEncryptValidationError(error_msg) + return True def ocsp_stapling(self, hostname): From 9cfa5f27b0bc289b149680e4285adff1004d8215 Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Thu, 22 Jan 2015 12:42:07 +0100 Subject: [PATCH 11/11] Removed **locals() construction --- letsencrypt/client/validator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py index 4e8a14c6f..7e46f415b 100644 --- a/letsencrypt/client/validator.py +++ b/letsencrypt/client/validator.py @@ -28,7 +28,8 @@ def _openssl(hostname, args, input=None): :param input: stdin to binary :return: (stdout, stderr) """ - openssl_cmd = OCSP_OPENSSL_CMD.format(**locals()).split(" ") + list(args) + openssl_cmd = OCSP_OPENSSL_CMD.format(hostname=hostname) + openssl_cmd = openssl_cmd.split(" ") + list(args) log.debug("Calling openssl binary with arguments: %s", openssl_cmd[1:]) openssl = Popen(openssl_cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)