blob: a5dae01007a720a81d2c75f1def46073cbf1d153 [file]
import os
import re
import pytest
from pyhttpd.certs import CertificateSpec
from pyhttpd.conf import HttpdConf
class TestVhostCompatSecure:
"""Under the default 'secure' SSLVHostSNIPolicy, requests routed to a
virtual host with different SSL parameters (certificate or auth
settings) than the handshake host are rejected with 421."""
@pytest.fixture(autouse=True, scope='class')
def _class_scope(self, env):
alt_domain = f"test2.{env.http_tld}"
alt_creds = env.ca.issue_cert(
CertificateSpec(name="ssl005-alt", domains=[alt_domain],
key_type='rsa2048'))
for name in ("test1", "test2"):
doc = os.path.join(env.server_dir, "htdocs", name)
os.makedirs(doc, exist_ok=True)
with open(os.path.join(doc, "index.html"), "w") as f:
f.write(f"vhost {name}\n")
conf = HttpdConf(env, extras={
f"test1.{env.http_tld}": "LogLevel ssl:debug",
alt_domain: f"""
LogLevel ssl:debug
SSLCertificateFile "{alt_creds.cert_file}"
SSLCertificateKeyFile "{alt_creds.pkey_file}"
""",
})
conf.add_vhost_test1()
conf.start_vhost(domains=[alt_domain],
port=env.https_port,
doc_root="htdocs/test2",
with_certificates=False)
conf.end_vhost()
conf.install()
assert env.apache_restart() == 0
env.httpd_error_log.add_ignored_lognos(["AH02032"])
# SNI selects a vhost and Host: names a different one with a
# different certificate. Must return 421.
def test_ssl_005_01(self, env):
url = env.mkurl("https", "test1", "/index.html")
alt_host = f"test2.{env.http_tld}"
r = env.curl_get(url, options=[
"--http1.1", "-H", f"Host: {alt_host}",
])
assert r.exit_code == 0, f"curl failed: {r.stderr}"
assert r.response["status"] == 421
# Same vhost for SNI and Host: — must succeed.
def test_ssl_005_02(self, env):
url = env.mkurl("https", "test1", "/index.html")
r = env.curl_get(url, options=["--http1.1"])
assert r.exit_code == 0, f"curl failed: {r.stderr}"
assert r.response["status"] == 200
# No SNI at all, with Host: selecting a vhost whose SSL parameters
# differ from the default (handshake) host. Must return 421.
def test_ssl_005_03(self, env):
alt_host = f"test2.{env.http_tld}"
r = env.run([
'openssl', 's_client',
'-connect', f'127.0.0.1:{env.https_port}',
'-CAfile', env.ca.cert_file,
'-noservername',
'-tls1_3',
'-ign_eof',
], intext=(
f"GET /index.html HTTP/1.1\r\n"
f"Host: {alt_host}\r\n"
f"Connection: close\r\n"
f"\r\n"
))
assert r.exit_code == 0, f"openssl s_client failed: {r.stderr}"
assert re.search(r'HTTP/1\.[01] 421', r.stdout), \
f"expected 421 in response, got: {r.stdout[:500]}"
# No SNI, but Host: matches the default (handshake) vhost — must
# succeed because the SSL parameters are the same.
def test_ssl_005_04(self, env):
default_host = f"test1.{env.http_tld}"
r = env.run([
'openssl', 's_client',
'-connect', f'127.0.0.1:{env.https_port}',
'-CAfile', env.ca.cert_file,
'-noservername',
'-tls1_3',
'-ign_eof',
], intext=(
f"GET /index.html HTTP/1.1\r\n"
f"Host: {default_host}\r\n"
f"Connection: close\r\n"
f"\r\n"
))
assert r.exit_code == 0, f"openssl s_client failed: {r.stderr}"
assert re.search(r'HTTP/1\.[01] 200', r.stdout), \
f"expected 200 in response, got: {r.stdout[:500]}"
# TLS 1.3 session resumption: establish a session with vhost A, then
# resume it without SNI while Host: names vhost B (different SSL
# config). The resumed connection must also be rejected with 421.
def test_ssl_005_05(self, env):
default_host = f"test1.{env.http_tld}"
alt_host = f"test2.{env.http_tld}"
sess_dir = os.path.join(env.gen_dir, "ssl005")
os.makedirs(sess_dir, exist_ok=True)
sess_file = os.path.join(sess_dir, "session.pem")
r = env.run([
'openssl', 's_client',
'-connect', f'127.0.0.1:{env.https_port}',
'-CAfile', env.ca.cert_file,
'-servername', default_host,
'-tls1_3',
'-sess_out', sess_file,
'-ign_eof',
], intext=(
f"GET /index.html HTTP/1.1\r\n"
f"Host: {default_host}\r\n"
f"Connection: close\r\n"
f"\r\n"
))
assert r.exit_code == 0, f"session setup failed: {r.stderr}"
assert os.path.isfile(sess_file), "no session ticket saved"
r = env.run([
'openssl', 's_client',
'-connect', f'127.0.0.1:{env.https_port}',
'-CAfile', env.ca.cert_file,
'-noservername',
'-tls1_3',
'-sess_in', sess_file,
'-ign_eof',
], intext=(
f"GET /index.html HTTP/1.1\r\n"
f"Host: {alt_host}\r\n"
f"Connection: close\r\n"
f"\r\n"
))
assert r.exit_code == 0, f"resumed connection failed: {r.stderr}"
assert re.search(r'HTTP/1\.[01] 421', r.stdout), \
f"expected 421 on resumed connection, got: {r.stdout[:500]}"
class TestVhostCompatAuthonly:
"""Under 'authonly' SSLVHostSNIPolicy, vhosts with different server
certificates but identical authentication settings are compatible.
This is the regression fix for PR 69743: the 'secure' default was
too strict for deployments that intentionally use different certs on
the same IP:port."""
@pytest.fixture(autouse=True, scope='class')
def _class_scope(self, env, request):
has_policy = env.has_directive("SSLVHostSNIPolicy")
if not has_policy:
request.applymarker(pytest.mark.xfail(
reason="SSLVHostSNIPolicy not available", strict=False))
alt_domain = f"test2.{env.http_tld}"
alt_creds = env.ca.issue_cert(
CertificateSpec(name="ssl005-authonly", domains=[alt_domain],
key_type='rsa2048'))
for name in ("test1", "test2"):
doc = os.path.join(env.server_dir, "htdocs", name)
os.makedirs(doc, exist_ok=True)
with open(os.path.join(doc, "index.html"), "w") as f:
f.write(f"vhost {name}\n")
extras = {
f"test1.{env.http_tld}": "LogLevel ssl:debug",
alt_domain: f"""
LogLevel ssl:debug
SSLCertificateFile "{alt_creds.cert_file}"
SSLCertificateKeyFile "{alt_creds.pkey_file}"
""",
}
if has_policy:
extras['base'] = 'SSLVHostSNIPolicy authonly'
conf = HttpdConf(env, extras=extras)
conf.add_vhost_test1()
conf.start_vhost(domains=[alt_domain],
port=env.https_port,
doc_root="htdocs/test2",
with_certificates=False)
conf.end_vhost()
conf.install()
assert env.apache_restart() == 0
# Under 'authonly', a Host: mismatch to a vhost with a different
# certificate but same auth settings is allowed. This is the
# regression that PR 69743 fixed.
def test_ssl_005_10(self, env):
url = env.mkurl("https", "test1", "/index.html")
alt_host = f"test2.{env.http_tld}"
r = env.curl_get(url, options=[
"--http1.1", "-H", f"Host: {alt_host}",
])
assert r.exit_code == 0, f"curl failed: {r.stderr}"
assert r.response["status"] == 200
# Same check without SNI — should also be allowed under 'authonly'
# when only the certificate differs.
def test_ssl_005_11(self, env):
alt_host = f"test2.{env.http_tld}"
r = env.run([
'openssl', 's_client',
'-connect', f'127.0.0.1:{env.https_port}',
'-CAfile', env.ca.cert_file,
'-noservername',
'-tls1_3',
'-ign_eof',
], intext=(
f"GET /index.html HTTP/1.1\r\n"
f"Host: {alt_host}\r\n"
f"Connection: close\r\n"
f"\r\n"
))
assert r.exit_code == 0, f"openssl s_client failed: {r.stderr}"
assert re.search(r'HTTP/1\.[01] 200', r.stdout), \
f"expected 200 in response, got: {r.stdout[:500]}"
class TestVhostCompatAuthonlyAuth:
"""Under 'authonly' SSLVHostSNIPolicy, vhosts with different client
authentication settings (different SSLCACertificateFile) must still
be blocked — this is the core CVE-2025-23048 scenario."""
@pytest.fixture(autouse=True, scope='class')
def _class_scope(self, env, request):
has_policy = env.has_directive("SSLVHostSNIPolicy")
if not has_policy:
request.applymarker(pytest.mark.xfail(
reason="SSLVHostSNIPolicy not available", strict=False))
alt_domain = f"test2.{env.http_tld}"
sub_ca = env.ca.issue_cert(
CertificateSpec(name="ssl005-subca"))
sub_ca_file = os.path.join(env.server_dir, "ssl005-subca.pem")
sub_ca.save_cert_pem(sub_ca_file)
for name in ("test1", "test2"):
doc = os.path.join(env.server_dir, "htdocs", name)
os.makedirs(doc, exist_ok=True)
with open(os.path.join(doc, "index.html"), "w") as f:
f.write(f"vhost {name}\n")
extras = {
f"test1.{env.http_tld}": f"""
LogLevel ssl:debug
SSLVerifyClient optional
SSLCACertificateFile "{env.ca.cert_file}"
""",
alt_domain: f"""
LogLevel ssl:debug
SSLVerifyClient optional
SSLCACertificateFile "{sub_ca_file}"
""",
}
if has_policy:
extras['base'] = 'SSLVHostSNIPolicy authonly'
conf = HttpdConf(env, extras=extras)
conf.add_vhost_test1()
conf.start_vhost(domains=[alt_domain],
port=env.https_port,
doc_root="htdocs/test2")
conf.end_vhost()
conf.install()
assert env.apache_restart() == 0
env.httpd_error_log.add_ignored_lognos(["AH02032"])
# Even under 'authonly', different SSLCACertificateFile between
# vhosts must return 421.
def test_ssl_005_20(self, env):
url = env.mkurl("https", "test1", "/index.html")
alt_host = f"test2.{env.http_tld}"
r = env.curl_get(url, options=[
"--http1.1", "-H", f"Host: {alt_host}",
])
assert r.exit_code == 0, f"curl failed: {r.stderr}"
assert r.response["status"] == 421
# Without SNI, same scenario — must also be 421.
def test_ssl_005_21(self, env):
alt_host = f"test2.{env.http_tld}"
r = env.run([
'openssl', 's_client',
'-connect', f'127.0.0.1:{env.https_port}',
'-CAfile', env.ca.cert_file,
'-noservername',
'-tls1_3',
'-ign_eof',
], intext=(
f"GET /index.html HTTP/1.1\r\n"
f"Host: {alt_host}\r\n"
f"Connection: close\r\n"
f"\r\n"
))
assert r.exit_code == 0, f"openssl s_client failed: {r.stderr}"
assert re.search(r'HTTP/1\.[01] 421', r.stdout), \
f"expected 421 in response, got: {r.stdout[:500]}"