From d3bce8ee40ea707f6d52580bb78ede60aa57bce2 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Thu, 24 Sep 2026 20:39:50 +0100 Subject: [PATCH 1/2] * modules/ssl/ssl_engine_kernel.c (ssl_hook_Access_modern): Don't add SSL_VERIFY_CLIENT_ONCE to the verify mode used for Post-Handshake Authentication. OpenSSL refuses the request with SSL_R_INVALID_CONFIG when that flag is set and a certificate has already been received, so a per-directory SSLVerifyClient failed with 403 on a TLSv1.3 connection to a virtual host which had itself asked for a certificate. Submitted by: Michael Kaufmann PR: 63096 --- changes-entries/ssl-perdir-verify-client.txt | 3 +++ modules/ssl/ssl_engine_kernel.c | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 changes-entries/ssl-perdir-verify-client.txt diff --git a/changes-entries/ssl-perdir-verify-client.txt b/changes-entries/ssl-perdir-verify-client.txt new file mode 100644 index 00000000000..2fab3f87547 --- /dev/null +++ b/changes-entries/ssl-perdir-verify-client.txt @@ -0,0 +1,3 @@ + *) mod_ssl: Fix per-directory "SSLVerifyClient require" failing on a TLSv1.3 + connection when the virtual host had already requested a client + certificate. PR 63096. [Michael Kaufmann ] diff --git a/modules/ssl/ssl_engine_kernel.c b/modules/ssl/ssl_engine_kernel.c index 5967db58b7f..6e554b0d2b1 100644 --- a/modules/ssl/ssl_engine_kernel.c +++ b/modules/ssl/ssl_engine_kernel.c @@ -948,7 +948,6 @@ static int ssl_hook_Access_modern(request_rec *r, SSLSrvConfigRec *sc, SSLDirCon return DECLINED; } - vmode_needed |= SSL_VERIFY_CLIENT_ONCE; if (vmode_inplace != vmode_needed) { /* Need to change, if new setting is more restrictive than existing one */ From 7eee79af620d2fd9b78216cdfdbae703b37f8e18 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Thu, 24 Sep 2026 20:40:13 +0100 Subject: [PATCH 2/2] * test/modules/ssl/test_003_verify_client_perdir.py: New test suite. Co-Authored-By: Claude Opus 5 (1M context) --- .../ssl/test_003_verify_client_perdir.py | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 test/modules/ssl/test_003_verify_client_perdir.py diff --git a/test/modules/ssl/test_003_verify_client_perdir.py b/test/modules/ssl/test_003_verify_client_perdir.py new file mode 100644 index 00000000000..c18d0668889 --- /dev/null +++ b/test/modules/ssl/test_003_verify_client_perdir.py @@ -0,0 +1,160 @@ +import os +import re + +import pytest + +from pyhttpd.certs import CertificateSpec +from pyhttpd.conf import HttpdConf + +# SSLVerifyClient in a asks for the certificate once the handshake +# has finished, and the two protocol families do that quite differently: +# ssl_hook_Access_classic() renegotiates for TLSv1.2 and below, while +# ssl_hook_Access_modern() uses TLSv1.3 Post-Handshake Authentication +# (RFC 8446), logging AH10129 as it starts and AH10158 when it cannot proceed. +# +# What the enclosing virtual host asks for matters as much as the protocol: +# with "SSLVerifyClient optional" the certificate is requested during the +# initial handshake, which changes what is left for the per-directory setting +# to do. +PHA_STARTED = "AH10129" +VHOSTS = {"unset": "", "optional": "SSLVerifyClient optional"} + + +class TestVerifyClientPerDir: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + doc_dir = os.path.join(env.server_dir, "htdocs", "test1", "secure") + os.makedirs(doc_dir, exist_ok=True) + with open(os.path.join(doc_dir, "index.html"), "w") as f: + f.write("secret\n") + env.httpd_error_log.add_ignored_lognos( + ["AH02261", "AH02262", "AH02263", "AH10158", "AH10373", + "AH02040"]) + env.httpd_error_log.add_ignored_matches([ + r'.*SSL Library Error.*', + r'.*certificate verify failed.*', + ]) + + def install(self, env, proto, vhost_verify, vhost_depth=5, loc_depth=None, + loc_verify="SSLVerifyClient require"): + loc_lines = loc_verify + if loc_depth is not None: + loc_lines += f"\n SSLVerifyDepth {loc_depth}" + conf = HttpdConf(env, extras={ + f"test1.{env.http_tld}": f""" + LogLevel ssl:debug + SSLProtocol -all +{proto} + SSLCACertificateFile "{env.ca.cert_file}" + SSLVerifyDepth {vhost_depth} + {vhost_verify} + + {loc_lines} + + """, + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + def get(self, env, proto, version, vhost_verify="", + path="/secure/index.html", with_cert=True, + vhost_depth=5, loc_depth=None, + loc_verify="SSLVerifyClient require"): + self.install(env, proto, vhost_verify, vhost_depth, loc_depth, + loc_verify) + env.httpd_error_log.clear_log() + # Pin the version at both ends: "--tlsvX" alone is a floor, not a pin. + # Force HTTP/1.1 because per-directory verification is refused outright + # on an HTTP/2 secondary connection, which would look like a protocol + # failure while proving nothing about the TLS version. + options = ["-v", "--http1.1", f"--tlsv{version}", "--tls-max", version] + if with_cert: + creds = env.ca.issue_cert( + CertificateSpec(name="perdir-client", client=True)) + options.extend(["--cert", creds.cert_file, + "--key", creds.pkey_file]) + r = env.curl_get(env.mkurl("https", "test1", path), options=options) + if r.exit_code == 0: + got = re.search(r'SSL connection using (TLSv[0-9.]+)', r.stderr or "") + assert got and got.group(1) == proto, \ + f"negotiated {got and got.group(1)}, wanted {proto}" + return r + + def logged(self, env, logno): + with open(env.httpd_error_log.path) as fd: + return any(logno in line for line in fd) + + # TLSv1.2 reaches the per-directory setting by renegotiating, whatever the + # virtual host asked for. + @pytest.mark.parametrize("vhost", list(VHOSTS), ids=list(VHOSTS)) + def test_ssl_003_01(self, env, vhost): + r = self.get(env, "TLSv1.2", "1.2", VHOSTS[vhost]) + assert r.exit_code == 0, f"handshake failed: {r.stderr}" + assert r.response["status"] == 200 + + # On TLSv1.3 it uses Post-Handshake Authentication, and a client which + # offers the extension is served just the same. + def test_ssl_003_02(self, env): + r = self.get(env, "TLSv1.3", "1.3", VHOSTS["unset"]) + assert r.exit_code == 0, f"handshake failed: {r.stderr}" + assert r.response["status"] == 200 + assert self.logged(env, PHA_STARTED), \ + f"{PHA_STARTED} absent: the certificate was not requested " \ + "post-handshake, so this is not exercising PHA" + + # Without a certificate the location is refused, so the cases above are + # really being verified rather than served unconditionally. + @pytest.mark.parametrize("vhost", list(VHOSTS), ids=list(VHOSTS)) + def test_ssl_003_03(self, env, vhost): + r = self.get(env, "TLSv1.3", "1.3", VHOSTS[vhost], with_cert=False) + assert not (r.exit_code == 0 and r.response + and r.response["status"] == 200), \ + "resource served without a client certificate" + + # The rest of the virtual host is unaffected. + def test_ssl_003_04(self, env): + r = self.get(env, "TLSv1.3", "1.3", VHOSTS["unset"], path="/index.html") + assert r.exit_code == 0, f"handshake failed: {r.stderr}" + assert r.response["status"] == 200 + + # A virtual host asking for an optional certificate has already collected + # one during the handshake, so tightening to "require" for a location has + # nothing left to ask for. This used to attempt Post-Handshake + # Authentication anyway, which OpenSSL refuses once a certificate was + # requested in the handshake - SSL_R_INVALID_CONFIG, reported as AH10158 + # and turned into 403, even for a client which offered the + # post_handshake_auth extension and presented a valid certificate. + def test_ssl_003_05(self, env): + r = self.get(env, "TLSv1.3", "1.3", VHOSTS["optional"]) + assert r.exit_code == 0, f"handshake failed: {r.stderr}" + assert r.response["status"] == 200 + + # SSLVerifyDepth 0 accepts only a self-signed certificate, so the CA + # issued one used here is refused - which establishes that the depth is + # applied at all, before asking where it is applied from. + @pytest.mark.parametrize("proto,version", [("TLSv1.2", "1.2"), + ("TLSv1.3", "1.3")]) + def test_ssl_003_06(self, env, proto, version): + r = self.get(env, proto, version, VHOSTS["unset"], vhost_depth=0) + assert not (r.exit_code == 0 and r.response + and r.response["status"] == 200), \ + "certificate accepted at SSLVerifyDepth 0" + + # Reducing the depth for a below what the virtual host allows + # should refuse the same certificate. It does not, on either protocol: + # the depth is only consulted inside "if (vmode_inplace != vmode_needed)", + # and vmode_needed is the union of the server and directory settings, so a + # virtual host which already asks for a certificate makes the two equal + # and the reduction is never reached. This is the case the FIXME in + # ssl_hook_Access_modern() asks about, and it is not TLSv1.3 specific. + @pytest.mark.xfail(strict=True, reason="per-directory SSLVerifyDepth " + "reduction below the virtual host's is not applied") + @pytest.mark.parametrize("proto,version", [("TLSv1.2", "1.2"), + ("TLSv1.3", "1.3")]) + def test_ssl_003_07(self, env, proto, version): + r = self.get(env, proto, version, "SSLVerifyClient require", + vhost_depth=9, loc_depth=0, loc_verify="") + assert not (r.exit_code == 0 and r.response + and r.response["status"] == 200), \ + "certificate accepted despite SSLVerifyDepth 0 in the location"