On 9/11/26 2:04 PM, Salvatore Bonaccorso wrote:
Hi,
On Fri, Sep 04, 2026 at 11:57:10AM +0100, Adam D. Barratt wrote:
Control: tags -1 + confirmed
On Thu, 2026-08-20 at 14:33 +0200, Thomas Goirand wrote:
I would like to upload Octavia octavia_16.0.0-3+deb13u1 to
p-u to address:
https://security.openstack.org/ossa/OSSA-2026-035.html
aka:
https://bugs.debian.org/1144814
Please go ahead.
The version looks based on the wrong version? It should IMHO be on top
of the version in trixie, which is 16.0.0-2, not 16.0.0-3.
Regards,
Salvatore
Indeed, you're right, and also, the -3 version in my repository adds a
super bad bug where the Amphora falls into error, so it should
absolutely not be included.
I uploaded Octavia 16.0.0-2+deb13u1 to trixie p-u with the added patches
which address a very bad RCE vulnerability in the Amphora as well:
* 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
Please accept Octavia 16.0.0-2+deb13u1 into Trixie p-u. Debdiff attached
again, since it has been updated.
Cheers,
Thomas Goirand (zigo)
diff -Nru octavia-16.0.0/debian/changelog octavia-16.0.0/debian/changelog
--- octavia-16.0.0/debian/changelog 2025-07-25 12:55:07.000000000 +0200
+++ octavia-16.0.0/debian/changelog 2026-10-13 12:06:42.000000000 +0200
@@ -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 <[email protected]> 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 01:00:00.000000000 +0100
+++
octavia-16.0.0/debian/patches/CVE-2026-74248_OSSA-2026-035_Fix_QoS_policy_validation_to_use_request_context.patch
2026-10-13 12:06:42.000000000 +0200
@@ -0,0 +1,134 @@
+Author: Gregory Thiemonge <[email protected]>
+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) <[email protected]>
+Change-Id: I0a30cf54fc31b83e6c48d1b65b804659e06fd7f7
+Signed-off-by: Gregory Thiemonge <[email protected]>
+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 01:00:00.000000000 +0100
+++
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 12:06:42.000000000 +0200
@@ -0,0 +1,168 @@
+Author: Gregory Thiemonge <[email protected]>
+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 <[email protected]>
+Change-Id: I23c3ca392233c7e8223a9a00b9ee07c6f4b63615
+Signed-off-by: Gregory Thiemonge <[email protected]>
+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 01:00:00.000000000 +0100
+++
octavia-16.0.0/debian/patches/CVE-2026-94571_CVE-2026-94572_Fix_HAProxy_config_injection_via_tls_ciphers_field.patch
2026-10-13 12:06:42.000000000 +0200
@@ -0,0 +1,207 @@
+Author: Gregory Thiemonge <[email protected]>
+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 <[email protected]>
+Change-Id: Ia6affaafcaa8b22ec2e0c94b3c4007cccaae41ba
+Signed-off-by: Gregory Thiemonge <[email protected]>
+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 12:55:07.000000000
+0200
+++ octavia-16.0.0/debian/patches/series 2026-10-13 12:06:42.000000000
+0200
@@ -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