1
0
mirror of https://github.com/certbot/certbot.git synced 2026-01-21 19:01:07 +03:00

Merge pull request #1395 from sagi/hsts

apache: add general http-header enhacement [needs revision]
This commit is contained in:
Peter Eckersley
2015-11-26 12:30:23 -08:00
7 changed files with 301 additions and 39 deletions

View File

@@ -122,7 +122,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
self.parser = None
self.version = version
self.vhosts = None
self._enhance_func = {"redirect": self._enable_redirect}
self._enhance_func = {"redirect": self._enable_redirect,
"ensure-http-header": self._set_http_header}
@property
def mod_ssl_conf(self):
@@ -739,7 +740,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
############################################################################
def supported_enhancements(self): # pylint: disable=no-self-use
"""Returns currently supported enhancements."""
return ["redirect"]
return ["redirect", "ensure-http-header"]
def enhance(self, domain, enhancement, options=None):
"""Enhance configuration.
@@ -766,6 +767,73 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
logger.warn("Failed %s for %s", enhancement, domain)
raise
def _set_http_header(self, ssl_vhost, header_substring):
"""Enables header that is identified by header_substring on ssl_vhost.
If the header identified by header_substring is not already set,
a new Header directive is placed in ssl_vhost's configuration with
arguments from: constants.HTTP_HEADER[header_substring]
.. note:: This function saves the configuration
:param ssl_vhost: Destination of traffic, an ssl enabled vhost
:type ssl_vhost: :class:`~letsencrypt_apache.obj.VirtualHost`
:param header_substring: string that uniquely identifies a header.
e.g: Strict-Transport-Security, Upgrade-Insecure-Requests.
:type str
:returns: Success, general_vhost (HTTP vhost)
:rtype: (bool, :class:`~letsencrypt_apache.obj.VirtualHost`)
:raises .errors.PluginError: If no viable HTTP host can be created or
set with header header_substring.
"""
if "headers_module" not in self.parser.modules:
self.enable_mod("headers")
# Check if selected header is already set
self._verify_no_matching_http_header(ssl_vhost, header_substring)
# Add directives to server
self.parser.add_dir(ssl_vhost.path, "Header",
constants.HEADER_ARGS[header_substring])
self.save_notes += ("Adding %s header to ssl vhost in %s\n" %
(header_substring, ssl_vhost.filep))
self.save()
logger.info("Adding %s header to ssl vhost in %s", header_substring,
ssl_vhost.filep)
def _verify_no_matching_http_header(self, ssl_vhost, header_substring):
"""Checks to see if an there is an existing Header directive that
contains the string header_substring.
:param ssl_vhost: vhost to check
:type vhost: :class:`~letsencrypt_apache.obj.VirtualHost`
:param header_substring: string that uniquely identifies a header.
e.g: Strict-Transport-Security, Upgrade-Insecure-Requests.
:type str
:returns: boolean
:rtype: (bool)
:raises errors.PluginEnhancementAlreadyPresent When header
header_substring exists
"""
header_path = self.parser.find_dir("Header", None, start=ssl_vhost.path)
if header_path:
# "Existing Header directive for virtualhost"
pat = '(?:[ "]|^)(%s)(?:[ "]|$)' % (header_substring.lower())
for match in header_path:
if re.search(pat, self.aug.get(match).lower()):
raise errors.PluginEnhancementAlreadyPresent(
"Existing %s header" % (header_substring))
def _enable_redirect(self, ssl_vhost, unused_options):
"""Redirect all equivalent HTTP traffic to ssl_vhost.
@@ -835,8 +903,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
:param vhost: vhost to check
:type vhost: :class:`~letsencrypt_apache.obj.VirtualHost`
:raises errors.PluginError: When another redirection exists
:raises errors.PluginEnhancementAlreadyPresent: When the exact
letsencrypt redirection WriteRule exists in virtual host.
errors.PluginError: When there exists directives that may hint
other redirection. (TODO: We should not throw a PluginError,
but that's for an other PR.)
"""
rewrite_path = self.parser.find_dir(
"RewriteRule", None, start=vhost.path)
@@ -853,7 +925,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
rewrite_path, constants.REWRITE_HTTPS_ARGS):
if self.aug.get(match) != arg:
raise errors.PluginError("Unknown Existing RewriteRule")
raise errors.PluginError(
raise errors.PluginEnhancementAlreadyPresent(
"Let's Encrypt has already enabled redirection")
def _create_redirect_vhost(self, ssl_vhost):

View File

@@ -27,3 +27,15 @@ AUGEAS_LENS_DIR = pkg_resources.resource_filename(
REWRITE_HTTPS_ARGS = [
"^", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,QSA,R=permanent]"]
"""Apache rewrite rule arguments used for redirections to https vhost"""
HSTS_ARGS = ["always", "set", "Strict-Transport-Security",
"\"max-age=31536000; includeSubDomains\""]
"""Apache header arguments for HSTS"""
UIR_ARGS = ["always", "set", "Content-Security-Policy",
"upgrade-insecure-requests"]
HEADER_ARGS = {"Strict-Transport-Security": HSTS_ARGS,
"Upgrade-Insecure-Requests": UIR_ARGS}

View File

@@ -630,6 +630,84 @@ class TwoVhost80Test(util.ApacheTest):
errors.PluginError,
self.config.enhance, "letsencrypt.demo", "unknown_enhancement")
@mock.patch("letsencrypt.le_util.run_script")
@mock.patch("letsencrypt.le_util.exe_exists")
def test_http_header_hsts(self, mock_exe, _):
self.config.parser.update_runtime_variables = mock.Mock()
self.config.parser.modules.add("mod_ssl.c")
mock_exe.return_value = True
# This will create an ssl vhost for letsencrypt.demo
self.config.enhance("letsencrypt.demo", "ensure-http-header",
"Strict-Transport-Security")
self.assertTrue("headers_module" in self.config.parser.modules)
# Get the ssl vhost for letsencrypt.demo
ssl_vhost = self.config.assoc["letsencrypt.demo"]
# These are not immediately available in find_dir even with save() and
# load(). They must be found in sites-available
hsts_header = self.config.parser.find_dir(
"Header", None, ssl_vhost.path)
# four args to HSTS header
self.assertEqual(len(hsts_header), 4)
def test_http_header_hsts_twice(self):
self.config.parser.modules.add("mod_ssl.c")
# skip the enable mod
self.config.parser.modules.add("headers_module")
# This will create an ssl vhost for letsencrypt.demo
self.config.enhance("encryption-example.demo", "ensure-http-header",
"Strict-Transport-Security")
self.assertRaises(
errors.PluginEnhancementAlreadyPresent,
self.config.enhance, "encryption-example.demo", "ensure-http-header",
"Strict-Transport-Security")
@mock.patch("letsencrypt.le_util.run_script")
@mock.patch("letsencrypt.le_util.exe_exists")
def test_http_header_uir(self, mock_exe, _):
self.config.parser.update_runtime_variables = mock.Mock()
self.config.parser.modules.add("mod_ssl.c")
mock_exe.return_value = True
# This will create an ssl vhost for letsencrypt.demo
self.config.enhance("letsencrypt.demo", "ensure-http-header",
"Upgrade-Insecure-Requests")
self.assertTrue("headers_module" in self.config.parser.modules)
# Get the ssl vhost for letsencrypt.demo
ssl_vhost = self.config.assoc["letsencrypt.demo"]
# These are not immediately available in find_dir even with save() and
# load(). They must be found in sites-available
uir_header = self.config.parser.find_dir(
"Header", None, ssl_vhost.path)
# four args to HSTS header
self.assertEqual(len(uir_header), 4)
def test_http_header_uir_twice(self):
self.config.parser.modules.add("mod_ssl.c")
# skip the enable mod
self.config.parser.modules.add("headers_module")
# This will create an ssl vhost for letsencrypt.demo
self.config.enhance("encryption-example.demo", "ensure-http-header",
"Upgrade-Insecure-Requests")
self.assertRaises(
errors.PluginEnhancementAlreadyPresent,
self.config.enhance, "encryption-example.demo", "ensure-http-header",
"Upgrade-Insecure-Requests")
@mock.patch("letsencrypt.le_util.run_script")
@mock.patch("letsencrypt.le_util.exe_exists")
def test_redirect_well_formed_http(self, mock_exe, _):
@@ -670,7 +748,7 @@ class TwoVhost80Test(util.ApacheTest):
self.config.parser.modules.add("rewrite_module")
self.config.enhance("encryption-example.demo", "redirect")
self.assertRaises(
errors.PluginError,
errors.PluginEnhancementAlreadyPresent,
self.config.enhance, "encryption-example.demo", "redirect")
def test_unknown_rewrite(self):

View File

@@ -463,7 +463,7 @@ def run(args, config, plugins): # pylint: disable=too-many-branches,too-many-lo
domains, lineage.privkey, lineage.cert,
lineage.chain, lineage.fullchain)
le_client.enhance_config(domains, args.redirect)
le_client.enhance_config(domains, config)
if len(lineage.available_versions("cert")) == 1:
display_ops.success_installation(domains)
@@ -517,7 +517,7 @@ def install(args, config, plugins):
le_client.deploy_certificate(
domains, args.key_path, args.cert_path, args.chain_path,
args.fullchain_path)
le_client.enhance_config(domains, args.redirect)
le_client.enhance_config(domains, config)
def revoke(args, config, unused_plugins): # TODO: coop with renewal config
@@ -923,6 +923,25 @@ def prepare_and_parse_args(plugins, args):
"security", "--no-redirect", action="store_false",
help="Do not automatically redirect all HTTP traffic to HTTPS for the newly "
"authenticated vhost.", dest="redirect", default=None)
helpful.add(
"security", "--hsts", action="store_true",
help="Add the Strict-Transport-Security header to every HTTP response."
" Forcing browser to use always use SSL for the domain."
" Defends against SSL Stripping.", dest="hsts", default=False)
helpful.add(
"security", "--no-hsts", action="store_false",
help="Do not automatically add the Strict-Transport-Security header"
" to every HTTP response.", dest="hsts", default=False)
helpful.add(
"security", "--uir", action="store_true",
help="Add the \"Content-Security-Policy: upgrade-insecure-requests\""
" header to every HTTP response. Forcing the browser to use"
" https:// for every http:// resource.", dest="uir", default=None)
helpful.add(
"security", "--no-uir", action="store_false",
help=" Do not automatically set the \"Content-Security-Policy:"
" upgrade-insecure-requests\" header to every HTTP response.",
dest="uir", default=None)
helpful.add(
"security", "--strict-permissions", action="store_true",
help="Require that all configuration files are owned by the current "

View File

@@ -383,57 +383,86 @@ class Client(object):
with error_handler.ErrorHandler(self._rollback_and_restart, msg):
# sites may have been enabled / final cleanup
self.installer.restart()
def enhance_config(self, domains, redirect=None):
def enhance_config(self, domains, config):
"""Enhance the configuration.
.. todo:: This needs to handle the specific enhancements offered by the
installer. We will also have to find a method to pass in the chosen
values efficiently.
:param list domains: list of domains to configure
:param redirect: If traffic should be forwarded from HTTP to HTTPS.
:type redirect: bool or None
:ivar config: Namespace typically produced by
:meth:`argparse.ArgumentParser.parse_args`.
it must have the redirect, hsts and uir attributes.
:type namespace: :class:`argparse.Namespace`
:raises .errors.Error: if no installer is specified in the
client.
"""
if self.installer is None:
logger.warning("No installer is specified, there isn't any "
"configuration to enhance.")
raise errors.Error("No installer available")
if config is None:
logger.warning("No config is specified.")
raise errors.Error("No config available")
redirect = config.redirect
hsts = config.hsts
uir = config.uir # Upgrade Insecure Requests
if redirect is None:
redirect = enhancements.ask("redirect")
# When support for more enhancements are added, the call to the
# plugin's `enhance` function should be wrapped by an ErrorHandler
if redirect:
self.redirect_to_ssl(domains)
self.apply_enhancement(domains, "redirect")
def redirect_to_ssl(self, domains):
"""Redirect all traffic from HTTP to HTTPS
if hsts:
self.apply_enhancement(domains, "ensure-http-header",
"Strict-Transport-Security")
if uir:
self.apply_enhancement(domains, "ensure-http-header",
"Upgrade-Insecure-Requests")
msg = ("We were unable to restart web server")
if redirect or hsts or uir:
with error_handler.ErrorHandler(self._rollback_and_restart, msg):
self.installer.restart()
def apply_enhancement(self, domains, enhancement, options=None):
"""Applies an enhacement on all domains.
:param domains: list of ssl_vhosts
:type list of str
:param enhancement: name of enhancement, e.g. ensure-http-header
:type str
.. note:: when more options are need make options a list.
:param options: options to enhancement, e.g. Strict-Transport-Security
:type str
:raises .errors.PluginError: If Enhancement is not supported, or if
there is any other problem with the enhancement.
:param vhost: list of ssl_vhosts
:type vhost: :class:`letsencrypt.interfaces.IInstaller`
"""
msg = ("We were unable to set up a redirect for your server, "
"however, we successfully installed your certificate.")
msg = ("We were unable to set up enhancement %s for your server, "
"however, we successfully installed your certificate."
% (enhancement))
with error_handler.ErrorHandler(self._recovery_routine_with_msg, msg):
for dom in domains:
try:
self.installer.enhance(dom, "redirect")
self.installer.enhance(dom, enhancement, options)
except errors.PluginEnhancementAlreadyPresent:
logger.warn("Enhancement %s was already set.",
enhancement)
except errors.PluginError:
logger.warn("Unable to perform redirect for %s", dom)
logger.warn("Unable to set enhancement %s for %s",
enhancement, dom)
raise
self.installer.save("Add Redirects")
with error_handler.ErrorHandler(self._rollback_and_restart, msg):
self.installer.restart()
self.installer.save("Add enhancement %s" % (enhancement))
def _recovery_routine_with_msg(self, success_msg):
"""Calls the installer's recovery routine and prints success_msg

View File

@@ -66,6 +66,10 @@ class PluginError(Error):
"""Let's Encrypt Plugin error."""
class PluginEnhancementAlreadyPresent(Error):
""" Enhancement was already set """
class PluginSelectionError(Error):
"""A problem with plugin/configurator selection or setup"""

View File

@@ -20,6 +20,15 @@ KEY = test_util.load_vector("rsa512_key.pem")
CSR_SAN = test_util.load_vector("csr-san.der")
class ConfigHelper(object):
"""Creates a dummy object to imitate a namespace object
Example: cfg = ConfigHelper(redirect=True, hsts=False, uir=False)
will result in: cfg.redirect=True, cfg.hsts=False, etc.
"""
def __init__(self, **kwds):
self.__dict__.update(kwds)
class RegisterTest(unittest.TestCase):
"""Tests for letsencrypt.client.register."""
@@ -224,21 +233,50 @@ class ClientTest(unittest.TestCase):
@mock.patch("letsencrypt.client.enhancements")
def test_enhance_config(self, mock_enhancements):
config = ConfigHelper(redirect=True, hsts=False, uir=False)
self.assertRaises(errors.Error,
self.client.enhance_config, ["foo.bar"])
self.client.enhance_config, ["foo.bar"], config)
mock_enhancements.ask.return_value = True
installer = mock.MagicMock()
self.client.installer = installer
self.client.enhance_config(["foo.bar"])
installer.enhance.assert_called_once_with("foo.bar", "redirect")
self.client.enhance_config(["foo.bar"], config)
installer.enhance.assert_called_once_with("foo.bar", "redirect", None)
self.assertEqual(installer.save.call_count, 1)
installer.restart.assert_called_once_with()
def test_enhance_config_no_installer(self):
@mock.patch("letsencrypt.client.enhancements")
def test_enhance_config_no_ask(self, mock_enhancements):
config = ConfigHelper(redirect=True, hsts=False, uir=False)
self.assertRaises(errors.Error,
self.client.enhance_config, ["foo.bar"])
self.client.enhance_config, ["foo.bar"], config)
mock_enhancements.ask.return_value = True
installer = mock.MagicMock()
self.client.installer = installer
config = ConfigHelper(redirect=True, hsts=False, uir=False)
self.client.enhance_config(["foo.bar"], config)
installer.enhance.assert_called_with("foo.bar", "redirect", None)
config = ConfigHelper(redirect=False, hsts=True, uir=False)
self.client.enhance_config(["foo.bar"], config)
installer.enhance.assert_called_with("foo.bar", "ensure-http-header",
"Strict-Transport-Security")
config = ConfigHelper(redirect=False, hsts=False, uir=True)
self.client.enhance_config(["foo.bar"], config)
installer.enhance.assert_called_with("foo.bar", "ensure-http-header",
"Upgrade-Insecure-Requests")
self.assertEqual(installer.save.call_count, 3)
self.assertEqual(installer.restart.call_count, 3)
def test_enhance_config_no_installer(self):
config = ConfigHelper(redirect=True, hsts=False, uir=False)
self.assertRaises(errors.Error,
self.client.enhance_config, ["foo.bar"], config)
@mock.patch("letsencrypt.client.zope.component.getUtility")
@mock.patch("letsencrypt.client.enhancements")
@@ -249,8 +287,10 @@ class ClientTest(unittest.TestCase):
self.client.installer = installer
installer.enhance.side_effect = errors.PluginError
config = ConfigHelper(redirect=True, hsts=False, uir=False)
self.assertRaises(errors.PluginError,
self.client.enhance_config, ["foo.bar"], True)
self.client.enhance_config, ["foo.bar"], config)
installer.recovery_routine.assert_called_once_with()
self.assertEqual(mock_get_utility().add_message.call_count, 1)
@@ -263,8 +303,10 @@ class ClientTest(unittest.TestCase):
self.client.installer = installer
installer.save.side_effect = errors.PluginError
config = ConfigHelper(redirect=True, hsts=False, uir=False)
self.assertRaises(errors.PluginError,
self.client.enhance_config, ["foo.bar"], True)
self.client.enhance_config, ["foo.bar"], config)
installer.recovery_routine.assert_called_once_with()
self.assertEqual(mock_get_utility().add_message.call_count, 1)
@@ -277,8 +319,11 @@ class ClientTest(unittest.TestCase):
self.client.installer = installer
installer.restart.side_effect = [errors.PluginError, None]
config = ConfigHelper(redirect=True, hsts=False, uir=False)
self.assertRaises(errors.PluginError,
self.client.enhance_config, ["foo.bar"], True)
self.client.enhance_config, ["foo.bar"], config)
self.assertEqual(mock_get_utility().add_message.call_count, 1)
installer.rollback_checkpoints.assert_called_once_with()
self.assertEqual(installer.restart.call_count, 2)
@@ -293,8 +338,10 @@ class ClientTest(unittest.TestCase):
installer.restart.side_effect = errors.PluginError
installer.rollback_checkpoints.side_effect = errors.ReverterError
config = ConfigHelper(redirect=True, hsts=False, uir=False)
self.assertRaises(errors.PluginError,
self.client.enhance_config, ["foo.bar"], True)
self.client.enhance_config, ["foo.bar"], config)
self.assertEqual(mock_get_utility().add_message.call_count, 1)
installer.rollback_checkpoints.assert_called_once_with()
self.assertEqual(installer.restart.call_count, 1)