Merge "Add support for HTTP Strict Transport Security"

This commit is contained in:
Zuul 2023-08-23 20:23:51 +00:00 committed by Gerrit Code Review
commit a904f35304
30 changed files with 527 additions and 58 deletions

View File

@ -808,6 +808,62 @@ healthmonitor-url_path-optional:
in: body
required: false
type: string
hsts_include_subdomains:
description: |
Defines whether the ``includeSubDomains`` directive should be
added to the Strict-Transport-Security HTTP response
header.
in: body
min_version: 2.27
required: true
type: bool
hsts_include_subdomains-optional:
description: |
Defines whether the ``includeSubDomains`` directive should be
added to the Strict-Transport-Security HTTP response
header. This requires setting the ``hsts_max_age`` option as well in
order to become effective.
in: body
min_version: 2.27
required: false
type: bool
hsts_max_age:
description: |
The value of the ``max_age`` directive for the
Strict-Transport-Security HTTP response header.
in: body
min_version: 2.27
required: true
type: integer
hsts_max_age-optional:
description: |
The value of the ``max_age`` directive for the
Strict-Transport-Security HTTP response header.
Setting this enables HTTP Strict Transport
Security (HSTS) for the TLS-terminated listener.
in: body
min_version: 2.27
required: false
type: integer
hsts_preload:
description: |
Defines whether the ``preload`` directive should be
added to the Strict-Transport-Security HTTP response
header.
in: body
min_version: 2.27
required: true
type: bool
hsts_preload-optional:
description: |
Defines whether the ``preload`` directive should be
added to the Strict-Transport-Security HTTP response
header. This requires setting the ``hsts_max_age`` option as well in
order to become effective.
in: body
min_version: 2.27
required: false
type: bool
id:
description: |
The ID of the resource.

View File

@ -1 +1 @@
curl -X POST -H "Content-Type: application/json" -H "X-Auth-Token: <token>" -d '{"listener": {"protocol": "TERMINATED_HTTPS", "description": "A great TLS listener", "admin_state_up": true, "connection_limit": 200, "protocol_port": "443", "loadbalancer_id": "607226db-27ef-4d41-ae89-f2a800e9c2db", "name": "great_tls_listener", "insert_headers": {"X-Forwarded-For": "true", "X-Forwarded-Port": "true"}, "default_tls_container_ref": "http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "sni_container_refs": ["http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "http://198.51.100.10:9311/v1/containers/aaebb31e-7761-4826-8cb4-2b829caca3ee"], "timeout_client_data": 50000, "timeout_member_connect": 5000, "timeout_member_data": 50000, "timeout_tcp_inspect": 0, "tags": ["test_tag"], "client_ca_tls_container_ref": "http://198.51.100.10:9311/v1/containers/35649991-49f3-4625-81ce-2465fe8932e5", "client_authentication": "MANDATORY", "client_crl_container_ref": "http://198.51.100.10:9311/v1/containers/e222b065-b93b-4e2a-9a02-804b7a118c3c", "allowed_cidrs": ["192.0.2.0/24", "198.51.100.0/24"], "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", "tls_versions": ["TLSv1.2", "TLSv1.3"], "alpn_protocols": ["http/1.1", "http/1.0"]}}' http://198.51.100.10:9876/v2/lbaas/listeners
curl -X POST -H "Content-Type: application/json" -H "X-Auth-Token: <token>" -d '{"listener": {"protocol": "TERMINATED_HTTPS", "description": "A great TLS listener", "admin_state_up": true, "connection_limit": 200, "protocol_port": "443", "loadbalancer_id": "607226db-27ef-4d41-ae89-f2a800e9c2db", "name": "great_tls_listener", "insert_headers": {"X-Forwarded-For": "true", "X-Forwarded-Port": "true"}, "default_tls_container_ref": "http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "sni_container_refs": ["http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "http://198.51.100.10:9311/v1/containers/aaebb31e-7761-4826-8cb4-2b829caca3ee"], "timeout_client_data": 50000, "timeout_member_connect": 5000, "timeout_member_data": 50000, "timeout_tcp_inspect": 0, "tags": ["test_tag"], "client_ca_tls_container_ref": "http://198.51.100.10:9311/v1/containers/35649991-49f3-4625-81ce-2465fe8932e5", "client_authentication": "MANDATORY", "client_crl_container_ref": "http://198.51.100.10:9311/v1/containers/e222b065-b93b-4e2a-9a02-804b7a118c3c", "allowed_cidrs": ["192.0.2.0/24", "198.51.100.0/24"], "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", "tls_versions": ["TLSv1.2", "TLSv1.3"], "alpn_protocols": ["http/1.1", "http/1.0"], "hsts_include_subdomains": true, "hsts_max_age": 31536000, "hsts_preload": true}}' http://198.51.100.10:9876/v2/lbaas/listeners

View File

@ -30,6 +30,9 @@
],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256",
"tls_versions": ["TLSv1.2", "TLSv1.3"],
"alpn_protocols": ["http/1.1", "http/1.0"]
"alpn_protocols": ["http/1.1", "http/1.0"],
"hsts_include_subdomains": true,
"hsts_max_age": 31536000,
"hsts_preload": true
}
}

View File

@ -44,7 +44,16 @@
"198.51.100.0/24"
],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256",
"tls_versions": ["TLSv1.2", "TLSv1.3"],
"alpn_protocols": ["http/1.1", "http/1.0"]
"tls_versions": [
"TLSv1.2",
"TLSv1.3"
],
"alpn_protocols": [
"http/1.1",
"http/1.0"
],
"hsts_include_subdomains": true,
"hsts_max_age": 31536000,
"hsts_preload": true
}
}

View File

@ -44,7 +44,16 @@
"198.51.100.0/24"
],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256",
"tls_versions": ["TLSv1.2", "TLSv1.3"],
"alpn_protocols": ["http/1.1", "http/1.0"]
"tls_versions": [
"TLSv1.2",
"TLSv1.3"
],
"alpn_protocols": [
"http/1.1",
"http/1.0"
],
"hsts_include_subdomains": true,
"hsts_max_age": 31536000,
"hsts_preload": true
}
}

View File

@ -1 +1 @@
curl -X PUT -H "Content-Type: application/json" -H "X-Auth-Token: <token>" -d '{"listener": {"description": "An updated great TLS listener", "admin_state_up": true, "connection_limit": 200, "name": "great_updated_tls_listener", "insert_headers": {"X-Forwarded-For": "false", "X-Forwarded-Port": "true"}, "default_tls_container_ref": "http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "sni_container_refs": ["http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "http://198.51.100.10:9311/v1/containers/aaebb31e-7761-4826-8cb4-2b829caca3ee"], "timeout_client_data": 100000, "timeout_member_connect": 1000, "timeout_member_data": 100000, "timeout_tcp_inspect": 5, "tags": ["updated_tag"], "client_ca_tls_container_ref": null, "allowed_cidrs": ["192.0.2.0/24", "198.51.100.0/24"], "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", "tls_versions": ["TLSv1.2", "TLSv1.3"], "alpn_protocols": ["http/1.1", "http/1.0"]}}' http://198.51.100.10:9876/v2/lbaas/listeners/023f2e34-7806-443b-bfae-16c324569a3d
curl -X PUT -H "Content-Type: application/json" -H "X-Auth-Token: <token>" -d '{"listener": {"description": "An updated great TLS listener", "admin_state_up": true, "connection_limit": 200, "name": "great_updated_tls_listener", "insert_headers": {"X-Forwarded-For": "false", "X-Forwarded-Port": "true"}, "default_tls_container_ref": "http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "sni_container_refs": ["http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "http://198.51.100.10:9311/v1/containers/aaebb31e-7761-4826-8cb4-2b829caca3ee"], "timeout_client_data": 100000, "timeout_member_connect": 1000, "timeout_member_data": 100000, "timeout_tcp_inspect": 5, "tags": ["updated_tag"], "client_ca_tls_container_ref": null, "allowed_cidrs": ["192.0.2.0/24", "198.51.100.0/24"], "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", "tls_versions": ["TLSv1.2", "TLSv1.3"], "alpn_protocols": ["http/1.1", "http/1.0"], "hsts_include_subdomains": true, "hsts_max_age": 31536000, "hsts_preload": true}}' http://198.51.100.10:9876/v2/lbaas/listeners/023f2e34-7806-443b-bfae-16c324569a3d

View File

@ -18,14 +18,25 @@
"timeout_member_connect": 1000,
"timeout_member_data": 100000,
"timeout_tcp_inspect": 5,
"tags": ["updated_tag"],
"tags": [
"updated_tag"
],
"client_ca_tls_container_ref": null,
"allowed_cidrs": [
"192.0.2.0/24",
"198.51.100.0/24"
],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256",
"tls_versions": ["TLSv1.2", "TLSv1.3"],
"alpn_protocols": ["http/1.1", "http/1.0"]
"tls_versions": [
"TLSv1.2",
"TLSv1.3"
],
"alpn_protocols": [
"http/1.1",
"http/1.0"
],
"hsts_include_subdomains": true,
"hsts_max_age": 31536000,
"hsts_preload": true
}
}

View File

@ -44,7 +44,16 @@
"198.51.100.0/24"
],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256",
"tls_versions": ["TLSv1.2", "TLSv1.3"],
"alpn_protocols": ["http/1.1", "http/1.0"]
"tls_versions": [
"TLSv1.2",
"TLSv1.3"
],
"alpn_protocols": [
"http/1.1",
"http/1.0"
],
"hsts_include_subdomains": true,
"hsts_max_age": 31536000,
"hsts_preload": true
}
}

View File

@ -37,7 +37,9 @@
"timeout_member_connect": 5000,
"timeout_member_data": 50000,
"timeout_tcp_inspect": 0,
"tags": ["test_tag"],
"tags": [
"test_tag"
],
"client_ca_tls_container_ref": "http://198.51.100.10:9311/v1/containers/35649991-49f3-4625-81ce-2465fe8932e5",
"client_authentication": "NONE",
"client_crl_container_ref": "http://198.51.100.10:9311/v1/containers/e222b065-b93b-4e2a-9a02-804b7a118c3c",
@ -46,8 +48,17 @@
"198.51.100.0/24"
],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256",
"tls_versions": ["TLSv1.2", "TLSv1.3"],
"alpn_protocols": ["http/1.1", "http/1.0"]
"tls_versions": [
"TLSv1.2",
"TLSv1.3"
],
"alpn_protocols": [
"http/1.1",
"http/1.0"
],
"hsts_include_subdomains": true,
"hsts_max_age": 31536000,
"hsts_preload": true
}
]
}

View File

@ -56,6 +56,9 @@ Response Parameters
- default_pool_id: default_pool_id
- default_tls_container_ref: default_tls_container_ref
- description: description
- hsts_include_subdomains: hsts_include_subdomains
- hsts_max_age: hsts_max_age
- hsts_preload: hsts_preload
- id: listener-id
- insert_headers: insert_headers
- l7policies: l7policy-ids
@ -153,6 +156,9 @@ Request
- default_pool_id: default_pool_id-optional
- default_tls_container_ref: default_tls_container_ref-optional
- description: description-optional
- hsts_include_subdomains: hsts_include_subdomains-optional
- hsts_max_age: hsts_max_age-optional
- hsts_preload: hsts_preload-optional
- insert_headers: insert_headers-optional
- l7policies: l7policies-optional
- listeners: listener
@ -277,6 +283,9 @@ Response Parameters
- default_pool_id: default_pool_id
- default_tls_container_ref: default_tls_container_ref
- description: description
- hsts_include_subdomains: hsts_include_subdomains
- hsts_max_age: hsts_max_age
- hsts_preload: hsts_preload
- id: listener-id
- insert_headers: insert_headers
- l7policies: l7policy-ids
@ -358,6 +367,9 @@ Response Parameters
- default_pool_id: default_pool_id
- default_tls_container_ref: default_tls_container_ref
- description: description
- hsts_include_subdomains: hsts_include_subdomains
- hsts_max_age: hsts_max_age
- hsts_preload: hsts_preload
- id: listener-id
- insert_headers: insert_headers
- l7policies: l7policy-ids
@ -428,6 +440,9 @@ Request
- default_pool_id: default_pool_id-optional
- default_tls_container_ref: default_tls_container_ref-optional
- description: description-optional
- hsts_include_subdomains: hsts_include_subdomains-optional
- hsts_max_age: hsts_max_age-optional
- hsts_preload: hsts_preload-optional
- insert_headers: insert_headers-optional
- listener_id: path-listener-id
- name: name-optional
@ -468,6 +483,9 @@ Response Parameters
- default_pool_id: default_pool_id
- default_tls_container_ref: default_tls_container_ref
- description: description
- hsts_include_subdomains: hsts_include_subdomains
- hsts_max_age: hsts_max_age
- hsts_preload: hsts_preload
- id: listener-id
- insert_headers: insert_headers
- l7policies: l7policy-ids

View File

@ -440,6 +440,13 @@ balancer features, like Layer 7 features and header manipulation.
openstack loadbalancer member create --subnet-id private-subnet --address 192.0.2.10 --protocol-port 80 pool1
openstack loadbalancer member create --subnet-id private-subnet --address 192.0.2.11 --protocol-port 80 pool1
.. note::
A good security practise for production servers is to enable
HTTP Strict Transport Security (HSTS),
which can be configured during listener creation using the
``--hsts-max-age`` option and optionally ``--hsts-include-subdomains``
``--hsts-prefetch``.
Deploy a TLS-terminated HTTPS load balancer with SNI
----------------------------------------------------

View File

@ -143,6 +143,9 @@ class RootController(object):
self._add_a_version(versions, 'v2.25', 'v2', 'SUPPORTED',
'2021-10-02T00:00:00Z', host_url)
# Additional VIPs
self._add_a_version(versions, 'v2.26', 'v2', 'CURRENT',
self._add_a_version(versions, 'v2.26', 'v2', 'SUPPORTED',
'2022-08-29T00:00:00Z', host_url)
# HTTP Strict Transport Security (HSTS)
self._add_a_version(versions, 'v2.27', 'v2', 'CURRENT',
'2023-05-05T00:00:00Z', host_url)
return {'versions': versions}

View File

@ -325,6 +325,8 @@ class ListenersController(base.BaseController):
# Validate ALPN protocol list
validate.check_alpn_protocols(listener_dict['alpn_protocols'])
validate.check_hsts_options(listener_dict)
try:
db_listener = self.repositories.listener.create(
lock_session, **listener_dict)
@ -348,7 +350,6 @@ class ListenersController(base.BaseController):
except odb_exceptions.DBError as e:
raise exceptions.InvalidOption(value=listener_dict.get('protocol'),
option='protocol') from e
return None
@wsme_pecan.wsexpose(listener_types.ListenerRootResponse,
body=listener_types.ListenerRootPOST, status_code=201)
@ -563,6 +564,8 @@ class ListenersController(base.BaseController):
# Validate ALPN protocol list
validate.check_alpn_protocols(listener.alpn_protocols)
validate.check_hsts_options_put(listener, db_listener)
def _set_default_on_none(self, listener):
"""Reset settings to their default values if None/null was passed in
@ -598,10 +601,14 @@ class ListenersController(base.BaseController):
if listener.alpn_protocols is None:
listener.alpn_protocols = (
CONF.api_settings.default_listener_alpn_protocols)
if listener.hsts_include_subdomains is None:
listener.hsts_include_subdomains = False
if listener.hsts_preload is None:
listener.hsts_preload = False
@wsme_pecan.wsexpose(listener_types.ListenerRootResponse, wtypes.text,
body=listener_types.ListenerRootPUT, status_code=200)
def put(self, id, listener_):
def put(self, id, listener_: listener_types.ListenerRootPUT):
"""Updates a listener on a load balancer."""
listener = listener_.listener
context = pecan_request.context.get('octavia_context')

View File

@ -63,6 +63,9 @@ class ListenerResponse(BaseListenerType):
tls_ciphers = wtypes.StringType()
tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType()))
alpn_protocols = wtypes.wsattr(wtypes.ArrayType(types.AlpnProtocolType()))
hsts_max_age = wtypes.wsattr(wtypes.IntegerType())
hsts_include_subdomains = wtypes.wsattr(bool)
hsts_preload = wtypes.wsattr(bool)
@classmethod
def from_data_model(cls, data_model, children=False):
@ -86,6 +89,9 @@ class ListenerResponse(BaseListenerType):
listener.tls_versions = data_model.tls_versions
listener.alpn_protocols = data_model.alpn_protocols
listener.hsts_max_age = data_model.hsts_max_age
listener.hsts_include_subdomains = data_model.hsts_include_subdomains
listener.hsts_preload = data_model.hsts_preload
return listener
@ -155,6 +161,9 @@ class ListenerPOST(BaseListenerType):
tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType(
max_length=32)))
alpn_protocols = wtypes.wsattr(wtypes.ArrayType(types.AlpnProtocolType()))
hsts_max_age = wtypes.wsattr(wtypes.IntegerType(minimum=0))
hsts_include_subdomains = wtypes.wsattr(bool, default=False)
hsts_preload = wtypes.wsattr(bool, default=False)
class ListenerRootPOST(types.BaseType):
@ -196,6 +205,9 @@ class ListenerPUT(BaseListenerType):
tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType(
max_length=32)))
alpn_protocols = wtypes.wsattr(wtypes.ArrayType(types.AlpnProtocolType()))
hsts_max_age = wtypes.wsattr(wtypes.IntegerType(minimum=0))
hsts_include_subdomains = wtypes.wsattr(bool)
hsts_preload = wtypes.wsattr(bool)
class ListenerRootPUT(types.BaseType):
@ -247,6 +259,9 @@ class ListenerSingleCreate(BaseListenerType):
tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType(
max_length=32)))
alpn_protocols = wtypes.wsattr(wtypes.ArrayType(types.AlpnProtocolType()))
hsts_max_age = wtypes.wsattr(wtypes.IntegerType())
hsts_include_subdomains = wtypes.wsattr(bool, default=False)
hsts_preload = wtypes.wsattr(bool, default=False)
class ListenerStatusResponse(BaseListenerType):

View File

@ -16,6 +16,7 @@
import datetime
import re
import typing as tp
from oslo_log import log as logging
from sqlalchemy.orm import collections
@ -419,7 +420,8 @@ class Listener(BaseDataModel):
tags=None, client_ca_tls_certificate_id=None,
client_authentication=None, client_crl_container_id=None,
allowed_cidrs=None, tls_ciphers=None, tls_versions=None,
alpn_protocols=None):
alpn_protocols=None, hsts_max_age=None,
hsts_include_subdomains=None, hsts_preload=None):
self.id = id
self.project_id = project_id
self.name = name
@ -455,6 +457,10 @@ class Listener(BaseDataModel):
self.tls_ciphers = tls_ciphers
self.tls_versions = tls_versions
self.alpn_protocols = alpn_protocols
self.hsts_max_age: tp.Optional[int] = hsts_max_age
self.hsts_include_subdomains: tp.Optional[bool] = (
hsts_include_subdomains)
self.hsts_preload: tp.Optional[bool] = hsts_preload
def update(self, update_dict):
for key, value in update_dict.items():

View File

@ -23,6 +23,7 @@ from oslo_utils import versionutils
from octavia.common.config import cfg
from octavia.common import constants
from octavia.common import utils as octavia_utils
from octavia.db import models
PROTOCOL_MAP = {
constants.PROTOCOL_TCP: 'tcp',
@ -298,7 +299,8 @@ class JinjaTemplater(object):
'vrrp_priority': amphora.vrrp_priority
}
def _transform_listener(self, listener, tls_certs, feature_compatibility,
def _transform_listener(self, listener: models.Listener, tls_certs,
feature_compatibility,
loadbalancer):
"""Transforms a listener into an object that will
@ -363,6 +365,13 @@ class JinjaTemplater(object):
ret_value['tls_versions'] = listener.tls_versions
if listener.alpn_protocols is not None:
ret_value['alpn_protocols'] = ",".join(listener.alpn_protocols)
if listener.hsts_max_age is not None:
hsts_directives = f"max-age={listener.hsts_max_age};"
if listener.hsts_include_subdomains:
hsts_directives += " includeSubDomains;"
if listener.hsts_preload:
hsts_directives += " preload;"
ret_value['hsts_directives'] = hsts_directives
pools = []
pool_gen = (pool for pool in listener.pools if

View File

@ -166,6 +166,9 @@ frontend {{ listener.id }}
{% if (listener.protocol.lower() ==
constants.PROTOCOL_TERMINATED_HTTPS.lower()) %}
redirect scheme https if !{ ssl_fc }
{% if listener.hsts_directives is defined %}
http-response set-header Strict-Transport-Security "{{ listener.hsts_directives }}"
{% endif %}
{% endif %}
{{ bind_macro(constants, lib_consts, listener, lb_vip_address)|trim() }}
{% for add_vip in additional_vips %}

View File

@ -20,6 +20,7 @@ from octavia_lib.common import constants as lib_consts
from octavia.common.config import cfg
from octavia.common import constants
from octavia.common import utils as octavia_utils
from octavia.db import models
CONF = cfg.CONF
@ -59,7 +60,7 @@ class LvsJinjaTemplater(object):
self.keepalivedlvs_template = (keepalivedlvs_template or
KEEPALIVED_LVS_TEMPLATE)
def build_config(self, listener, **kwargs):
def build_config(self, listener: models.Listener, **kwargs):
"""Convert a logical configuration to the Keepalived LVS version
:param listener: The listener configuration
@ -97,7 +98,8 @@ class LvsJinjaTemplater(object):
constants=constants,
lib_consts=lib_consts)
def _transform_loadbalancer(self, loadbalancer, listener):
def _transform_loadbalancer(self, loadbalancer: models.LoadBalancer,
listener: models.Listener):
"""Transforms a load balancer into an object that will
be processed by the templating system

View File

@ -28,11 +28,13 @@ from rfc3986 import validators
from wsme import types as wtypes
from octavia.common import constants
from octavia.common import data_models
from octavia.common import exceptions
from octavia.common import utils
from octavia.i18n import _
CONF = cfg.CONF
_ListenerPUT = 'octavia.api.v2.types.listener.ListenerPUT'
def url(url, require_scheme=True):
@ -531,3 +533,36 @@ def check_alpn_protocols(protocols):
if invalid_protocols:
raise exceptions.ValidationException(
detail=_('Invalid ALPN protocol: ' + ', '.join(invalid_protocols)))
def check_hsts_options(listener: dict):
if ((listener.get('hsts_include_subdomains') or
listener.get('hsts_preload')) and
not isinstance(listener.get('hsts_max_age'), int)):
raise exceptions.ValidationException(
detail=_('HSTS configuration options hsts_include_subdomains and '
'hsts_preload only make sense if hsts_max_age is '
'set as well.'))
if (isinstance(listener.get('hsts_max_age'), int) and
listener['protocol'] != constants.PROTOCOL_TERMINATED_HTTPS):
raise exceptions.ValidationException(
detail=_('The HSTS feature can only be used for listeners using '
'the TERMINATED_HTTPS protocol.'))
def check_hsts_options_put(listener: _ListenerPUT,
db_listener: data_models.Listener):
hsts_disabled = all(obj.hsts_max_age in [None, wtypes.Unset] for obj
in (db_listener, listener))
if ((listener.hsts_include_subdomains or listener.hsts_preload) and
hsts_disabled):
raise exceptions.ValidationException(
detail=_('Cannot enable hsts_include_subdomains or hsts_preload '
'if hsts_max_age was not set as well.'))
if (isinstance(listener.hsts_max_age, int) and
db_listener.protocol != constants.PROTOCOL_TERMINATED_HTTPS):
raise exceptions.ValidationException(
detail=_('The HSTS feature can only be used for listeners using '
'the TERMINATED_HTTPS protocol.'))

View File

@ -0,0 +1,42 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""Add HTTP Strict Transport Security support
Revision ID: 632152d2d32e
Revises: 0995c26fc506
Create Date: 2023-04-19 13:36:44.015581
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '632152d2d32e'
down_revision = '0995c26fc506'
def upgrade():
op.add_column(
'listener',
sa.Column('hsts_max_age', sa.Integer, nullable=True)
)
op.add_column(
'listener',
sa.Column('hsts_include_subdomains', sa.Boolean, nullable=True)
)
op.add_column(
'listener',
sa.Column('hsts_preload', sa.Boolean, nullable=True)
)

View File

@ -603,6 +603,9 @@ class Listener(base_models.BASE, base_models.IdMixin,
tls_ciphers = sa.Column(sa.String(2048), nullable=True)
tls_versions = sa.Column(ScalarListType(), nullable=True)
alpn_protocols = sa.Column(ScalarListType(), nullable=True)
hsts_max_age = sa.Column(sa.Integer, nullable=True)
hsts_include_subdomains = sa.Column(sa.Boolean, nullable=True)
hsts_preload = sa.Column(sa.Boolean, nullable=True)
_tags = orm.relationship(
'Tags',

View File

@ -476,7 +476,10 @@ class SampleDriverDataModels(object):
lib_consts.TLS_CIPHERS: constants.CIPHERS_OWASP_SUITE_B,
lib_consts.TLS_VERSIONS: constants.TLS_VERSIONS_OWASP_SUITE_B,
lib_consts.ALPN_PROTOCOLS:
constants.AMPHORA_SUPPORTED_ALPN_PROTOCOLS
constants.AMPHORA_SUPPORTED_ALPN_PROTOCOLS,
lib_consts.HSTS_INCLUDE_SUBDOMAINS: False,
lib_consts.HSTS_MAX_AGE: None,
lib_consts.HSTS_PRELOAD: False,
}
self.test_listener1_dict.update(self._common_test_dict)
@ -488,6 +491,9 @@ class SampleDriverDataModels(object):
self.test_listener2_dict[lib_consts.DEFAULT_POOL_ID] = self.pool2_id
self.test_listener2_dict[
lib_consts.DEFAULT_POOL] = self.test_pool2_dict
self.test_listener2_dict[lib_consts.HSTS_INCLUDE_SUBDOMAINS] = True
self.test_listener2_dict[lib_consts.HSTS_MAX_AGE] = 10
self.test_listener2_dict[lib_consts.HSTS_PRELOAD] = False
del self.test_listener2_dict[lib_consts.L7POLICIES]
del self.test_listener2_dict[constants.SNI_CONTAINERS]
del self.test_listener2_dict[constants.CLIENT_CA_TLS_CERTIFICATE_ID]
@ -524,6 +530,9 @@ class SampleDriverDataModels(object):
lib_consts.DEFAULT_TLS_CONTAINER_REF:
self.default_tls_container_ref,
lib_consts.DESCRIPTION: 'Listener 1',
lib_consts.HSTS_INCLUDE_SUBDOMAINS: False,
lib_consts.HSTS_MAX_AGE: None,
lib_consts.HSTS_PRELOAD: False,
lib_consts.INSERT_HEADERS: {},
lib_consts.L7POLICIES: self.provider_l7policies_dict,
lib_consts.LISTENER_ID: self.listener1_id,
@ -571,6 +580,9 @@ class SampleDriverDataModels(object):
self.provider_listener2_dict[
lib_consts.CLIENT_CRL_CONTAINER_REF] = None
del self.provider_listener2_dict[lib_consts.CLIENT_CRL_CONTAINER_DATA]
self.provider_listener2_dict[lib_consts.HSTS_INCLUDE_SUBDOMAINS] = True
self.provider_listener2_dict[lib_consts.HSTS_MAX_AGE] = 10
self.provider_listener2_dict[lib_consts.HSTS_PRELOAD] = False
self.provider_listener1 = driver_dm.Listener(
**self.provider_listener1_dict)

View File

@ -45,34 +45,9 @@ class TestRootController(base_db_test.OctaviaDBTestBase):
def test_api_versions(self):
versions = self._get_versions_with_config()
version_ids = tuple(v.get('id') for v in versions)
self.assertEqual(27, len(version_ids))
self.assertIn('v2.0', version_ids)
self.assertIn('v2.1', version_ids)
self.assertIn('v2.2', version_ids)
self.assertIn('v2.3', version_ids)
self.assertIn('v2.4', version_ids)
self.assertIn('v2.5', version_ids)
self.assertIn('v2.6', version_ids)
self.assertIn('v2.7', version_ids)
self.assertIn('v2.8', version_ids)
self.assertIn('v2.9', version_ids)
self.assertIn('v2.10', version_ids)
self.assertIn('v2.11', version_ids)
self.assertIn('v2.12', version_ids)
self.assertIn('v2.13', version_ids)
self.assertIn('v2.14', version_ids)
self.assertIn('v2.15', version_ids)
self.assertIn('v2.16', version_ids)
self.assertIn('v2.17', version_ids)
self.assertIn('v2.18', version_ids)
self.assertIn('v2.19', version_ids)
self.assertIn('v2.20', version_ids)
self.assertIn('v2.21', version_ids)
self.assertIn('v2.22', version_ids)
self.assertIn('v2.23', version_ids)
self.assertIn('v2.24', version_ids)
self.assertIn('v2.25', version_ids)
self.assertIn('v2.26', version_ids)
expected_versions = (f"v2.{i}" for i in range(28))
for version in expected_versions:
self.assertIn(version, version_ids)
# Each version should have a 'self' 'href' to the API version URL
# [{u'rel': u'self', u'href': u'http://localhost/v2'}]

View File

@ -1894,7 +1894,10 @@ class TestListener(base.BaseAPITest):
client_ca_tls_container_ref=ca_tls_uuid,
tls_versions=[lib_consts.TLS_VERSION_1_3],
tls_ciphers='TLS_AES_256_GCM_SHA384',
alpn_protocols=['http/1.0']).get(self.root_tag)
alpn_protocols=['http/1.0'],
hsts_max_age=20, hsts_include_subdomains=True,
hsts_preload=True,
).get(self.root_tag)
self.set_lb_status(self.lb_id)
unset_params = {
'name': None, 'description': None, 'connection_limit': None,
@ -1904,7 +1907,10 @@ class TestListener(base.BaseAPITest):
'timeout_tcp_inspect': None, 'client_ca_tls_container_ref': None,
'client_authentication': None, 'default_pool_id': None,
'client_crl_container_ref': None, 'tls_versions': None,
'tls_ciphers': None, 'alpn_protocols': None}
'tls_ciphers': None, 'alpn_protocols': None,
'hsts_max_age': None, 'hsts_include_subdomains': None,
'hsts_preload': None,
}
body = self._build_body(unset_params)
listener_path = self.LISTENER_PATH.format(
listener_id=listener['id'])
@ -1931,6 +1937,9 @@ class TestListener(base.BaseAPITest):
self.assertEqual(constants.CIPHERS_OWASP_SUITE_B,
api_listener['tls_ciphers'])
self.assertEqual(['http/1.1'], api_listener['alpn_protocols'])
self.assertIsNone(api_listener['hsts_max_age'])
self.assertFalse(api_listener['hsts_include_subdomains'])
self.assertFalse(api_listener['hsts_preload'])
@mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data')
def test_update_with_bad_ca_cert(self, mock_cert_data):

View File

@ -2883,7 +2883,10 @@ class TestLoadBalancerGraph(base.BaseAPITest):
'allowed_cidrs': None,
'tls_ciphers': None,
'tls_versions': None,
'alpn_protocols': None
'alpn_protocols': None,
'hsts_include_subdomains': False,
'hsts_max_age': None,
'hsts_preload': False,
}
if create_sni_containers:
create_listener['sni_container_refs'] = create_sni_containers

View File

@ -49,6 +49,8 @@ class TestHaproxyCfg(base.TestCase):
fe = ("frontend sample_listener_id_1\n"
" maxconn {maxconn}\n"
" redirect scheme https if !{{ ssl_fc }}\n"
" http-response set-header Strict-Transport-Security "
"\"max-age=10000000; includeSubDomains; preload;\"\n"
" bind 10.0.0.2:443 "
"ssl crt-list {crt_list} "
"ca-file /var/lib/octavia/certs/sample_loadbalancer_id_1/"
@ -107,6 +109,8 @@ class TestHaproxyCfg(base.TestCase):
fe = ("frontend sample_listener_id_1\n"
" maxconn {maxconn}\n"
" redirect scheme https if !{{ ssl_fc }}\n"
" http-response set-header Strict-Transport-Security "
"\"max-age=10000000; includeSubDomains; preload;\"\n"
" bind 10.0.0.2:443 ssl crt-list {crt_list}"
" ciphers {ciphers} no-sslv3 no-tlsv10 no-tlsv11 alpn {alpn}\n"
" mode http\n"
@ -158,6 +162,8 @@ class TestHaproxyCfg(base.TestCase):
fe = ("frontend sample_listener_id_1\n"
" maxconn {maxconn}\n"
" redirect scheme https if !{{ ssl_fc }}\n"
" http-response set-header Strict-Transport-Security "
"\"max-age=10000000; includeSubDomains; preload;\"\n"
" bind 10.0.0.2:443 ssl crt-list {crt_list} "
"no-sslv3 no-tlsv10 no-tlsv11 alpn {alpn}\n"
" mode http\n"
@ -208,6 +214,8 @@ class TestHaproxyCfg(base.TestCase):
fe = ("frontend sample_listener_id_1\n"
" maxconn {maxconn}\n"
" redirect scheme https if !{{ ssl_fc }}\n"
" http-response set-header Strict-Transport-Security "
"\"max-age=10000000; includeSubDomains; preload;\"\n"
" bind 10.0.0.2:443 "
"ssl crt-list {crt_list} "
"ca-file /var/lib/octavia/certs/sample_loadbalancer_id_1/"
@ -266,6 +274,8 @@ class TestHaproxyCfg(base.TestCase):
fe = ("frontend sample_listener_id_1\n"
" maxconn {maxconn}\n"
" redirect scheme https if !{{ ssl_fc }}\n"
" http-response set-header Strict-Transport-Security "
"\"max-age=10000000; includeSubDomains; preload;\"\n"
" bind 10.0.0.2:443 ssl crt-list {crt_list} "
"alpn {alpn}\n"
" mode http\n"
@ -318,6 +328,8 @@ class TestHaproxyCfg(base.TestCase):
fe = ("frontend sample_listener_id_1\n"
" maxconn {maxconn}\n"
" redirect scheme https if !{{ ssl_fc }}\n"
" http-response set-header Strict-Transport-Security "
"\"max-age=10000000; includeSubDomains; preload;\"\n"
" bind 10.0.0.2:443 ssl crt-list {crt_list} "
"ciphers {ciphers} no-sslv3 no-tlsv10 no-tlsv11 alpn {alpn}\n"
" mode http\n"
@ -370,6 +382,8 @@ class TestHaproxyCfg(base.TestCase):
fe = ("frontend sample_listener_id_1\n"
" maxconn {maxconn}\n"
" redirect scheme https if !{{ ssl_fc }}\n"
" http-response set-header Strict-Transport-Security "
"\"max-age=10000000; includeSubDomains; preload;\"\n"
" bind 10.0.0.2:443 ssl crt-list {crt_list} "
"ciphers {ciphers} no-sslv3 no-tlsv10 no-tlsv11\n"
" mode http\n"
@ -412,6 +426,119 @@ class TestHaproxyCfg(base.TestCase):
frontend=fe, backend=be),
rendered_obj)
def test_render_template_tls_no_alpn_hsts_max_age_only(self):
conf = self.useFixture(oslo_fixture.Config(cfg.CONF))
conf.config(group="haproxy_amphora", base_cert_dir='/fake_cert_dir')
FAKE_CRT_LIST_FILENAME = os.path.join(
CONF.haproxy_amphora.base_cert_dir,
'sample_loadbalancer_id_1/sample_listener_id_1.pem')
fe = ("frontend sample_listener_id_1\n"
" maxconn {maxconn}\n"
" redirect scheme https if !{{ ssl_fc }}\n"
" http-response set-header Strict-Transport-Security "
"\"max-age=10000000;\"\n"
" bind 10.0.0.2:443 ssl crt-list {crt_list} "
"ciphers {ciphers} no-sslv3 no-tlsv10 no-tlsv11\n"
" mode http\n"
" default_backend sample_pool_id_1:sample_listener_id_1\n"
" timeout client 50000\n").format(
maxconn=constants.HAPROXY_DEFAULT_MAXCONN,
crt_list=FAKE_CRT_LIST_FILENAME,
ciphers=constants.CIPHERS_OWASP_SUITE_B)
be = ("backend sample_pool_id_1:sample_listener_id_1\n"
" mode http\n"
" balance roundrobin\n"
" cookie SRV insert indirect nocache\n"
" timeout check 31s\n"
" option httpchk GET /index.html HTTP/1.0\\r\\n\n"
" http-check expect rstatus 418\n"
" fullconn {maxconn}\n"
" option allbackups\n"
" timeout connect 5000\n"
" timeout server 50000\n"
" server sample_member_id_1 10.0.0.99:82 "
"weight 13 check inter 30s fall 3 rise 2 "
"cookie sample_member_id_1\n"
" server sample_member_id_2 10.0.0.98:82 "
"weight 13 check inter 30s fall 3 rise 2 "
"cookie sample_member_id_2\n\n").format(
maxconn=constants.HAPROXY_DEFAULT_MAXCONN)
rendered_obj = self.jinja_cfg.render_loadbalancer_obj(
sample_configs_combined.sample_amphora_tuple(),
[sample_configs_combined.sample_listener_tuple(
proto='TERMINATED_HTTPS', tls=True,
alpn_protocols=None, hsts_include_subdomains=False,
hsts_preload=False)],
tls_certs={'cont_id_1':
sample_configs_combined.sample_tls_container_tuple(
id='tls_container_id',
certificate='ImAalsdkfjCert',
private_key='ImAsdlfksdjPrivateKey',
primary_cn="FakeCN")})
self.assertEqual(
sample_configs_combined.sample_base_expected_config(
frontend=fe, backend=be),
rendered_obj)
def test_render_template_tls_no_hsts(self):
conf = self.useFixture(oslo_fixture.Config(cfg.CONF))
conf.config(group="haproxy_amphora", base_cert_dir='/fake_cert_dir')
FAKE_CRT_LIST_FILENAME = os.path.join(
CONF.haproxy_amphora.base_cert_dir,
'sample_loadbalancer_id_1/sample_listener_id_1.pem')
fe = ("frontend sample_listener_id_1\n"
" maxconn {maxconn}\n"
" redirect scheme https if !{{ ssl_fc }}\n"
" bind 10.0.0.2:443 "
"ssl crt-list {crt_list} "
"ca-file /var/lib/octavia/certs/sample_loadbalancer_id_1/"
"client_ca.pem verify required crl-file /var/lib/octavia/"
"certs/sample_loadbalancer_id_1/SHA_ID.pem ciphers {ciphers} "
"no-sslv3 no-tlsv10 no-tlsv11 alpn {alpn}\n"
" mode http\n"
" default_backend sample_pool_id_1:sample_listener_id_1\n"
" timeout client 50000\n").format(
maxconn=constants.HAPROXY_DEFAULT_MAXCONN,
crt_list=FAKE_CRT_LIST_FILENAME,
ciphers=constants.CIPHERS_OWASP_SUITE_B,
alpn=",".join(constants.AMPHORA_SUPPORTED_ALPN_PROTOCOLS))
be = ("backend sample_pool_id_1:sample_listener_id_1\n"
" mode http\n"
" balance roundrobin\n"
" cookie SRV insert indirect nocache\n"
" timeout check 31s\n"
" option httpchk GET /index.html HTTP/1.0\\r\\n\n"
" http-check expect rstatus 418\n"
" fullconn {maxconn}\n"
" option allbackups\n"
" timeout connect 5000\n"
" timeout server 50000\n"
" server sample_member_id_1 10.0.0.99:82 "
"weight 13 check inter 30s fall 3 rise 2 "
"cookie sample_member_id_1\n"
" server sample_member_id_2 10.0.0.98:82 "
"weight 13 check inter 30s fall 3 rise 2 cookie "
"sample_member_id_2\n\n").format(
maxconn=constants.HAPROXY_DEFAULT_MAXCONN)
tls_tupe = {'cont_id_1':
sample_configs_combined.sample_tls_container_tuple(
id='tls_container_id',
certificate='imaCert1', private_key='imaPrivateKey1',
primary_cn='FakeCN'),
'cont_id_ca': 'client_ca.pem',
'cont_id_crl': 'SHA_ID.pem'}
rendered_obj = self.jinja_cfg.render_loadbalancer_obj(
sample_configs_combined.sample_amphora_tuple(),
[sample_configs_combined.sample_listener_tuple(
proto='TERMINATED_HTTPS', tls=True, sni=True,
client_ca_cert=True, client_crl_cert=True,
hsts_max_age=None)],
tls_tupe)
self.assertEqual(
sample_configs_combined.sample_base_expected_config(
frontend=fe, backend=be),
rendered_obj)
def test_render_template_http(self):
be = ("backend sample_pool_id_1:sample_listener_id_1\n"
" mode http\n"
@ -1758,6 +1885,8 @@ class TestHaproxyCfg(base.TestCase):
fe = ("frontend sample_listener_id_1\n"
" maxconn {maxconn}\n"
" redirect scheme https if !{{ ssl_fc }}\n"
" http-response set-header Strict-Transport-Security "
"\"max-age=10000000; includeSubDomains; preload;\"\n"
" bind 10.0.0.2:443 ciphers {ciphers} "
"no-sslv3 no-tlsv10 no-tlsv11 alpn {alpn}\n"
" mode http\n"

View File

@ -707,7 +707,9 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True,
backend_alpn_protocols=constants.
AMPHORA_SUPPORTED_ALPN_PROTOCOLS,
include_pools=True,
additional_vips=False):
additional_vips=False,
hsts_max_age=10_000_000,
hsts_include_subdomains=True, hsts_preload=True):
proto = 'HTTP' if proto is None else proto
if be_proto is None:
be_proto = 'HTTP' if proto == 'TERMINATED_HTTPS' else proto
@ -731,7 +733,9 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True,
'timeout_tcp_inspect, client_ca_tls_certificate_id, '
'client_ca_tls_certificate, client_authentication, '
'client_crl_container_id, provisioning_status, '
'tls_ciphers, tls_versions, alpn_protocols')
'tls_ciphers, tls_versions, alpn_protocols, '
'hsts_max_age, hsts_include_subdomains, hsts_preload'
)
if l7:
pools = [
sample_pool_tuple(
@ -859,7 +863,10 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True,
provisioning_status=provisioning_status,
tls_ciphers=tls_ciphers,
tls_versions=tls_versions,
alpn_protocols=alpn_protocols
alpn_protocols=alpn_protocols,
hsts_max_age=hsts_max_age,
hsts_include_subdomains=hsts_include_subdomains,
hsts_preload=hsts_preload,
)
if recursive_nest:
listener.load_balancer.listeners.append(listener)

View File

@ -16,6 +16,7 @@ from unittest import mock
from oslo_config import cfg
from oslo_config import fixture as oslo_fixture
from oslo_utils import uuidutils
from wsme import types as wtypes
import octavia.common.constants as constants
import octavia.common.exceptions as exceptions
@ -536,3 +537,69 @@ class TestValidations(base.TestCase):
'2001:db8::/32'))
self.assertFalse(validate.is_ip_member_of_cidr('::ffff:0:203.0.113.5',
'2001:db8::/32'))
def test_check_hsts_options(self):
self.assertRaises(
exceptions.ValidationException,
validate.check_hsts_options,
{'hsts_include_subdomains': True,
'hsts_preload': wtypes.Unset,
'hsts_max_age': wtypes.Unset}
)
self.assertRaises(
exceptions.ValidationException,
validate.check_hsts_options,
{'hsts_include_subdomains': wtypes.Unset,
'hsts_preload': True,
'hsts_max_age': wtypes.Unset}
)
self.assertRaises(
exceptions.ValidationException,
validate.check_hsts_options,
{'protocol': constants.PROTOCOL_UDP,
'hsts_include_subdomains': wtypes.Unset,
'hsts_preload': wtypes.Unset,
'hsts_max_age': 1}
)
self.assertIsNone(
validate.check_hsts_options(
{'protocol': constants.PROTOCOL_TERMINATED_HTTPS,
'hsts_include_subdomains': wtypes.Unset,
'hsts_preload': wtypes.Unset,
'hsts_max_age': 1})
)
def test_check_hsts_options_put(self):
listener = mock.MagicMock()
db_listener = mock.MagicMock()
db_listener.protocol = constants.PROTOCOL_TERMINATED_HTTPS
listener.hsts_max_age = wtypes.Unset
db_listener.hsts_max_age = None
for obj in (listener, db_listener):
obj.hsts_include_subdomains = False
obj.hsts_preload = False
self.assertIsNone(validate.check_hsts_options_put(
listener, db_listener))
for i in range(2):
listener.hsts_include_subdomains = bool(i % 2)
listener.hsts_preload = not bool(i % 2)
self.assertRaises(
exceptions.ValidationException,
validate.check_hsts_options_put,
listener, db_listener)
listener.hsts_max_age, db_listener.hsts_max_age = wtypes.Unset, 0
self.assertIsNone(validate.check_hsts_options_put(
listener, db_listener))
listener.hsts_max_age, db_listener.hsts_max_age = 3, None
self.assertIsNone(validate.check_hsts_options_put(
listener, db_listener))
db_listener.protocol = constants.PROTOCOL_HTTP
self.assertRaises(
exceptions.ValidationException,
validate.check_hsts_options_put,
listener, db_listener)

View File

@ -0,0 +1,9 @@
---
features:
- |
Added support for HTTP Strict Transport Security (HSTS) for TLS-terminated
listeners. The API for creating and updating listeners has been extended
by the optional fields `hsts_max_age`, `hsts_include_subdomains` and
`hsts_preload`. By default this feature is disabled.
In order to activate this feature the `hsts_max_age`
option needs to be set.

View File

@ -46,7 +46,7 @@ castellan>=0.16.0 # Apache-2.0
tenacity>=5.0.4 # Apache-2.0
distro>=1.2.0 # Apache-2.0
jsonschema>=3.2.0 # MIT
octavia-lib>=3.1.0 # Apache-2.0
octavia-lib>=3.3.0 # Apache-2.0
simplejson>=3.13.2 # MIT
setproctitle>=1.1.10 # BSD
python-dateutil>=2.7.0 # BSD