From c9075475128cf27262a1e4f26d9ee65f8208d4d2 Mon Sep 17 00:00:00 2001 From: Tom Weininger Date: Wed, 19 Apr 2023 11:56:00 +0200 Subject: [PATCH] Add support for HTTP Strict Transport Security Closes-Bug: #2017972 Depends-on: https://review.opendev.org/c/openstack/octavia-lib/+/880821 Change-Id: I0f2f2ff6b8c430b2dd06d707097af74bb608dcc9 --- api-ref/source/parameters.yaml | 56 ++++++++ .../source/v2/examples/listener-create-curl | 2 +- .../v2/examples/listener-create-request.json | 5 +- .../v2/examples/listener-create-response.json | 13 +- .../v2/examples/listener-show-response.json | 13 +- .../source/v2/examples/listener-update-curl | 2 +- .../v2/examples/listener-update-request.json | 17 ++- .../v2/examples/listener-update-response.json | 13 +- .../v2/examples/listeners-list-response.json | 17 ++- api-ref/source/v2/listener.inc | 18 +++ doc/source/user/guides/basic-cookbook.rst | 7 + octavia/api/root_controller.py | 5 +- octavia/api/v2/controllers/listener.py | 11 +- octavia/api/v2/types/listener.py | 15 ++ octavia/common/data_models.py | 8 +- .../haproxy/combined_listeners/jinja_cfg.py | 11 +- .../combined_listeners/templates/macros.j2 | 3 + octavia/common/jinja/lvs/jinja_cfg.py | 6 +- octavia/common/validate.py | 35 +++++ ...32e_add_http_strict_transport_security_.py | 42 ++++++ octavia/db/models.py | 3 + octavia/tests/common/sample_data_models.py | 14 +- .../functional/api/test_root_controller.py | 31 +---- .../tests/functional/api/v2/test_listener.py | 13 +- .../functional/api/v2/test_load_balancer.py | 5 +- .../combined_listeners/test_jinja_cfg.py | 129 ++++++++++++++++++ .../sample_configs/sample_configs_combined.py | 13 +- octavia/tests/unit/common/test_validate.py | 67 +++++++++ .../add-hsts-support-91527398ba966115.yaml | 9 ++ requirements.txt | 2 +- 30 files changed, 527 insertions(+), 58 deletions(-) create mode 100644 octavia/db/migration/alembic_migrations/versions/632152d2d32e_add_http_strict_transport_security_.py create mode 100644 releasenotes/notes/add-hsts-support-91527398ba966115.yaml diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index 49bad50193..203e911db9 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -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. diff --git a/api-ref/source/v2/examples/listener-create-curl b/api-ref/source/v2/examples/listener-create-curl index 7d46a7c327..aa74e8e7ba 100644 --- a/api-ref/source/v2/examples/listener-create-curl +++ b/api-ref/source/v2/examples/listener-create-curl @@ -1 +1 @@ -curl -X POST -H "Content-Type: application/json" -H "X-Auth-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: " -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 diff --git a/api-ref/source/v2/examples/listener-create-request.json b/api-ref/source/v2/examples/listener-create-request.json index fec3dcb6e1..dda54ebff1 100644 --- a/api-ref/source/v2/examples/listener-create-request.json +++ b/api-ref/source/v2/examples/listener-create-request.json @@ -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 } } diff --git a/api-ref/source/v2/examples/listener-create-response.json b/api-ref/source/v2/examples/listener-create-response.json index 162b4b96f8..b24dc8a636 100644 --- a/api-ref/source/v2/examples/listener-create-response.json +++ b/api-ref/source/v2/examples/listener-create-response.json @@ -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 } } diff --git a/api-ref/source/v2/examples/listener-show-response.json b/api-ref/source/v2/examples/listener-show-response.json index e94a120789..0168d2e165 100644 --- a/api-ref/source/v2/examples/listener-show-response.json +++ b/api-ref/source/v2/examples/listener-show-response.json @@ -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 } } diff --git a/api-ref/source/v2/examples/listener-update-curl b/api-ref/source/v2/examples/listener-update-curl index 21ab5d27f9..01d0d98aeb 100644 --- a/api-ref/source/v2/examples/listener-update-curl +++ b/api-ref/source/v2/examples/listener-update-curl @@ -1 +1 @@ -curl -X PUT -H "Content-Type: application/json" -H "X-Auth-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: " -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 diff --git a/api-ref/source/v2/examples/listener-update-request.json b/api-ref/source/v2/examples/listener-update-request.json index ef9387dda6..435e9826a6 100644 --- a/api-ref/source/v2/examples/listener-update-request.json +++ b/api-ref/source/v2/examples/listener-update-request.json @@ -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 } } diff --git a/api-ref/source/v2/examples/listener-update-response.json b/api-ref/source/v2/examples/listener-update-response.json index 7a67dc6bbb..bd231f8fd0 100644 --- a/api-ref/source/v2/examples/listener-update-response.json +++ b/api-ref/source/v2/examples/listener-update-response.json @@ -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 } } diff --git a/api-ref/source/v2/examples/listeners-list-response.json b/api-ref/source/v2/examples/listeners-list-response.json index ae2b59c396..51d3689e19 100644 --- a/api-ref/source/v2/examples/listeners-list-response.json +++ b/api-ref/source/v2/examples/listeners-list-response.json @@ -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 } ] } diff --git a/api-ref/source/v2/listener.inc b/api-ref/source/v2/listener.inc index e633c2d658..d741095029 100644 --- a/api-ref/source/v2/listener.inc +++ b/api-ref/source/v2/listener.inc @@ -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 diff --git a/doc/source/user/guides/basic-cookbook.rst b/doc/source/user/guides/basic-cookbook.rst index e2592a0cf0..14680524d3 100644 --- a/doc/source/user/guides/basic-cookbook.rst +++ b/doc/source/user/guides/basic-cookbook.rst @@ -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 ---------------------------------------------------- diff --git a/octavia/api/root_controller.py b/octavia/api/root_controller.py index 09d12f38f1..272fad78d1 100644 --- a/octavia/api/root_controller.py +++ b/octavia/api/root_controller.py @@ -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} diff --git a/octavia/api/v2/controllers/listener.py b/octavia/api/v2/controllers/listener.py index 457dcfc85d..3942032e65 100644 --- a/octavia/api/v2/controllers/listener.py +++ b/octavia/api/v2/controllers/listener.py @@ -324,6 +324,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) @@ -345,7 +347,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) @@ -557,6 +558,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 @@ -592,10 +595,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') diff --git a/octavia/api/v2/types/listener.py b/octavia/api/v2/types/listener.py index 5d1296e5e8..d13df5e063 100644 --- a/octavia/api/v2/types/listener.py +++ b/octavia/api/v2/types/listener.py @@ -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): diff --git a/octavia/common/data_models.py b/octavia/common/data_models.py index ac5ace9815..b88fc4ef57 100644 --- a/octavia/common/data_models.py +++ b/octavia/common/data_models.py @@ -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(): diff --git a/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py b/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py index 094ea900fb..d68b2265b1 100644 --- a/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py +++ b/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py @@ -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 diff --git a/octavia/common/jinja/haproxy/combined_listeners/templates/macros.j2 b/octavia/common/jinja/haproxy/combined_listeners/templates/macros.j2 index a7a6214d42..4fd8c4c8e6 100644 --- a/octavia/common/jinja/haproxy/combined_listeners/templates/macros.j2 +++ b/octavia/common/jinja/haproxy/combined_listeners/templates/macros.j2 @@ -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 %} diff --git a/octavia/common/jinja/lvs/jinja_cfg.py b/octavia/common/jinja/lvs/jinja_cfg.py index faadd7b391..f3897c1d4d 100644 --- a/octavia/common/jinja/lvs/jinja_cfg.py +++ b/octavia/common/jinja/lvs/jinja_cfg.py @@ -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 diff --git a/octavia/common/validate.py b/octavia/common/validate.py index c8058fa3f7..6681f427e8 100644 --- a/octavia/common/validate.py +++ b/octavia/common/validate.py @@ -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.')) diff --git a/octavia/db/migration/alembic_migrations/versions/632152d2d32e_add_http_strict_transport_security_.py b/octavia/db/migration/alembic_migrations/versions/632152d2d32e_add_http_strict_transport_security_.py new file mode 100644 index 0000000000..5aafa88ea6 --- /dev/null +++ b/octavia/db/migration/alembic_migrations/versions/632152d2d32e_add_http_strict_transport_security_.py @@ -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) + ) diff --git a/octavia/db/models.py b/octavia/db/models.py index 8057965c2a..782449e92d 100644 --- a/octavia/db/models.py +++ b/octavia/db/models.py @@ -599,6 +599,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', diff --git a/octavia/tests/common/sample_data_models.py b/octavia/tests/common/sample_data_models.py index d2193645f7..bfebd73523 100644 --- a/octavia/tests/common/sample_data_models.py +++ b/octavia/tests/common/sample_data_models.py @@ -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) diff --git a/octavia/tests/functional/api/test_root_controller.py b/octavia/tests/functional/api/test_root_controller.py index 45fa99ccee..a65e757fd0 100644 --- a/octavia/tests/functional/api/test_root_controller.py +++ b/octavia/tests/functional/api/test_root_controller.py @@ -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'}] diff --git a/octavia/tests/functional/api/v2/test_listener.py b/octavia/tests/functional/api/v2/test_listener.py index cac1c594a6..db1eb49a47 100644 --- a/octavia/tests/functional/api/v2/test_listener.py +++ b/octavia/tests/functional/api/v2/test_listener.py @@ -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): diff --git a/octavia/tests/functional/api/v2/test_load_balancer.py b/octavia/tests/functional/api/v2/test_load_balancer.py index 17fd957b0f..ccce5a001e 100644 --- a/octavia/tests/functional/api/v2/test_load_balancer.py +++ b/octavia/tests/functional/api/v2/test_load_balancer.py @@ -2879,7 +2879,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 diff --git a/octavia/tests/unit/common/jinja/haproxy/combined_listeners/test_jinja_cfg.py b/octavia/tests/unit/common/jinja/haproxy/combined_listeners/test_jinja_cfg.py index 5ae68b2d83..9d0832c720 100644 --- a/octavia/tests/unit/common/jinja/haproxy/combined_listeners/test_jinja_cfg.py +++ b/octavia/tests/unit/common/jinja/haproxy/combined_listeners/test_jinja_cfg.py @@ -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" diff --git a/octavia/tests/unit/common/sample_configs/sample_configs_combined.py b/octavia/tests/unit/common/sample_configs/sample_configs_combined.py index 90d2c4413a..4828c58ab4 100644 --- a/octavia/tests/unit/common/sample_configs/sample_configs_combined.py +++ b/octavia/tests/unit/common/sample_configs/sample_configs_combined.py @@ -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) diff --git a/octavia/tests/unit/common/test_validate.py b/octavia/tests/unit/common/test_validate.py index 2897e123da..f2f7674217 100644 --- a/octavia/tests/unit/common/test_validate.py +++ b/octavia/tests/unit/common/test_validate.py @@ -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) diff --git a/releasenotes/notes/add-hsts-support-91527398ba966115.yaml b/releasenotes/notes/add-hsts-support-91527398ba966115.yaml new file mode 100644 index 0000000000..be4f980be1 --- /dev/null +++ b/releasenotes/notes/add-hsts-support-91527398ba966115.yaml @@ -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. diff --git a/requirements.txt b/requirements.txt index 872c355b6f..85e3254ace 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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