Version in base suite: 16.0.0-2 Base version: octavia_16.0.0-2 Target version: octavia_16.0.0-2+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/o/octavia/octavia_16.0.0-2.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/o/octavia/octavia_16.0.0-2+deb13u1.dsc changelog | 13 patches/CVE-2026-74248_OSSA-2026-035_Fix_QoS_policy_validation_to_use_request_context.patch | 134 ++++++ patches/CVE-2026-94571_CVE-2026-94572_Fix_HAProxy_config_injection_via_L7_policy_redirect_URLs.patch | 168 ++++++++ patches/CVE-2026-94571_CVE-2026-94572_Fix_HAProxy_config_injection_via_tls_ciphers_field.patch | 207 ++++++++++ patches/series | 3 5 files changed, 525 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpmv9bpdqu/octavia_16.0.0-2.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpmv9bpdqu/octavia_16.0.0-2+deb13u1.dsc: no acceptable signature found diff -Nru octavia-16.0.0/debian/changelog octavia-16.0.0/debian/changelog --- octavia-16.0.0/debian/changelog 2025-07-25 10:55:07.000000000 +0000 +++ octavia-16.0.0/debian/changelog 2026-10-13 10:06:42.000000000 +0000 @@ -1,3 +1,16 @@ +octavia (16.0.0-2+deb13u1) trixie; urgency=medium + + * CVE-2026-74248 / OSSA-2026-035: Unauthorized QoS policy deletion lock. + Applied upstream patch: "Fix QoS policy validation to use request context". + (Closes: #1144814). + * CVE-2026-94572, CVE-2026-94571: + Octavia is vulnerable to an RCE in its amphora via the Octavia API. + Added upstream patches (Closes: #1148175): + - Fix_HAProxy_config_injection_via_tls_ciphers_field.patch + - Fix_HAProxy_config_injection_via_L7_policy_redirect_URLs.patch + + -- Thomas Goirand Tue, 13 Oct 2026 12:06:42 +0200 + octavia (16.0.0-2) unstable; urgency=medium * Set export OS_OSLO_MESSAGING_RABBIT__PROCESSNAME for all daemons. diff -Nru octavia-16.0.0/debian/patches/CVE-2026-74248_OSSA-2026-035_Fix_QoS_policy_validation_to_use_request_context.patch octavia-16.0.0/debian/patches/CVE-2026-74248_OSSA-2026-035_Fix_QoS_policy_validation_to_use_request_context.patch --- octavia-16.0.0/debian/patches/CVE-2026-74248_OSSA-2026-035_Fix_QoS_policy_validation_to_use_request_context.patch 1970-01-01 00:00:00.000000000 +0000 +++ octavia-16.0.0/debian/patches/CVE-2026-74248_OSSA-2026-035_Fix_QoS_policy_validation_to_use_request_context.patch 2026-10-13 10:06:42.000000000 +0000 @@ -0,0 +1,134 @@ +Author: Gregory Thiemonge +Date: Thu, 23 Jul 2026 06:39:28 +0000 +Description: Fix QoS policy validation to use request context + The qos_policy_exists validation was using service credentials + instead of the user's request context when querying Neutron for + QoS policies. This allowed users to reference QoS policies from + other tenants when creating or updating a load balancer. + . + Pass the request context through qos_policy_exists, get_qos_policy, + and down to _get_resource so that Neutron enforces tenant scoping. +Bug: https://launchpad.net/bugs/2161500 +Bug-Debian: https://bugs.debian.org/1144814 +Co-Authored-By: Claude Opus 4.6 (1M context) +Change-Id: I0a30cf54fc31b83e6c48d1b65b804659e06fd7f7 +Signed-off-by: Gregory Thiemonge +Origin: upstream, https://review.opendev.org/c/openstack/octavia/+/1000295 +Last-Update: 2026-08-19 + +diff --git a/octavia/api/v2/controllers/load_balancer.py b/octavia/api/v2/controllers/load_balancer.py +index 3fe5935..a2cabaf 100644 +--- a/octavia/api/v2/controllers/load_balancer.py ++++ b/octavia/api/v2/controllers/load_balancer.py +@@ -295,7 +295,7 @@ + load_balancer.vip_network_id = subnet.network_id + if load_balancer.vip_qos_policy_id: + validate.qos_policy_exists( +- qos_policy_id=load_balancer.vip_qos_policy_id) ++ qos_policy_id=load_balancer.vip_qos_policy_id, context=context) + + # Even though we've just validated the subnet or else retrieved its ID + # directly from the port, we might still be missing the network. +@@ -745,7 +745,8 @@ + validate.qos_extension_enabled(network_driver) + if load_balancer.vip_qos_policy_id is not None: + if db_lb.vip.qos_policy_id != load_balancer.vip_qos_policy_id: +- validate.qos_policy_exists(load_balancer.vip_qos_policy_id) ++ validate.qos_policy_exists(load_balancer.vip_qos_policy_id, ++ context=context) + + if not isinstance(load_balancer.vip_sg_ids, wtypes.UnsetType): + if load_balancer.vip_sg_ids is None: +diff --git a/octavia/common/validate.py b/octavia/common/validate.py +index e2c33bc..7458b8e 100644 +--- a/octavia/common/validate.py ++++ b/octavia/common/validate.py +@@ -350,11 +350,12 @@ + return subnet + + +-def qos_policy_exists(qos_policy_id): ++def qos_policy_exists(qos_policy_id, context=None): + network_driver = utils.get_network_driver() + qos_extension_enabled(network_driver) + try: +- qos_policy = network_driver.get_qos_policy(qos_policy_id) ++ qos_policy = network_driver.get_qos_policy(qos_policy_id, ++ context=context) + except Exception as e: + raise exceptions.InvalidSubresource( + resource='qos_policy', id=qos_policy_id) from e +diff --git a/octavia/network/drivers/neutron/base.py b/octavia/network/drivers/neutron/base.py +index 063fca0..ff69486 100644 +--- a/octavia/network/drivers/neutron/base.py ++++ b/octavia/network/drivers/neutron/base.py +@@ -281,8 +281,8 @@ + 'port', unique_item=True, + network_id=network_id, device_id=device_id) + +- def get_qos_policy(self, qos_policy_id): +- return self._get_resource('qos_policy', qos_policy_id) ++ def get_qos_policy(self, qos_policy_id, context=None): ++ return self._get_resource('qos_policy', qos_policy_id, context=context) + + def qos_enabled(self): + return self._qos_enabled +diff --git a/octavia/network/drivers/noop_driver/driver.py b/octavia/network/drivers/noop_driver/driver.py +index e30e065..f581af6 100644 +--- a/octavia/network/drivers/noop_driver/driver.py ++++ b/octavia/network/drivers/noop_driver/driver.py +@@ -431,7 +431,7 @@ + + return amp_configs + +- def get_qos_policy(self, qos_policy_id): ++ def get_qos_policy(self, qos_policy_id, context=None): + LOG.debug("Qos Policy %s no-op, get_qos_policy qos_policy_id %s", + self.__class__.__name__, qos_policy_id) + self.networkconfigconfig[qos_policy_id] = (qos_policy_id, +@@ -590,8 +590,8 @@ + 'network_models.SecurityGroup'): + return self.driver.get_security_group_by_id(sg_id, context=context) + +- def get_qos_policy(self, qos_policy_id): +- return self.driver.get_qos_policy(qos_policy_id) ++ def get_qos_policy(self, qos_policy_id, context=None): ++ return self.driver.get_qos_policy(qos_policy_id, context=context) + + def get_network_by_name(self, network_name): + return self.driver.get_network_by_name(network_name) +diff --git a/octavia/tests/unit/common/test_validate.py b/octavia/tests/unit/common/test_validate.py +index f2f7674..272e02f 100644 +--- a/octavia/tests/unit/common/test_validate.py ++++ b/octavia/tests/unit/common/test_validate.py +@@ -368,12 +368,15 @@ + def test_qos_policy_exists(self): + qos_policy_id = uuidutils.generate_uuid() + qos_policy = network_models.QosPolicy(id=qos_policy_id) ++ context = mock.Mock() + with mock.patch( + 'octavia.common.utils.get_network_driver') as net_mock: + net_mock.return_value.get_qos_policy.return_value = qos_policy + self.assertEqual( +- validate.qos_policy_exists(qos_policy_id), ++ validate.qos_policy_exists(qos_policy_id, context=context), + qos_policy) ++ net_mock.return_value.get_qos_policy.assert_called_once_with( ++ qos_policy_id, context=context) + + net_mock.return_value.get_qos_policy.side_effect = Exception + self.assertRaises(exceptions.InvalidSubresource, +diff --git a/releasenotes/notes/fix-qos-policy-context-baa964df88ba4645.yaml b/releasenotes/notes/fix-qos-policy-context-baa964df88ba4645.yaml +new file mode 100644 +index 0000000..5cb2850 +--- /dev/null ++++ b/releasenotes/notes/fix-qos-policy-context-baa964df88ba4645.yaml +@@ -0,0 +1,8 @@ ++--- ++security: ++ - | ++ Fixed QoS policy validation to use the user's request context when ++ checking if a QoS policy exists. Previously, the validation used ++ service credentials instead of the user's context, which could allow ++ a user to assign a QoS policy belonging to another tenant to their ++ load balancer. diff -Nru octavia-16.0.0/debian/patches/CVE-2026-94571_CVE-2026-94572_Fix_HAProxy_config_injection_via_L7_policy_redirect_URLs.patch octavia-16.0.0/debian/patches/CVE-2026-94571_CVE-2026-94572_Fix_HAProxy_config_injection_via_L7_policy_redirect_URLs.patch --- octavia-16.0.0/debian/patches/CVE-2026-94571_CVE-2026-94572_Fix_HAProxy_config_injection_via_L7_policy_redirect_URLs.patch 1970-01-01 00:00:00.000000000 +0000 +++ octavia-16.0.0/debian/patches/CVE-2026-94571_CVE-2026-94572_Fix_HAProxy_config_injection_via_L7_policy_redirect_URLs.patch 2026-10-13 10:06:42.000000000 +0000 @@ -0,0 +1,168 @@ +Author: Gregory Thiemonge +Date: Thu, 30 Jul 2026 15:13:40 +0000 +Description: Fix HAProxy config injection via L7 policy redirect URLs + The redirect_url and redirect_prefix fields on L7 policies were + vulnerable to HAProxy configuration injection. The rfc3986 URL + validator encodes control characters (e.g. newlines to %0A) before + validating, so they passed structural checks. However, Octavia + stores the raw unencoded input and writes it directly into the + HAProxy configuration file, allowing injection of arbitrary + directives. + . + Add a check in the URL validator to reject URLs containing control + characters and spaces before they reach rfc3986. Also add a safety + check at config generation time to clear invalid redirect URLs that + may already exist in the database. +Bug: https://launchpad.net/bugs/2162103 +Bug-Debian: https://bugs.debian.org/1148175 +Origin: upstream, https://review.opendev.org/c/openstack/octavia/+/1001097 +Co-Authored-By: Claude Opus 4.6 +Change-Id: I23c3ca392233c7e8223a9a00b9ee07c6f4b63615 +Signed-off-by: Gregory Thiemonge +Last-Update: 2026-10-13 +--- + +diff --git a/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py b/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py +index 94a5f0ab..03a8d79 100644 +--- a/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py ++++ b/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py +@@ -532,6 +532,14 @@ + 'domain_name': monitor.domain_name, + } + ++ @staticmethod ++ def _sanitize_url(value, field_name, obj_id): ++ if value and validate.INVALID_URL_CHARS_RE.search(value): ++ LOG.warning("L7policy %s has invalid %s in DB, clearing", ++ obj_id, field_name) ++ return None ++ return value ++ + def _transform_l7policy(self, l7policy, feature_compatibility, + listener_tls_enabled, tls_certs=None): + """Transforms an L7 policy into an object that will +@@ -541,8 +549,10 @@ + ret_value = { + 'id': l7policy.id, + 'action': l7policy.action, +- 'redirect_url': l7policy.redirect_url, +- 'redirect_prefix': l7policy.redirect_prefix, ++ 'redirect_url': self._sanitize_url( ++ l7policy.redirect_url, 'redirect_url', l7policy.id), ++ 'redirect_prefix': self._sanitize_url( ++ l7policy.redirect_prefix, 'redirect_prefix', l7policy.id), + 'enabled': l7policy.enabled + } + if (l7policy.redirect_pool and +diff --git a/octavia/common/validate.py b/octavia/common/validate.py +index 997d0a4..f4bba8f 100644 +--- a/octavia/common/validate.py ++++ b/octavia/common/validate.py +@@ -41,8 +41,18 @@ + _ListenerPUT = 'octavia.api.v2.types.listener.ListenerPUT' + + ++# Reject control characters and spaces in URLs before passing to rfc3986. ++# The rfc3986 library encodes these (e.g. \n -> %0A) before validating, so ++# they pass structural validation, but Octavia stores the raw input which ++# ends up in HAProxy config files — allowing config injection via newlines. ++INVALID_URL_CHARS_RE = re.compile(r'[\x00-\x20\x7f-\x9f]') ++ ++ + def url(url, require_scheme=True): + """Raises an error if the url doesn't look like a URL.""" ++ if INVALID_URL_CHARS_RE.search(url): ++ raise exceptions.InvalidURL(url=url) ++ + validator = validators.Validator() + if require_scheme: + validator.allow_schemes('http', 'https') +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 2bb9933..3833ab7 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 +@@ -1739,6 +1739,30 @@ + ret = self.jinja_cfg._transform_l7policy(in_l7policy, {}, False) + self.assertEqual(sample_configs_combined.RET_L7POLICY_6, ret) + ++ def test_transform_l7policy_redirect_url_injection(self): ++ in_l7policy = sample_configs_combined.sample_l7policy_tuple( ++ 'sample_l7policy_id_2', sample_policy=2) ++ # Replace redirect_url with a tainted value ++ in_l7policy = in_l7policy._replace( ++ redirect_url='https://example.com/\ncheck') ++ ret = self.jinja_cfg._transform_l7policy(in_l7policy, {}, False) ++ self.assertIsNone(ret['redirect_url']) ++ ++ def test_transform_l7policy_redirect_prefix_injection(self): ++ in_l7policy = sample_configs_combined.sample_l7policy_tuple( ++ 'sample_l7policy_id_2', sample_policy=2) ++ in_l7policy = in_l7policy._replace( ++ redirect_prefix='https://example.com/\ncheck', ++ redirect_url=None) ++ ret = self.jinja_cfg._transform_l7policy(in_l7policy, {}, False) ++ self.assertIsNone(ret['redirect_prefix']) ++ ++ def test_transform_l7policy_redirect_url_valid(self): ++ in_l7policy = sample_configs_combined.sample_l7policy_tuple( ++ 'sample_l7policy_id_2', sample_policy=2) ++ ret = self.jinja_cfg._transform_l7policy(in_l7policy, {}, False) ++ self.assertEqual('http://www.example.com', ret['redirect_url']) ++ + def test_escape_haproxy_config_string(self): + self.assertEqual(self.jinja_cfg._escape_haproxy_config_string( + 'string_with_none'), 'string_with_none') +diff --git a/octavia/tests/unit/common/test_validate.py b/octavia/tests/unit/common/test_validate.py +index 8dc9314..24fdfc1 100644 +--- a/octavia/tests/unit/common/test_validate.py ++++ b/octavia/tests/unit/common/test_validate.py +@@ -45,6 +45,31 @@ + self.assertRaises(exceptions.InvalidURL, validate.url, + 'ssh://www.example.com/') + ++ def test_validate_url_newline_injection(self): ++ self.assertRaises( ++ exceptions.InvalidURL, validate.url, ++ 'https://example.com/path\ncheck') ++ ++ def test_validate_url_crlf_injection(self): ++ self.assertRaises( ++ exceptions.InvalidURL, validate.url, ++ 'https://example.com/path\r\ncheck') ++ ++ def test_validate_url_space(self): ++ self.assertRaises( ++ exceptions.InvalidURL, validate.url, ++ 'https://example.com/path if check') ++ ++ def test_validate_url_tab(self): ++ self.assertRaises( ++ exceptions.InvalidURL, validate.url, ++ 'https://example.com/path\tcheck') ++ ++ def test_validate_url_null(self): ++ self.assertRaises( ++ exceptions.InvalidURL, validate.url, ++ 'https://example.com/path\x00check') ++ + def test_validate_url_path(self): + self.assertTrue(validate.url_path('/foo')) + self.assertTrue(validate.url_path('/foo%0Abar')) +diff --git a/releasenotes/notes/fix-redirect-url-haproxy-config-injection-b2c3d4e5f6a7b8c9.yaml b/releasenotes/notes/fix-redirect-url-haproxy-config-injection-b2c3d4e5f6a7b8c9.yaml +new file mode 100644 +index 0000000..855f0f0 +--- /dev/null ++++ b/releasenotes/notes/fix-redirect-url-haproxy-config-injection-b2c3d4e5f6a7b8c9.yaml +@@ -0,0 +1,12 @@ ++--- ++security: ++ - | ++ Fixed a HAProxy configuration injection vulnerability via the ++ ``redirect_url`` and ``redirect_prefix`` fields on L7 policies. ++ The ``rfc3986`` URL validator encodes control characters before ++ validation, so newlines and spaces passed structural checks but ++ were stored raw and written directly into the HAProxy configuration. ++ The URL validator now rejects URLs containing control characters ++ (U+0000-U+001F, U+007F-U+009F) and spaces before passing them to ++ ``rfc3986``. Existing invalid values in the database are detected ++ at config generation time and cleared. diff -Nru octavia-16.0.0/debian/patches/CVE-2026-94571_CVE-2026-94572_Fix_HAProxy_config_injection_via_tls_ciphers_field.patch octavia-16.0.0/debian/patches/CVE-2026-94571_CVE-2026-94572_Fix_HAProxy_config_injection_via_tls_ciphers_field.patch --- octavia-16.0.0/debian/patches/CVE-2026-94571_CVE-2026-94572_Fix_HAProxy_config_injection_via_tls_ciphers_field.patch 1970-01-01 00:00:00.000000000 +0000 +++ octavia-16.0.0/debian/patches/CVE-2026-94571_CVE-2026-94572_Fix_HAProxy_config_injection_via_tls_ciphers_field.patch 2026-10-13 10:06:42.000000000 +0000 @@ -0,0 +1,207 @@ +Author: Gregory Thiemonge +Date: Thu, 30 Jul 2026 15:09:06 +0000 +Description: Fix HAProxy config injection via tls_ciphers field + The tls_ciphers field on listeners and pools was not validated for + control characters, allowing injection of arbitrary HAProxy + configuration directives by embedding spaces or newlines in the cipher + string. + . + Add input validation at the API layer to reject cipher strings + containing characters outside the set allowed by OpenSSL cipher + strings. Also add a safety check at config generation time to detect + and replace invalid values that may already exist in the database, + falling back to the configured default ciphers. +Bug: https://launchpad.net/bugs/2162101 +Bug-Debian: https://bugs.debian.org/1148175 +Origin: upstream, https://review.opendev.org/c/openstack/octavia/+/1001094 +Co-Authored-By: Claude Opus 4.6 +Change-Id: Ia6affaafcaa8b22ec2e0c94b3c4007cccaae41ba +Signed-off-by: Gregory Thiemonge +Last-Update: 2026-10-13 +--- + +diff --git a/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py b/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py +index 4aadb1d..94a5f0ab 100644 +--- a/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py ++++ b/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py +@@ -18,13 +18,17 @@ + + import jinja2 + from octavia_lib.common import constants as lib_consts ++from oslo_log import log as logging + 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.common import validate + from octavia.db import models + ++LOG = logging.getLogger(__name__) ++ + PROTOCOL_MAP = { + constants.PROTOCOL_TCP: 'tcp', + constants.PROTOCOL_HTTP: 'http', +@@ -362,7 +366,13 @@ + constants.PROTOCOL_PROMETHEUS): + tls_enabled = True + if listener.tls_ciphers is not None: +- ret_value['tls_ciphers'] = listener.tls_ciphers ++ if not validate.VALID_CIPHER_RE.match(listener.tls_ciphers): ++ LOG.warning("Listener %s has invalid tls_ciphers in DB, " ++ "falling back to default", listener.id) ++ ret_value['tls_ciphers'] = ( ++ cfg.CONF.api_settings.default_listener_ciphers) ++ else: ++ ret_value['tls_ciphers'] = listener.tls_ciphers + if listener.tls_versions is not None: + ret_value['tls_versions'] = listener.tls_versions + if listener.alpn_protocols is not None: +@@ -447,7 +457,13 @@ + ret_value['client_cert'] = pool_tls_certs.get('client_cert') + if pool.tls_enabled is True: + if pool.tls_ciphers is not None: +- ret_value['tls_ciphers'] = pool.tls_ciphers ++ if not validate.VALID_CIPHER_RE.match(pool.tls_ciphers): ++ LOG.warning("Pool %s has invalid tls_ciphers in DB, " ++ "falling back to default", pool.id) ++ ret_value['tls_ciphers'] = ( ++ cfg.CONF.api_settings.default_pool_ciphers) ++ else: ++ ret_value['tls_ciphers'] = pool.tls_ciphers + if pool.tls_versions is not None: + ret_value['tls_versions'] = pool.tls_versions + if (pool.alpn_protocols is not None and +diff --git a/octavia/common/validate.py b/octavia/common/validate.py +index 7458b8e..997d0a4 100644 +--- a/octavia/common/validate.py ++++ b/octavia/common/validate.py +@@ -450,7 +450,20 @@ + option='member address') + + ++VALID_CIPHER_RE = re.compile(r'^[A-Za-z0-9\-+!:@=_.]+\Z') ++ ++ ++def check_cipher_string(cipherstring): ++ """Validate that a cipher string contains only safe characters.""" ++ if not VALID_CIPHER_RE.match(cipherstring): ++ raise exceptions.ValidationException(detail=_( ++ 'Invalid characters in cipher string. ' ++ 'Only alphanumeric, -, +, !, :, @, =, _, and . ' ++ 'are allowed.')) ++ ++ + def check_cipher_prohibit_list(cipherstring): ++ check_cipher_string(cipherstring) + ciphers = cipherstring.split(':') + prohibit_list = CONF.api_settings.tls_cipher_prohibit_list.split(':') + rejected = [] +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 d9259ee..68c3703 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 +@@ -2086,3 +2086,40 @@ + mock_amp, mock_listeners, tls_certs=mock_tls_certs, + socket_path=mock_socket_path, amp_details=None, + feature_compatibility=expected_fc) ++ ++ def test_transform_listener_tls_ciphers_injection_fallback(self): ++ tainted = 'ECDHE-RSA-AES128-GCM-SHA256\ncheck' ++ in_listener = sample_configs_combined.sample_listener_tuple( ++ proto='TERMINATED_HTTPS', tls=True, ++ tls_ciphers=tainted) ++ ret = self.jinja_cfg._transform_listener( ++ in_listener, None, {}, in_listener.load_balancer) ++ # Should fall back to default, not use the tainted value ++ self.assertEqual( ++ cfg.CONF.api_settings.default_listener_ciphers, ++ ret['tls_ciphers']) ++ ++ def test_transform_listener_tls_ciphers_valid(self): ++ valid = 'ECDHE-RSA-AES128-GCM-SHA256:AES256-SHA' ++ in_listener = sample_configs_combined.sample_listener_tuple( ++ proto='TERMINATED_HTTPS', tls=True, ++ tls_ciphers=valid) ++ ret = self.jinja_cfg._transform_listener( ++ in_listener, None, {}, in_listener.load_balancer) ++ self.assertEqual(valid, ret['tls_ciphers']) ++ ++ def test_transform_pool_tls_ciphers_injection_fallback(self): ++ tainted = 'AES128-SHA\ncheck' ++ in_pool = sample_configs_combined.sample_pool_tuple( ++ tls_enabled=True, tls_ciphers=tainted) ++ ret = self.jinja_cfg._transform_pool(in_pool, {}, False) ++ self.assertEqual( ++ cfg.CONF.api_settings.default_pool_ciphers, ++ ret['tls_ciphers']) ++ ++ def test_transform_pool_tls_ciphers_valid(self): ++ valid = 'ECDHE-RSA-AES128-GCM-SHA256:AES256-SHA' ++ in_pool = sample_configs_combined.sample_pool_tuple( ++ tls_enabled=True, tls_ciphers=valid) ++ ret = self.jinja_cfg._transform_pool(in_pool, {}, False) ++ self.assertEqual(valid, ret['tls_ciphers']) +diff --git a/octavia/tests/unit/common/test_validate.py b/octavia/tests/unit/common/test_validate.py +index 12f9917..793d8c6 100644 +--- a/octavia/tests/unit/common/test_validate.py ++++ b/octavia/tests/unit/common/test_validate.py +@@ -464,6 +464,41 @@ + validate.ip_not_reserved, + '2001:0DB8::5') + ++ def test_check_cipher_string_valid(self): ++ # Valid cipher strings should not raise ++ validate.check_cipher_string( ++ 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384') ++ validate.check_cipher_string('!aNULL:!MD5:@STRENGTH') ++ validate.check_cipher_string('TLS_AES_256_GCM_SHA384') ++ ++ def test_check_cipher_string_newline_injection(self): ++ self.assertRaises( ++ exceptions.ValidationException, ++ validate.check_cipher_string, ++ 'ECDHE-RSA-AES128-GCM-SHA256\ncheck') ++ ++ def test_check_cipher_string_space_injection(self): ++ self.assertRaises( ++ exceptions.ValidationException, ++ validate.check_cipher_string, ++ 'ECDHE-RSA-AES128-GCM-SHA256 #') ++ ++ def test_check_cipher_string_trailing_newline(self): ++ self.assertRaises( ++ exceptions.ValidationException, ++ validate.check_cipher_string, ++ 'foo\n') ++ ++ def test_check_cipher_string_control_chars(self): ++ self.assertRaises( ++ exceptions.ValidationException, ++ validate.check_cipher_string, ++ 'AES128\r\ncheck') ++ self.assertRaises( ++ exceptions.ValidationException, ++ validate.check_cipher_string, ++ 'AES128\tcheck') ++ + def test_check_default_ciphers_prohibit_list_conflict(self): + self.conf.config(group='api_settings', + tls_cipher_prohibit_list='PSK-AES128-CBC-SHA') +diff --git a/releasenotes/notes/fix-tls-ciphers-haproxy-config-injection-a1b2c3d4e5f6a7b8.yaml b/releasenotes/notes/fix-tls-ciphers-haproxy-config-injection-a1b2c3d4e5f6a7b8.yaml +new file mode 100644 +index 0000000..90ca3e9 +--- /dev/null ++++ b/releasenotes/notes/fix-tls-ciphers-haproxy-config-injection-a1b2c3d4e5f6a7b8.yaml +@@ -0,0 +1,11 @@ ++--- ++security: ++ - | ++ Fixed a HAProxy configuration injection vulnerability via the ++ ``tls_ciphers`` field on listeners and pools. The field was not validated ++ for control characters, allowing an attacker to inject arbitrary HAProxy ++ configuration directives by embedding newlines in the cipher string. The ++ API now rejects cipher strings containing characters outside the set ++ allowed by OpenSSL (alphanumeric, ``-``, ``+``, ``!``, ``:``, ``@``, ``=``, ++ ``_``, ``.``). Existing invalid values in the database are detected at ++ config generation time and replaced with the configured defaults. diff -Nru octavia-16.0.0/debian/patches/series octavia-16.0.0/debian/patches/series --- octavia-16.0.0/debian/patches/series 2025-07-25 10:55:07.000000000 +0000 +++ octavia-16.0.0/debian/patches/series 2026-10-13 10:06:42.000000000 +0000 @@ -4,3 +4,6 @@ use-octavia-agent-lvs-masquerade-not-lvs-masquerade.sh.patch compat-python3-cryptography-bookworm.patch fix-requirements-to-allow-python3-cryptography-38.patch +CVE-2026-74248_OSSA-2026-035_Fix_QoS_policy_validation_to_use_request_context.patch +CVE-2026-94571_CVE-2026-94572_Fix_HAProxy_config_injection_via_tls_ciphers_field.patch +CVE-2026-94571_CVE-2026-94572_Fix_HAProxy_config_injection_via_L7_policy_redirect_URLs.patch