Skip to content

Commit

Permalink
feat(nginx plugin): add HSTS enhancement (#5463)
Browse files Browse the repository at this point in the history
* feat(nginx plugin): add HSTS enhancement

* chore(nginx): factor out block-splitting code from redirect & hsts enhancements!

* chore(nginx): merge fixes

* address comments

* fix linter: remove a space

* fix(config): remove SSL directives in HTTP block after block split, and remove_directive removes 'Managed by certbot' comment

* chore(nginx-hsts): Move added SSL directives to a constant on Configurator class

* fix(nginx-hsts): rebase on wildcard cert changes
  • Loading branch information
sydneyli authored and ohemorange committed Mar 16, 2018
1 parent 5ecb68f commit 79d90d6
Show file tree
Hide file tree
Showing 8 changed files with 204 additions and 22 deletions.
94 changes: 76 additions & 18 deletions certbot-nginx/certbot_nginx/configurator.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ class NginxConfigurator(common.Installer):

DEFAULT_LISTEN_PORT = '80'

# SSL directives that Certbot can add when installing a new certificate.
SSL_DIRECTIVES = ['ssl_certificate', 'ssl_certificate_key', 'ssl_dhparam']

@classmethod
def add_parser_arguments(cls, add):
add("server-root", default=constants.CLI_DEFAULTS["server_root"],
Expand Down Expand Up @@ -105,6 +108,7 @@ def __init__(self, *args, **kwargs):
self.parser = None
self.version = version
self._enhance_func = {"redirect": self._enable_redirect,
"ensure-http-header": self._set_http_header,
"staple-ocsp": self._enable_ocsp_stapling}

self.reverter.recovery_routine()
Expand Down Expand Up @@ -621,7 +625,7 @@ def _make_server_ssl(self, vhost):
##################################
def supported_enhancements(self): # pylint: disable=no-self-use
"""Returns currently supported enhancements."""
return ['redirect', 'staple-ocsp']
return ['redirect', 'ensure-http-header', 'staple-ocsp']

def enhance(self, domain, enhancement, options=None):
"""Enhance configuration.
Expand All @@ -647,6 +651,40 @@ def _has_certbot_redirect(self, vhost, domain):
test_redirect_block = _test_block_from_block(_redirect_block_for_domain(domain))
return vhost.contains_list(test_redirect_block)

def _set_http_header(self, domain, header_substring):
"""Enables header identified by header_substring on domain.
If the vhost is listening plaintextishly, separates out the relevant
directives into a new server block, and only add header directive to
HTTPS block.
:param str domain: the domain to enable header for.
:param str header_substring: String to uniquely identify a header.
e.g. Strict-Transport-Security, Upgrade-Insecure-Requests
:returns: Success
:raises .errors.PluginError: If no viable HTTPS host can be created or
set with header header_substring.
"""
vhosts = self.choose_vhosts(domain)
if not vhosts:
raise errors.PluginError(
"Unable to find corresponding HTTPS host for enhancement.")
for vhost in vhosts:
if vhost.has_header(header_substring):
raise errors.PluginEnhancementAlreadyPresent(
"Existing %s header" % (header_substring))

# if there is no separate SSL block, break the block into two and
# choose the SSL block.
if vhost.ssl and any([not addr.ssl for addr in vhost.addrs]):
_, vhost = self._split_block(vhost)

header_directives = [
['\n ', 'add_header', ' ', header_substring, ' '] +
constants.HEADER_ARGS[header_substring],
['\n']]
self.parser.add_server_directives(vhost, header_directives, replace=False)

def _add_redirect_block(self, vhost, domain):
"""Add redirect directive to vhost
"""
Expand All @@ -655,6 +693,39 @@ def _add_redirect_block(self, vhost, domain):
self.parser.add_server_directives(
vhost, redirect_block, replace=False, insert_at_top=True)

def _split_block(self, vhost, only_directives=None):
"""Splits this "virtual host" (i.e. this nginx server block) into
separate HTTP and HTTPS blocks.
:param vhost: The server block to break up into two.
:param list only_directives: If this exists, only duplicate these directives
when splitting the block.
:type vhost: :class:`~certbot_nginx.obj.VirtualHost`
:returns: tuple (http_vhost, https_vhost)
:rtype: tuple of type :class:`~certbot_nginx.obj.VirtualHost`
"""
http_vhost = self.parser.duplicate_vhost(vhost, only_directives=only_directives)

def _ssl_match_func(directive):
return 'ssl' in directive

def _ssl_config_match_func(directive):
return self.mod_ssl_conf in directive

def _no_ssl_match_func(directive):
return 'ssl' not in directive

# remove all ssl addresses and related directives from the new block
for directive in self.SSL_DIRECTIVES:
self.parser.remove_server_directives(http_vhost, directive)
self.parser.remove_server_directives(http_vhost, 'listen', match_func=_ssl_match_func)
self.parser.remove_server_directives(http_vhost, 'include',
match_func=_ssl_config_match_func)

# remove all non-ssl addresses from the existing block
self.parser.remove_server_directives(vhost, 'listen', match_func=_no_ssl_match_func)
return http_vhost, vhost

def _enable_redirect(self, domain, unused_options):
"""Redirect all equivalent HTTP traffic to ssl_vhost.
Expand Down Expand Up @@ -694,28 +765,15 @@ def _enable_redirect_single(self, domain, vhost):
:param `~obj.Vhost` vhost: vhost to enable redirect for
"""

new_vhost = None
http_vhost = None
if vhost.ssl:
new_vhost = self.parser.duplicate_vhost(vhost,
only_directives=['listen', 'server_name'])

def _ssl_match_func(directive):
return 'ssl' in directive

def _no_ssl_match_func(directive):
return 'ssl' not in directive

# remove all ssl addresses from the new block
self.parser.remove_server_directives(new_vhost, 'listen', match_func=_ssl_match_func)

# remove all non-ssl addresses from the existing block
self.parser.remove_server_directives(vhost, 'listen', match_func=_no_ssl_match_func)
http_vhost, _ = self._split_block(vhost, ['listen', 'server_name'])

# Add this at the bottom to get the right order of directives
return_404_directive = [['\n ', 'return', ' ', '404']]
self.parser.add_server_directives(new_vhost, return_404_directive, replace=False)
self.parser.add_server_directives(http_vhost, return_404_directive, replace=False)

vhost = new_vhost
vhost = http_vhost

if self._has_certbot_redirect(vhost, domain):
logger.info("Traffic on port %s already redirecting to ssl in %s",
Expand Down
4 changes: 4 additions & 0 deletions certbot-nginx/certbot_nginx/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,7 @@ def os_constant(key):
:return: value of constant for active os
"""
return CLI_DEFAULTS[key]

HSTS_ARGS = ['\"max-age=31536000\"', ' ', 'always']

HEADER_ARGS = {'Strict-Transport-Security': HSTS_ARGS}
26 changes: 25 additions & 1 deletion certbot-nginx/certbot_nginx/obj.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from certbot.plugins import common

REDIRECT_DIRECTIVES = ['return', 'rewrite']
ADD_HEADER_DIRECTIVE = 'add_header'

class Addr(common.Addr):
r"""Represents an Nginx address, i.e. what comes after the 'listen'
Expand Down Expand Up @@ -198,6 +198,14 @@ def __hash__(self):
tuple(self.addrs), tuple(self.names),
self.ssl, self.enabled))

def has_header(self, header_name):
"""Determine if this server block has a particular header set.
:param str header_name: The name of the header to check for, e.g.
'Strict-Transport-Security'
"""
found = _find_directive(self.raw, ADD_HEADER_DIRECTIVE, header_name)
return found is not None

def contains_list(self, test):
"""Determine if raw server block contains test list at top level
"""
Expand Down Expand Up @@ -233,3 +241,19 @@ def display_repr(self):
addrs=", ".join(str(addr) for addr in self.addrs),
names=", ".join(self.names),
https="Yes" if self.ssl else "No"))

def _find_directive(directives, directive_name, match_content=None):
"""Find a directive of type directive_name in directives. If match_content is given,
Searches for `match_content` in the directive arguments.
"""
if not directives or isinstance(directives, six.string_types) or len(directives) == 0:
return None

# If match_content is None, just match on directive type. Otherwise, match on
# both directive type -and- the content!
if directives[0] == directive_name and \
(match_content is None or match_content in directives):
return directives

matches = (_find_directive(line, directive_name, match_content) for line in directives)
return next((m for m in matches if m is not None), None)
7 changes: 7 additions & 0 deletions certbot-nginx/certbot_nginx/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ def duplicate_vhost(self, vhost_template, delete_default=False, only_directives=
del directive[directive.index('default_server')]
return new_vhost


def _parse_ssl_options(ssl_options):
if ssl_options is not None:
try:
Expand Down Expand Up @@ -667,13 +668,19 @@ def can_append(loc, dir_name):
elif block[location] != directive:
raise errors.MisconfigurationError(err_fmt.format(directive, block[location]))

def _is_certbot_comment(directive):
return '#' in directive and COMMENT in directive

def _remove_directives(directive_name, match_func, block):
"""Removes directives of name directive_name from a config block if match_func matches.
"""
while True:
location = _find_location(block, directive_name, match_func=match_func)
if location is None:
return
# if the directive was made by us, remove the comment following
if location + 1 < len(block) and _is_certbot_comment(block[location + 1]):
del block[location + 1]
del block[location]

def _apply_global_addr_ssl(addr_to_ssl, parsed_server):
Expand Down
50 changes: 49 additions & 1 deletion certbot-nginx/certbot_nginx/tests/configurator_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def test_get_all_names(self, mock_gethostbyaddr):
"globalssl.com", "globalsslsetssl.com", "ipv6.com", "ipv6ssl.com"]))

def test_supported_enhancements(self):
self.assertEqual(['redirect', 'staple-ocsp'],
self.assertEqual(['redirect', 'ensure-http-header', 'staple-ocsp'],
self.config.supported_enhancements())

def test_enhance(self):
Expand Down Expand Up @@ -510,6 +510,54 @@ def test_split_for_redirect(self):
['return', '404'], ['#', ' managed by Certbot'], [], [], []]]],
generated_conf)

def test_split_for_headers(self):
example_conf = self.config.parser.abs_path('sites-enabled/example.com')
self.config.deploy_cert(
"example.org",
"example/cert.pem",
"example/key.pem",
"example/chain.pem",
"example/fullchain.pem")
self.config.enhance("www.example.com", "ensure-http-header", "Strict-Transport-Security")
generated_conf = self.config.parser.parsed[example_conf]
self.assertEqual(
[[['server'], [
['server_name', '.example.com'],
['server_name', 'example.*'], [],
['listen', '5001', 'ssl'], ['#', ' managed by Certbot'],
['ssl_certificate', 'example/fullchain.pem'], ['#', ' managed by Certbot'],
['ssl_certificate_key', 'example/key.pem'], ['#', ' managed by Certbot'],
['include', self.config.mod_ssl_conf], ['#', ' managed by Certbot'],
['ssl_dhparam', self.config.ssl_dhparams], ['#', ' managed by Certbot'],
[], [],
['add_header', 'Strict-Transport-Security', '"max-age=31536000"', 'always'],
['#', ' managed by Certbot'],
[], []]],
[['server'], [
['listen', '69.50.225.155:9000'],
['listen', '127.0.0.1'],
['server_name', '.example.com'],
['server_name', 'example.*'],
[], [], []]]],
generated_conf)

def test_http_header_hsts(self):
example_conf = self.config.parser.abs_path('sites-enabled/example.com')
self.config.enhance("www.example.com", "ensure-http-header",
"Strict-Transport-Security")
expected = ['add_header', 'Strict-Transport-Security', '"max-age=31536000"', 'always']
generated_conf = self.config.parser.parsed[example_conf]
self.assertTrue(util.contains_at_depth(generated_conf, expected, 2))

def test_http_header_hsts_twice(self):
self.config.enhance("www.example.com", "ensure-http-header",
"Strict-Transport-Security")
self.assertRaises(
errors.PluginEnhancementAlreadyPresent,
self.config.enhance, "www.example.com",
"ensure-http-header", "Strict-Transport-Security")


@mock.patch('certbot_nginx.obj.VirtualHost.contains_list')
def test_certbot_redirect_exists(self, mock_contains_list):
# Test that we add no redirect statement if there is already a
Expand Down
15 changes: 15 additions & 0 deletions certbot-nginx/certbot_nginx/tests/obj_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,15 @@ def setUp(self):
"filp",
set([Addr.fromstring("localhost")]), False, False,
set(['localhost']), raw4, [])
raw_has_hsts = [
['listen', '69.50.225.155:9000'],
['server_name', 'return.com'],
['add_header', 'always', 'set', 'Strict-Transport-Security', '\"max-age=31536000\"'],
]
self.vhost_has_hsts = VirtualHost(
"filep",
set([Addr.fromstring("localhost")]), False, False,
set(['localhost']), raw_has_hsts, [])

def test_eq(self):
from certbot_nginx.obj import Addr
Expand All @@ -162,6 +171,12 @@ def test_str(self):
'enabled: False'])
self.assertEqual(stringified, str(self.vhost1))

def test_has_header(self):
self.assertTrue(self.vhost_has_hsts.has_header('Strict-Transport-Security'))
self.assertFalse(self.vhost_has_hsts.has_header('Bogus-Header'))
self.assertFalse(self.vhost1.has_header('Strict-Transport-Security'))
self.assertFalse(self.vhost1.has_header('Bogus-Header'))

def test_contains_list(self):
from certbot_nginx.obj import VirtualHost
from certbot_nginx.obj import Addr
Expand Down
26 changes: 26 additions & 0 deletions certbot-nginx/certbot_nginx/tests/parser_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,32 @@ def test_has_ssl_on_directive(self):
['server_name', '*.www.foo.com', '*.www.example.com']]
self.assertTrue(nparser.has_ssl_on_directive(mock_vhost))


def test_remove_server_directives(self):
nparser = parser.NginxParser(self.config_path)
mock_vhost = obj.VirtualHost(nparser.abs_path('nginx.conf'),
None, None, None,
set(['localhost',
r'~^(www\.)?(example|bar)\.']),
None, [10, 1, 9])
example_com = nparser.abs_path('sites-enabled/example.com')
names = set(['.example.com', 'example.*'])
mock_vhost.filep = example_com
mock_vhost.names = names
mock_vhost.path = [0]
nparser.add_server_directives(mock_vhost,
[['foo', 'bar'], ['ssl_certificate',
'/etc/ssl/cert2.pem']],
replace=False)
nparser.remove_server_directives(mock_vhost, 'foo')
nparser.remove_server_directives(mock_vhost, 'ssl_certificate')
self.assertEqual(nparser.parsed[example_com],
[[['server'], [['listen', '69.50.225.155:9000'],
['listen', '127.0.0.1'],
['server_name', '.example.com'],
['server_name', 'example.*'],
[]]]])

def test_add_server_directives(self):
nparser = parser.NginxParser(self.config_path)
mock_vhost = obj.VirtualHost(nparser.abs_path('nginx.conf'),
Expand Down
4 changes: 2 additions & 2 deletions certbot/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,13 @@
"""Defaults for renewer script."""


ENHANCEMENTS = ["redirect", "http-header", "ocsp-stapling", "spdy"]
ENHANCEMENTS = ["redirect", "ensure-http-header", "ocsp-stapling", "spdy"]
"""List of possible :class:`certbot.interfaces.IInstaller`
enhancements.
List of expected options parameters:
- redirect: None
- http-header: TODO
- ensure-http-header: name of header (i.e. Strict-Transport-Security)
- ocsp-stapling: certificate chain file path
- spdy: TODO
Expand Down

0 comments on commit 79d90d6

Please sign in to comment.