You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficserver.apache.org by ez...@apache.org on 2026/10/19 22:03:11 UTC
(trafficserver) 07/30: Require a matching client certificate for outbound session reuse
This is an automated email from the ASF dual-hosted git repository.
ezelkow1 pushed a commit to branch 9.2.x
in repository https://gitbox.apache.org/repos/asf/trafficserver.git
commit 211141c3952fc750398d77291321e57489c641b1
Author: Evan Zelkowitz <e_...@apple.com>
AuthorDate: Wed Aug 26 16:38:02 2026 -0600
Require a matching client certificate for outbound session reuse
The default server_session_sharing.match value did not include the cert
validator, so a pooled TLS session could be handed to a transaction
configured with a different outbound client certificate. There is no
second handshake on reuse, so the origin still sees the identity the
session was established with.
---
doc/admin-guide/files/records.config.en.rst | 15 +-
doc/release-notes/upgrading.en.rst | 14 ++
proxy/http/HttpConfig.cc | 7 +-
proxy/http/Makefile.am | 1 +
proxy/http/unit_tests/test_HttpConfig.cc | 72 +++++++++
tests/gold_tests/session_sharing/gold/200_two.gold | 9 ++
.../session_sharing_client_cert.test.py | 164 +++++++++++++++++++++
7 files changed, 280 insertions(+), 2 deletions(-)
diff --git a/doc/admin-guide/files/records.config.en.rst b/doc/admin-guide/files/records.config.en.rst
index 718f258bb1..cf06653e47 100644
--- a/doc/admin-guide/files/records.config.en.rst
+++ b/doc/admin-guide/files/records.config.en.rst
@@ -1047,7 +1047,7 @@ mptcp
``host`` Re-use server sessions, checking that the fully qualified
domain name matches. In addition, if the session uses TLS, it also
checks that the current transaction's host header value matches the session's SNI.
- ``both`` Equivalent to ``host,ip``.
+ ``both`` Equivalent to ``host,ip,cert``.
``hostonly`` Check that the fully qualified domain name matches.
``sni`` Check that the SNI of the session matches the SNI that would be used to
create a new session. Only applicable for TLS sessions.
@@ -1060,6 +1060,19 @@ mptcp
for session reuse to operate. The other values may be used for greater control
with TLS session reuse.
+.. important::
+
+ ``both`` selects ``cert`` in addition to ``ip`` and ``host``. A pooled outbound TLS
+ session is only reused for a transaction that would present the same client
+ certificate, because the origin authorizes the connection on the certificate sent
+ during the handshake and reuse performs no second handshake.
+
+ Deployments that override :ts:cv:`proxy.config.ssl.client.cert.filename` per remap
+ rule or from a plugin will therefore see outbound sessions pool per certificate, and
+ so more outbound handshakes. Set ``ip,host`` explicitly to select what ``both``
+ selected previously; note that doing so also disables the client-certificate check on
+ session reuse.
+
.. note::
Server sessions to different upstream ports never match even if the FQDN and IP
diff --git a/doc/release-notes/upgrading.en.rst b/doc/release-notes/upgrading.en.rst
index a81138d548..d68eb12e25 100644
--- a/doc/release-notes/upgrading.en.rst
+++ b/doc/release-notes/upgrading.en.rst
@@ -58,6 +58,20 @@ Configuration Settings: records.config
These are the changes that are most likely to cause problems during an upgrade. Take special care making sure you have updated your
configurations accordingly.
+Outbound Session Sharing and Client Certificates
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The default value of :ts:cv:`proxy.config.http.server_session_sharing.match` is unchanged at
+``both``, but ``both`` now selects ``cert`` in addition to ``ip`` and ``host``. A pooled
+outbound TLS session is only reused for a transaction that would present the same client
+certificate, because the origin authorizes the connection on the certificate sent during the
+handshake and reuse performs no second handshake.
+
+Deployments that override :ts:cv:`proxy.config.ssl.client.cert.filename` per remap rule or from
+a plugin will see outbound sessions pool per certificate, and so more outbound handshakes. Set
+``ip,host`` explicitly to select what ``both`` selected before; that also turns the
+client-certificate check on session reuse back off.
+
Connection Management
~~~~~~~~~~~~~~~~~~~~~
diff --git a/proxy/http/HttpConfig.cc b/proxy/http/HttpConfig.cc
index d945acb91d..87762cbf46 100644
--- a/proxy/http/HttpConfig.cc
+++ b/proxy/http/HttpConfig.cc
@@ -145,8 +145,13 @@ HttpConfig::load_server_session_sharing_match(const char *key, MgmtByte &mask)
if (value < TS_SERVER_SESSION_SHARING_MATCH_NONE) {
mask |= (1 << value);
} else if (value == TS_SERVER_SESSION_SHARING_MATCH_BOTH) {
+ // MASK_CERT is included so a pooled TLS session is only reused for a
+ // transaction that would present the same client certificate. The
+ // outbound certificate is overridable per transaction, so without this
+ // bit a session authenticated as one identity can serve a transaction
+ // configured for another, which the origin sees as the wrong client.
mask |= TS_SERVER_SESSION_SHARING_MATCH_MASK_IP | TS_SERVER_SESSION_SHARING_MATCH_MASK_HOSTONLY |
- TS_SERVER_SESSION_SHARING_MATCH_MASK_HOSTSNISYNC;
+ TS_SERVER_SESSION_SHARING_MATCH_MASK_HOSTSNISYNC | TS_SERVER_SESSION_SHARING_MATCH_MASK_CERT;
} else if (value == TS_SERVER_SESSION_SHARING_MATCH_HOST) {
mask |= TS_SERVER_SESSION_SHARING_MATCH_MASK_HOSTONLY | TS_SERVER_SESSION_SHARING_MATCH_MASK_HOSTSNISYNC;
}
diff --git a/proxy/http/Makefile.am b/proxy/http/Makefile.am
index 26267a9b06..06a3dac943 100644
--- a/proxy/http/Makefile.am
+++ b/proxy/http/Makefile.am
@@ -166,6 +166,7 @@ test_HttpTransact_LDADD = \
test_HttpTransact_SOURCES = \
../../iocore/cache/test/stub.cc \
unit_tests/main.cc \
+ unit_tests/test_HttpConfig.cc \
unit_tests/test_HttpTransact.cc
clang-tidy-local: $(libhttp_a_SOURCES) $(noinst_HEADERS)
diff --git a/proxy/http/unit_tests/test_HttpConfig.cc b/proxy/http/unit_tests/test_HttpConfig.cc
new file mode 100644
index 0000000000..1298d0cd3c
--- /dev/null
+++ b/proxy/http/unit_tests/test_HttpConfig.cc
@@ -0,0 +1,72 @@
+/** @file
+
+ Catch-based tests for HttpConfig.cc.
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you 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.
+ */
+
+#include "catch.hpp"
+
+#include "HttpConfig.h"
+#include "HttpProxyAPIEnums.h"
+
+// The pooled outbound-session lookup only evaluates a validator when its bit is
+// set in the match mask. A mask without MASK_CERT therefore never compares the
+// client certificate of a pooled TLS session against the certificate the new
+// transaction would present, so a session authenticated with one client
+// certificate can be handed to a transaction configured for another. Because
+// the outbound certificate is per-transaction overridable, that is an identity
+// confusion at the origin, so the default value has to carry the bit.
+TEST_CASE("server_session_sharing.match parsing", "[HttpConfig]")
+{
+ MgmtByte mask = 0;
+
+ // Each section pins the whole mask rather than individual bits, so a validator
+ // that is silently added to or dropped from a value is a test failure.
+
+ SECTION("the default value matches address, host, host/SNI sync, and the client certificate")
+ {
+ REQUIRE(HttpConfig::load_server_session_sharing_match("both", mask) == true);
+ CHECK(mask == (TS_SERVER_SESSION_SHARING_MATCH_MASK_IP | TS_SERVER_SESSION_SHARING_MATCH_MASK_HOSTONLY |
+ TS_SERVER_SESSION_SHARING_MATCH_MASK_HOSTSNISYNC | TS_SERVER_SESSION_SHARING_MATCH_MASK_CERT));
+ }
+
+ SECTION("an explicit list selects exactly the named validators")
+ {
+ REQUIRE(HttpConfig::load_server_session_sharing_match("ip,sni,cert", mask) == true);
+ CHECK(mask == (TS_SERVER_SESSION_SHARING_MATCH_MASK_IP | TS_SERVER_SESSION_SHARING_MATCH_MASK_SNI |
+ TS_SERVER_SESSION_SHARING_MATCH_MASK_CERT));
+ }
+
+ SECTION("none disables every validator")
+ {
+ REQUIRE(HttpConfig::load_server_session_sharing_match("none", mask) == true);
+ CHECK(mask == 0);
+ }
+
+ SECTION("ip,host selects what the default selected before cert was added")
+ {
+ MgmtByte explicit_mask = 0;
+ REQUIRE(HttpConfig::load_server_session_sharing_match("ip,host", explicit_mask) == true);
+ CHECK(explicit_mask == (TS_SERVER_SESSION_SHARING_MATCH_MASK_IP | TS_SERVER_SESSION_SHARING_MATCH_MASK_HOSTONLY |
+ TS_SERVER_SESSION_SHARING_MATCH_MASK_HOSTSNISYNC));
+ }
+
+ SECTION("an unknown value is rejected") { CHECK(HttpConfig::load_server_session_sharing_match("nonsense", mask) == false); }
+}
diff --git a/tests/gold_tests/session_sharing/gold/200_two.gold b/tests/gold_tests/session_sharing/gold/200_two.gold
new file mode 100644
index 0000000000..5232f733c6
--- /dev/null
+++ b/tests/gold_tests/session_sharing/gold/200_two.gold
@@ -0,0 +1,9 @@
+``
+< HTTP/1.1 200 OK
+``
+< Content-Length: 0
+``
+< HTTP/1.1 200 OK
+``
+< Content-Length: 0
+``
diff --git a/tests/gold_tests/session_sharing/session_sharing_client_cert.test.py b/tests/gold_tests/session_sharing/session_sharing_client_cert.test.py
new file mode 100644
index 0000000000..e8db10406b
--- /dev/null
+++ b/tests/gold_tests/session_sharing/session_sharing_client_cert.test.py
@@ -0,0 +1,164 @@
+'''
+Test that a pooled outbound TLS session is only reused when the client
+certificate the new transaction would present matches the one the session
+authenticated with.
+'''
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+
+Test.Summary = '''
+Outbound session reuse honors the client certificate.
+'''
+
+# The outbound client certificate is overridable per transaction, so two remap
+# rules can reach one origin as two different identities. A pooled session that
+# authenticated as one of them must not be handed to a transaction configured
+# for the other, because the origin authorizes on the certificate presented
+# during the handshake and there is no second handshake on reuse.
+#
+# These cases deliberately leave proxy.config.http.server_session_sharing.match
+# unset so they exercise the shipped default rather than a value chosen here.
+#
+# Both certificates embed their private key and share one signer, so the origin
+# accepts either and the only variable is whether the pool reuses the session.
+#
+# Only the outbound connection is TLS. Traffic into the proxy is plain HTTP, so
+# no inbound server certificate is configured.
+
+POOL_HIT = "global pool search successful"
+CERT_ONE = "signed-foo.pem"
+CERT_TWO = "signed-san.pem"
+
+
+class ClientCertSharingTest:
+ counter = 0
+
+ def __init__(self, summary, require_client_cert):
+ ClientCertSharingTest.counter += 1
+ self._n = ClientCertSharingTest.counter
+ self._tr = Test.AddTestRun(summary)
+ self._require_client_cert = require_client_cert
+ self._setup_origin()
+ self._setup_ts()
+
+ def _setup_origin(self):
+ if self._require_client_cert:
+ self._server = Test.MakeOriginServer(
+ f"server{self._n}",
+ ssl=True,
+ options={
+ "--clientCA": f"{Test.RunDirectory}/signer.pem",
+ "--clientverify": ""
+ },
+ clientcert=f"{Test.RunDirectory}/{CERT_ONE}",
+ clientkey=f"{Test.RunDirectory}/signed-foo.key")
+ else:
+ self._server = Test.MakeOriginServer(f"server{self._n}", ssl=True)
+
+ # The origin readiness probe opens its own TLS connection, so the signer
+ # and the certificate it presents have to exist in the run directory.
+ self._server.Setup.Copy("../tls/ssl/signer.pem")
+ self._server.Setup.Copy(f"../tls/ssl/{CERT_ONE}")
+ self._server.Setup.Copy("../tls/ssl/signed-foo.key")
+
+ request_header = {
+ "headers": "GET / HTTP/1.1\r\nHost: www.example.com\r\nContent-Length: 0\r\n\r\n",
+ "timestamp": "1469733493.993",
+ "body": ""
+ }
+ response_header = {
+ "headers": "HTTP/1.1 200 OK\r\nServer: microserver\r\nContent-Length: 0\r\n\r\n",
+ "timestamp": "1469733493.993",
+ "body": ""
+ }
+ self._server.addResponse("sessionlog.json", request_header, response_header)
+
+ def _setup_ts(self):
+ self._ts = Test.MakeATSProcess(f"ts{self._n}")
+ for name in ("signer.pem", CERT_ONE, CERT_TWO):
+ self._ts.Setup.CopyAs(f"../tls/ssl/{name}", self._ts.Variables.SSLDir)
+
+ port = self._server.Variables.SSL_Port
+ if self._require_client_cert:
+ # Two identities, one origin. Same address, same host, different
+ # certificate. Only the certificate distinguishes the two rules.
+ self._ts.Disk.remap_config.AddLine(
+ f'map /one https://127.0.0.1:{port}/ @plugin=conf_remap.so '
+ f'@pparam=proxy.config.ssl.client.cert.filename={CERT_ONE}')
+ self._ts.Disk.remap_config.AddLine(
+ f'map /two https://127.0.0.1:{port}/ @plugin=conf_remap.so '
+ f'@pparam=proxy.config.ssl.client.cert.filename={CERT_TWO}')
+ else:
+ self._ts.Disk.remap_config.AddLine(f'map /one https://127.0.0.1:{port}/')
+ self._ts.Disk.remap_config.AddLine(f'map /two https://127.0.0.1:{port}/')
+
+ self._ts.Disk.records_config.update(
+ {
+ 'proxy.config.diags.debug.enabled': 1,
+ 'proxy.config.diags.debug.tags': 'http_ss',
+ 'proxy.config.http.server_session_sharing.pool': 'global',
+ 'proxy.config.ssl.client.cert.path': self._ts.Variables.SSLDir,
+ # The origin certificate is self-signed. Verification is off
+ # rather than PERMISSIVE so no expected warning is logged, which
+ # leaves the default diags scan at its full strength instead of
+ # weakening it to errors only.
+ 'proxy.config.ssl.client.verify.server.policy': 'DISABLED',
+ })
+
+ def _run(self, first_path, second_path):
+ port = self._ts.Variables.port
+ self._tr.MakeCurlCommandMulti(
+ f'{{curl}} -v -H\'Host: www.example.com\' -H\'Connection: close\' http://127.0.0.1:{port}{first_path} &&'
+ f'{{curl}} -v -H\'Host: www.example.com\' -H\'Connection: close\' http://127.0.0.1:{port}{second_path}',
+ ts=self._ts)
+ self._tr.Processes.Default.ReturnCode = 0
+ # curl is not invoked with -f, so a non-200 would still exit 0. Pin the
+ # responses so a case cannot pass on an error page from the proxy.
+ self._tr.Processes.Default.Streams.stderr = "gold/200_two.gold"
+ self._tr.Processes.Default.StartBefore(self._server)
+ self._tr.Processes.Default.StartBefore(self._ts)
+
+ def expect_reuse(self, first_path, second_path, reason):
+ self._run(first_path, second_path)
+ self._ts.Disk.traffic_out.Content = Testers.ContainsExpression(POOL_HIT, reason)
+
+ def expect_no_reuse(self, first_path, second_path, reason, proposed_cert, session_cert):
+ self._run(first_path, second_path)
+ self._ts.Disk.traffic_out.Content = Testers.ExcludesExpression(POOL_HIT, reason)
+ # The absence of a pool hit is also what a first request that never
+ # pooled anything looks like, so require the certificate comparison that
+ # rejected the candidate. This line is logged only when a candidate was
+ # found and tested.
+ self._ts.Disk.traffic_out.Content += Testers.ContainsExpression(
+ f"validate_cert proposed_cert={proposed_cert}, cert={session_cert}",
+ "Verify the pooled session was rejected on the client certificate")
+
+
+# The security property: two identities to one origin must not share a session.
+test = ClientCertSharingTest("Different client certificates do not share an outbound session", require_client_cert=True)
+test.expect_no_reuse(
+ '/one', '/two', "Verify a session is not reused across client certificates", proposed_cert=CERT_TWO, session_cert=CERT_ONE)
+
+# Control: the same identity must still pool, so the fix does not simply
+# disable outbound session reuse.
+test = ClientCertSharingTest("The same client certificate shares an outbound session", require_client_cert=True)
+test.expect_reuse('/one', '/one', "Verify a session is reused for the same client certificate")
+
+# Regression guard for deployments that present no client certificate at all.
+# Reuse here must keep working, so a later change to how an absent certificate
+# is compared cannot quietly disable pooling for ordinary TLS origins.
+test = ClientCertSharingTest("A TLS origin with no client certificate still shares a session", require_client_cert=False)
+test.expect_reuse('/one', '/two', "Verify reuse is unaffected when no client certificate is configured")