blob: 94e4282f4b9afb17119d0a1366693bcc80a9ec45 [file]
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"