You are viewing a plain text version of this content. The canonical link for it is here.
Posted to cvs@httpd.apache.org by jo...@apache.org on 2026/09/24 20:32:53 UTC
svn commit: r1938505 - httpd/httpd/trunk/test/modules/ssl
Author: jorton
Date: Thu Sep 24 20:32:52 2026
New Revision: 1938505
Log:
* test/modules/ssl/test_003_verify_client_perdir.py: New test suite.
Co-Authored-By: Claude Opus 5 (1M context) <no...@anthropic.com>
GitHub: closes #770
Added:
httpd/httpd/trunk/test/modules/ssl/test_003_verify_client_perdir.py
Added: httpd/httpd/trunk/test/modules/ssl/test_003_verify_client_perdir.py
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/ssl/test_003_verify_client_perdir.py Thu Sep 24 20:32:52 2026 (r1938505)
@@ -0,0 +1,142 @@
+import os
+import re
+
+import pytest
+
+from pyhttpd.certs import CertificateSpec
+from pyhttpd.conf import HttpdConf
+
+# SSLVerifyClient in a <Location> 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}
+ <Location "/secure">
+ {loc_lines}
+ </Location>
+ """,
+ })
+ 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"