mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
13,838 upstream commits, forward from the 26.6.1.1 fork point. 129 conflicts: 37 resolved mechanically (take upstream's file, re-apply the brand sweep) and 92 by hand where our fork and upstream had both changed the same code. The rule throughout: upstream owns the engine, we own the naming and a small set of deliberate deviations. Never resolve by discarding an upstream change — a dropped rename or a missed new parameter becomes a compile error a thousand files away. Deviations verified intact after the merge: ENABLE_XRAY still defaults OFF (CMakeLists.txt:105) X-Datastore-User read by the server (authenticateUserByHTTP.cpp) X-ClickHouse-* in src/ 0 hanzo/research present system.*_log retention bound present ArrowFlight rip held against upstream re-adding it Two things worth naming, because neither shows up as a conflict: src/CMakeLists.txt gained an UNGUARDED `add_object_library(datastore_storages_arrowflight Storages/ArrowFlight)` from upstream, for a directory our gRPC rip deleted. It sat outside every conflict hunk, so a resolver reading only the markers would sail straight past it into a CMake configure error. Removed; the Server/ArrowFlight sibling at line 474 stays because it is inside `if (TARGET ch_contrib::arrow_flight)`. The brand sweep had rewritten github.com/ClickHouse/ClickHouse issue links to github.com/Datastore/Datastore — a dead host. Those point at upstream's own tracker and are restored, alongside the contributors list, sql.clickhouse.com and the ClickHouse Inc copyright: attribution is theirs, not ours to rename. base/glibc-compatibility/memcpy/memcpy.h is deleted, not kept: upstream dropped it for LLVM-libc's, and our only change to it was a comment rebrand.
732 lines
25 KiB
Python
732 lines
25 KiB
Python
import os.path
|
|
import ssl
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
from os import remove
|
|
|
|
import pytest
|
|
|
|
from helpers.client import Client
|
|
from helpers.cluster import ClickHouseCluster
|
|
from helpers.ssl_context import WrapSSLContextWithSNI
|
|
|
|
# The test cluster is configured with certificate for that host name, see 'server-ext.cnf'.
|
|
# The client have to verify server certificate against that name. Client uses SNI
|
|
SSL_HOST = "integration-tests.clickhouse.com"
|
|
HTTPS_PORT = 8443
|
|
# It's important for the node to work at this IP because 'server-cert.pem' requires that (see server-ext.cnf).
|
|
SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
|
|
MAX_RETRY = 5
|
|
|
|
cluster = ClickHouseCluster(__file__)
|
|
instance = cluster.add_instance(
|
|
"node",
|
|
main_configs=[
|
|
"configs/ssl_config.xml",
|
|
"configs/session_log.xml",
|
|
"certs/server-key.pem",
|
|
"certs/server-cert.pem",
|
|
"certs/ca-cert.pem",
|
|
],
|
|
user_configs=["configs/users_with_ssl_auth.xml"],
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def started_cluster():
|
|
try:
|
|
cluster.start()
|
|
yield cluster
|
|
|
|
finally:
|
|
cluster.shutdown()
|
|
|
|
|
|
config = """<datastore>
|
|
<openSSL>
|
|
<client>
|
|
<verificationMode>strict</verificationMode>
|
|
<certificateFile>{certificateFile}</certificateFile>
|
|
<privateKeyFile>{privateKeyFile}</privateKeyFile>
|
|
<caConfig>{caConfig}</caConfig>
|
|
</client>
|
|
</openSSL>
|
|
</datastore>"""
|
|
|
|
|
|
def execute_query_native(node, query, user, cert_name, password=None):
|
|
config_path = f"{SCRIPT_DIR}/configs/client_{uuid.uuid4().hex}.xml"
|
|
|
|
formatted = config.format(
|
|
certificateFile=f"{SCRIPT_DIR}/certs/{cert_name}-cert.pem",
|
|
privateKeyFile=f"{SCRIPT_DIR}/certs/{cert_name}-key.pem",
|
|
caConfig=f"{SCRIPT_DIR}/certs/ca-cert.pem",
|
|
)
|
|
|
|
file = open(config_path, "w")
|
|
file.write(formatted)
|
|
file.close()
|
|
|
|
client = Client(
|
|
node.ip_address,
|
|
9440,
|
|
command=cluster.client_bin_path,
|
|
secure=True,
|
|
config=config_path,
|
|
)
|
|
|
|
try:
|
|
return client.query(query, user=user, password=password)
|
|
finally:
|
|
remove(config_path)
|
|
|
|
|
|
def test_native():
|
|
assert (
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="john", cert_name="client1"
|
|
)
|
|
== "john\n"
|
|
)
|
|
assert (
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="lucy", cert_name="client2"
|
|
)
|
|
== "lucy\n"
|
|
)
|
|
assert (
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="lucy", cert_name="client3"
|
|
)
|
|
== "lucy\n"
|
|
)
|
|
|
|
|
|
def test_native_wrong_cert():
|
|
# Wrong certificate: different user's certificate
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="john", cert_name="client2"
|
|
)
|
|
assert "AUTHENTICATION_FAILED" in str(err.value)
|
|
|
|
# Wrong certificate: self-signed certificate.
|
|
# In this case clickhouse-client itself will throw an error
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="john", cert_name="wrong"
|
|
)
|
|
assert "unknown ca" in str(err.value)
|
|
|
|
|
|
def test_native_fallback_to_password():
|
|
# Unrelated certificate, correct password
|
|
assert (
|
|
execute_query_native(
|
|
instance,
|
|
"SELECT currentUser()",
|
|
user="jane",
|
|
cert_name="client2",
|
|
password="qwe123",
|
|
)
|
|
== "jane\n"
|
|
)
|
|
|
|
# Unrelated certificate, wrong password
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_native(
|
|
instance,
|
|
"SELECT currentUser()",
|
|
user="jane",
|
|
cert_name="client2",
|
|
password="wrong",
|
|
)
|
|
assert "AUTHENTICATION_FAILED" in str(err.value)
|
|
|
|
|
|
def get_ssl_context(cert_name):
|
|
context = WrapSSLContextWithSNI(SSL_HOST, ssl.PROTOCOL_TLS_CLIENT)
|
|
context.load_verify_locations(cafile=f"{SCRIPT_DIR}/certs/ca-cert.pem")
|
|
if cert_name:
|
|
context.load_cert_chain(
|
|
f"{SCRIPT_DIR}/certs/{cert_name}-cert.pem",
|
|
f"{SCRIPT_DIR}/certs/{cert_name}-key.pem",
|
|
)
|
|
context.verify_mode = ssl.CERT_REQUIRED
|
|
context.check_hostname = True
|
|
# Python 3.10 has removed many ciphers from the cipher suite.
|
|
# Hence based on https://github.com/urllib3/urllib3/issues/3100#issuecomment-1671106236
|
|
# we are expanding the list of cipher suites.
|
|
context.set_ciphers("DEFAULT")
|
|
return context
|
|
|
|
|
|
def execute_query_https(
|
|
query, user, enable_ssl_auth=True, cert_name=None, password=None
|
|
):
|
|
url = (
|
|
f"https://{instance.ip_address}:{HTTPS_PORT}/?query={urllib.parse.quote(query)}"
|
|
)
|
|
request = urllib.request.Request(url)
|
|
request.add_header("X-Datastore-User", user)
|
|
if enable_ssl_auth:
|
|
request.add_header("X-Datastore-SSL-Certificate-Auth", "on")
|
|
if password:
|
|
request.add_header("X-Datastore-Key", password)
|
|
response = urllib.request.urlopen(
|
|
request, context=get_ssl_context(cert_name)
|
|
).read()
|
|
return response.decode("utf-8")
|
|
|
|
|
|
def test_https():
|
|
assert (
|
|
execute_query_https("SELECT currentUser()", user="john", cert_name="client1")
|
|
== "john\n"
|
|
)
|
|
assert (
|
|
execute_query_https("SELECT currentUser()", user="lucy", cert_name="client2")
|
|
== "lucy\n"
|
|
)
|
|
assert (
|
|
execute_query_https("SELECT currentUser()", user="lucy", cert_name="client3")
|
|
== "lucy\n"
|
|
)
|
|
|
|
|
|
def test_https_wrong_cert():
|
|
# Wrong certificate: different user's certificate
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_https("SELECT currentUser()", user="john", cert_name="client2")
|
|
assert "403" in str(err.value)
|
|
|
|
# TODO: Add non-flaky tests for:
|
|
# - Wrong certificate: self-signed certificate.
|
|
|
|
# No certificate.
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_https("SELECT currentUser()", user="john")
|
|
assert "403" in str(err.value)
|
|
|
|
# No header enabling SSL authentication.
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_https(
|
|
"SELECT currentUser()",
|
|
user="john",
|
|
enable_ssl_auth=False,
|
|
cert_name="client1",
|
|
)
|
|
|
|
|
|
def test_https_non_ssl_auth():
|
|
# Users with non-SSL authentication are allowed, in this case we can skip sending a client certificate at all (because "verificationMode" is set to "relaxed").
|
|
# assert execute_query_https("SELECT currentUser()", user="peter", enable_ssl_auth=False) == "peter\n"
|
|
assert (
|
|
execute_query_https(
|
|
"SELECT currentUser()",
|
|
user="jane",
|
|
enable_ssl_auth=False,
|
|
password="qwe123",
|
|
)
|
|
== "jane\n"
|
|
)
|
|
|
|
# But we still can send a certificate if we want.
|
|
assert (
|
|
execute_query_https(
|
|
"SELECT currentUser()",
|
|
user="peter",
|
|
enable_ssl_auth=False,
|
|
cert_name="client1",
|
|
)
|
|
== "peter\n"
|
|
)
|
|
assert (
|
|
execute_query_https(
|
|
"SELECT currentUser()",
|
|
user="peter",
|
|
enable_ssl_auth=False,
|
|
cert_name="client2",
|
|
)
|
|
== "peter\n"
|
|
)
|
|
assert (
|
|
execute_query_https(
|
|
"SELECT currentUser()",
|
|
user="peter",
|
|
enable_ssl_auth=False,
|
|
cert_name="client3",
|
|
)
|
|
== "peter\n"
|
|
)
|
|
|
|
assert (
|
|
execute_query_https(
|
|
"SELECT currentUser()",
|
|
user="jane",
|
|
enable_ssl_auth=False,
|
|
password="qwe123",
|
|
cert_name="client1",
|
|
)
|
|
== "jane\n"
|
|
)
|
|
assert (
|
|
execute_query_https(
|
|
"SELECT currentUser()",
|
|
user="jane",
|
|
enable_ssl_auth=False,
|
|
password="qwe123",
|
|
cert_name="client2",
|
|
)
|
|
== "jane\n"
|
|
)
|
|
assert (
|
|
execute_query_https(
|
|
"SELECT currentUser()",
|
|
user="jane",
|
|
enable_ssl_auth=False,
|
|
password="qwe123",
|
|
cert_name="client3",
|
|
)
|
|
== "jane\n"
|
|
)
|
|
|
|
# TODO: Add non-flaky tests for:
|
|
# - sending wrong cert
|
|
|
|
def test_mixed_x509_san_password_support():
|
|
assert (
|
|
execute_query_https("SELECT currentUser()", user="trurl", cert_name="client4")
|
|
== "trurl\n"
|
|
)
|
|
assert (
|
|
execute_query_https("SELECT currentUser()", enable_ssl_auth=False, user="trurl", password="mixed_sha_pass")
|
|
== "trurl\n"
|
|
)
|
|
|
|
# Verify that system.users shows both auth methods (sha256_password + ssl_certificate)
|
|
# for user 'trurl'. Sort by auth_type name for a deterministic assertion regardless of
|
|
# config order. Both columns are extracted from a single sorted zip to keep them aligned.
|
|
assert (
|
|
instance.query(
|
|
"SELECT name, "
|
|
"arrayMap(x -> toString(x.1), s) AS auth_type, "
|
|
"arrayMap(x -> x.2, s) AS auth_params "
|
|
"FROM (SELECT name, arraySort(x -> toString(x.1), arrayZip(auth_type, auth_params)) AS s "
|
|
"FROM system.users WHERE name='trurl')"
|
|
)
|
|
== 'trurl\t[\'sha256_password\',\'ssl_certificate\']\t[\'{}\',\'{"subject_alt_names":["URI:spiffe:\\\\/\\\\/foo.com\\\\/bar"]}\']\n'
|
|
)
|
|
|
|
|
|
def test_create_user():
|
|
instance.query("DROP USER IF EXISTS emma")
|
|
|
|
instance.query("CREATE USER emma IDENTIFIED WITH ssl_certificate CN 'client3'")
|
|
assert (
|
|
execute_query_https("SELECT currentUser()", user="emma", cert_name="client3")
|
|
== "emma\n"
|
|
)
|
|
assert (
|
|
instance.query("SHOW CREATE USER emma")
|
|
== "CREATE USER emma IDENTIFIED WITH ssl_certificate CN \\'client3\\'\n"
|
|
)
|
|
|
|
instance.query("ALTER USER emma IDENTIFIED WITH ssl_certificate CN 'client2'")
|
|
assert (
|
|
execute_query_https("SELECT currentUser()", user="emma", cert_name="client2")
|
|
== "emma\n"
|
|
)
|
|
assert (
|
|
instance.query("SHOW CREATE USER emma")
|
|
== "CREATE USER emma IDENTIFIED WITH ssl_certificate CN \\'client2\\'\n"
|
|
)
|
|
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_https("SELECT currentUser()", user="emma", cert_name="client3")
|
|
assert "403" in str(err.value)
|
|
|
|
assert (
|
|
instance.query("SHOW CREATE USER lucy")
|
|
== "CREATE USER lucy IDENTIFIED WITH ssl_certificate CN \\'client2\\', \\'client3\\'\n"
|
|
)
|
|
|
|
assert (
|
|
instance.query(
|
|
"SELECT name, auth_type, auth_params FROM system.users WHERE name IN ['emma', 'lucy'] ORDER BY name"
|
|
)
|
|
== "emma\t['ssl_certificate']\t['{\"common_names\":[\"client2\"]}']\n"
|
|
'lucy\t[\'ssl_certificate\']\t[\'{"common_names":["client2","client3"]}\']\n'
|
|
)
|
|
|
|
instance.query("DROP USER IF EXISTS emma")
|
|
|
|
|
|
def test_x509_san_support():
|
|
instance.query("DROP USER IF EXISTS jemma")
|
|
|
|
assert (
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="jerome", cert_name="client4"
|
|
)
|
|
== "jerome\n"
|
|
)
|
|
assert (
|
|
execute_query_https("SELECT currentUser()", user="jerome", cert_name="client4")
|
|
== "jerome\n"
|
|
)
|
|
assert (
|
|
instance.query(
|
|
"SELECT name, auth_type, auth_params FROM system.users WHERE name='jerome'"
|
|
)
|
|
== 'jerome\t[\'ssl_certificate\']\t[\'{"subject_alt_names":["URI:spiffe:\\\\/\\\\/foo.com\\\\/bar","URI:spiffe:\\\\/\\\\/foo.com\\\\/baz"]}\']\n'
|
|
)
|
|
# user `jerome` is configured via xml config, but `show create` should work regardless.
|
|
assert (
|
|
instance.query("SHOW CREATE USER jerome")
|
|
== "CREATE USER jerome IDENTIFIED WITH ssl_certificate SAN \\'URI:spiffe://foo.com/bar\\', \\'URI:spiffe://foo.com/baz\\'\n"
|
|
)
|
|
|
|
instance.query(
|
|
"CREATE USER jemma IDENTIFIED WITH ssl_certificate SAN 'URI:spiffe://foo.com/bar', 'URI:spiffe://foo.com/baz'"
|
|
)
|
|
assert (
|
|
execute_query_https("SELECT currentUser()", user="jemma", cert_name="client4")
|
|
== "jemma\n"
|
|
)
|
|
assert (
|
|
instance.query("SHOW CREATE USER jemma")
|
|
== "CREATE USER jemma IDENTIFIED WITH ssl_certificate SAN \\'URI:spiffe://foo.com/bar\\', \\'URI:spiffe://foo.com/baz\\'\n"
|
|
)
|
|
|
|
instance.query("DROP USER IF EXISTS jemma")
|
|
|
|
|
|
def test_x509_san_wildcard_support():
|
|
assert (
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="stewie", cert_name="client5"
|
|
)
|
|
== "stewie\n"
|
|
)
|
|
|
|
assert (
|
|
instance.query(
|
|
"SELECT name, auth_type, auth_params FROM system.users WHERE name='stewie'"
|
|
)
|
|
== "stewie\t['ssl_certificate']\t['{\"subject_alt_names\":[\"URI:spiffe:\\\\/\\\\/bar.com\\\\/foo\\\\/*\\\\/far\"]}']\n"
|
|
)
|
|
|
|
assert (
|
|
instance.query("SHOW CREATE USER stewie")
|
|
== "CREATE USER stewie IDENTIFIED WITH ssl_certificate SAN \\'URI:spiffe://bar.com/foo/*/far\\'\n"
|
|
)
|
|
|
|
instance.query(
|
|
"CREATE USER brian IDENTIFIED WITH ssl_certificate SAN 'URI:spiffe://bar.com/foo/*/far'"
|
|
)
|
|
|
|
assert (
|
|
execute_query_https("SELECT currentUser()", user="brian", cert_name="client6")
|
|
== "brian\n"
|
|
)
|
|
|
|
assert (
|
|
instance.query("SHOW CREATE USER brian")
|
|
== "CREATE USER brian IDENTIFIED WITH ssl_certificate SAN \\'URI:spiffe://bar.com/foo/*/far\\'\n"
|
|
)
|
|
|
|
instance.query("DROP USER brian")
|
|
|
|
|
|
def test_x509_dns_san_wildcard_single_label():
|
|
# A '*' in a DNS SAN must match exactly one DNS label (RFC 6125 6.4.3).
|
|
# Positive: a single-label name under the wildcard authenticates, on both interfaces.
|
|
assert (
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="wildcard_dns", cert_name="client7"
|
|
)
|
|
== "wildcard_dns\n"
|
|
)
|
|
assert (
|
|
execute_query_https(
|
|
"SELECT currentUser()", user="wildcard_dns", cert_name="client7"
|
|
)
|
|
== "wildcard_dns\n"
|
|
)
|
|
# Negative (authentication bypass): a multi-label name must NOT match a single-label
|
|
# wildcard. 'evil.deep.corp.example.com' must be rejected by 'DNS:*.corp.example.com'.
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="wildcard_dns", cert_name="client8"
|
|
)
|
|
assert "AUTHENTICATION_FAILED" in str(err.value)
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_https(
|
|
"SELECT currentUser()", user="wildcard_dns", cert_name="client8"
|
|
)
|
|
assert "403" in str(err.value)
|
|
# Negative (empty label): the '*' must match a NON-empty label, so the malformed name
|
|
# 'DNS:.corp.example.com' (empty first label) must be rejected by 'DNS:*.corp.example.com'.
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="wildcard_dns", cert_name="client10"
|
|
)
|
|
assert "AUTHENTICATION_FAILED" in str(err.value)
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_https(
|
|
"SELECT currentUser()", user="wildcard_dns", cert_name="client10"
|
|
)
|
|
assert "403" in str(err.value)
|
|
# Negative (slash in span): a DNS label contains no '/', so the matched span must reject one.
|
|
# 'DNS:foo/bar.corp.example.com' must NOT match 'DNS:*.corp.example.com' (the old slash-count
|
|
# guard forbade this; the single-label rule must keep forbidding it).
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="wildcard_dns", cert_name="client12"
|
|
)
|
|
assert "AUTHENTICATION_FAILED" in str(err.value)
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_https(
|
|
"SELECT currentUser()", user="wildcard_dns", cert_name="client12"
|
|
)
|
|
assert "403" in str(err.value)
|
|
|
|
|
|
def test_x509_cn_wildcard_single_label():
|
|
# The same single-label rule applies to a wildcard in the certificate CN.
|
|
assert (
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="wildcard_cn", cert_name="client7"
|
|
)
|
|
== "wildcard_cn\n"
|
|
)
|
|
assert (
|
|
execute_query_https(
|
|
"SELECT currentUser()", user="wildcard_cn", cert_name="client7"
|
|
)
|
|
== "wildcard_cn\n"
|
|
)
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="wildcard_cn", cert_name="client8"
|
|
)
|
|
assert "AUTHENTICATION_FAILED" in str(err.value)
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_https(
|
|
"SELECT currentUser()", user="wildcard_cn", cert_name="client8"
|
|
)
|
|
assert "403" in str(err.value)
|
|
# Negative (empty label): an empty CN label '.corp.example.com' must be rejected by the
|
|
# CN wildcard '*.corp.example.com'.
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="wildcard_cn", cert_name="client10"
|
|
)
|
|
assert "AUTHENTICATION_FAILED" in str(err.value)
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_https(
|
|
"SELECT currentUser()", user="wildcard_cn", cert_name="client10"
|
|
)
|
|
assert "403" in str(err.value)
|
|
# Negative (slash in span): a CN label contains no '/', so 'foo/bar.corp.example.com' must NOT
|
|
# match the CN wildcard '*.corp.example.com' (the old slash-count guard forbade this).
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="wildcard_cn", cert_name="client12"
|
|
)
|
|
assert "AUTHENTICATION_FAILED" in str(err.value)
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_https(
|
|
"SELECT currentUser()", user="wildcard_cn", cert_name="client12"
|
|
)
|
|
assert "403" in str(err.value)
|
|
|
|
|
|
def test_x509_uri_san_wildcard_dot_in_segment():
|
|
# Non-regression: '.' separates labels for DNS/CN but is NOT a separator for URI SANs,
|
|
# whose separator is '/'. A wildcard URI path segment may legitimately contain dots, so
|
|
# 'URI:spiffe://bar.com/foo/baz.qux/far' must still match 'URI:spiffe://bar.com/foo/*/far'.
|
|
assert (
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="stewie", cert_name="client9"
|
|
)
|
|
== "stewie\n"
|
|
)
|
|
|
|
|
|
def test_x509_unprefixed_san_wildcard_does_not_widen_uri():
|
|
# A SAN wildcard configured without a recognized 'DNS:'/'URI:' prefix (here a bare 'SAN *')
|
|
# must NOT widen URI matching. The label separator '.' is used only for a CN or a 'DNS:' SAN;
|
|
# every other SAN keeps the '/' separator, whose "no '/' in the matched span" rule is identical
|
|
# to the original slash-count guard. So 'SAN *' must NOT match the URI certificate
|
|
# 'URI:spiffe://foo/bar' (client11), exactly as before this fix. Checked on both interfaces.
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_native(
|
|
instance,
|
|
"SELECT currentUser()",
|
|
user="wildcard_san_unprefixed",
|
|
cert_name="client11",
|
|
)
|
|
assert "AUTHENTICATION_FAILED" in str(err.value)
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_https(
|
|
"SELECT currentUser()", user="wildcard_san_unprefixed", cert_name="client11"
|
|
)
|
|
assert "403" in str(err.value)
|
|
|
|
|
|
def test_x509_unprefixed_san_wildcard_does_not_span_dns_labels():
|
|
# A SAN wildcard configured without a recognized 'DNS:'/'URI:' prefix must match nothing.
|
|
# Certificate SAN subjects are always stored prefixed, so a bare 'SAN *.corp.example.com'
|
|
# would otherwise let '*' absorb the candidate's 'DNS:' prefix and span DNS labels: against
|
|
# 'DNS:evil.deep.corp.example.com' (client8) the matched span 'DNS:evil.deep' has no '/'.
|
|
# The unprefixed SAN pattern must be rejected, so neither a single-label (client7) nor a
|
|
# multi-label (client8) DNS certificate authenticates. Checked on both interfaces.
|
|
for cert in ("client7", "client8"):
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_native(
|
|
instance,
|
|
"SELECT currentUser()",
|
|
user="wildcard_san_unprefixed_dns",
|
|
cert_name=cert,
|
|
)
|
|
assert "AUTHENTICATION_FAILED" in str(err.value)
|
|
with pytest.raises(Exception) as err:
|
|
execute_query_https(
|
|
"SELECT currentUser()",
|
|
user="wildcard_san_unprefixed_dns",
|
|
cert_name=cert,
|
|
)
|
|
assert "403" in str(err.value)
|
|
|
|
|
|
def test_session_log_certificate_success():
|
|
# A successful certificate authentication must record the certificate details
|
|
# in system.session_log, both for the native (TCP) and the HTTPS interface.
|
|
instance.query("SYSTEM FLUSH LOGS")
|
|
|
|
assert (
|
|
execute_query_native(
|
|
instance, "SELECT currentUser()", user="john", cert_name="client1"
|
|
)
|
|
== "john\n"
|
|
)
|
|
assert (
|
|
execute_query_https("SELECT currentUser()", user="john", cert_name="client1")
|
|
== "john\n"
|
|
)
|
|
|
|
instance.query("SYSTEM FLUSH LOGS")
|
|
|
|
# A fully-populated certificate must be recorded for the successful login on both the native
|
|
# (TCP) and the HTTPS interface, so the query must find such a LoginSuccess row for each.
|
|
result = instance.query_with_retry(
|
|
"""
|
|
SELECT count(DISTINCT interface)
|
|
FROM system.session_log
|
|
WHERE user = 'john' AND type = 'LoginSuccess' AND interface IN ('TCP', 'HTTP')
|
|
AND has(certificate_subjects, 'CN:client1')
|
|
AND certificate_issuer != '' AND certificate_serial != ''
|
|
AND certificate_not_before IS NOT NULL AND certificate_not_after IS NOT NULL
|
|
AND certificate_not_before < certificate_not_after
|
|
""",
|
|
check_callback=lambda r: r.strip() == "2",
|
|
).strip()
|
|
assert result == "2", result
|
|
|
|
|
|
def test_session_log_certificate_login_failure():
|
|
# 'john' may only authenticate with the 'client1' certificate. Presenting a different but
|
|
# CA-valid certificate fails authentication, and the failed attempt must be recorded as a
|
|
# LoginFailure carrying the presented certificate.
|
|
instance.query("SYSTEM FLUSH LOGS")
|
|
|
|
with pytest.raises(Exception):
|
|
execute_query_native(instance, "SELECT 1", user="john", cert_name="client2")
|
|
|
|
instance.query("SYSTEM FLUSH LOGS")
|
|
|
|
result = instance.query_with_retry(
|
|
"""
|
|
SELECT type, certificate_serial != ''
|
|
FROM system.session_log
|
|
WHERE user = 'john' AND has(certificate_subjects, 'CN:client2')
|
|
ORDER BY event_time_microseconds DESC LIMIT 1 FORMAT TSV
|
|
""",
|
|
check_callback=lambda r: r.strip() != "",
|
|
).strip()
|
|
assert result == "LoginFailure\t1", result
|
|
|
|
|
|
def test_session_log_certificate_https_non_cert_auth():
|
|
# A client may present a TLS certificate over HTTPS while authenticating by another method
|
|
# (here 'peter' authenticates without certificate authentication). The presented certificate
|
|
# must still be recorded in system.session_log, even though it is not used for authentication.
|
|
instance.query("SYSTEM FLUSH LOGS")
|
|
|
|
assert (
|
|
execute_query_https(
|
|
"SELECT currentUser()",
|
|
user="peter",
|
|
enable_ssl_auth=False,
|
|
cert_name="client1",
|
|
)
|
|
== "peter\n"
|
|
)
|
|
|
|
instance.query("SYSTEM FLUSH LOGS")
|
|
|
|
# The login succeeded via a non-certificate method, but the presented certificate must still
|
|
# be recorded with fully-populated metadata on the HTTPS (HTTP interface) LoginSuccess row.
|
|
result = instance.query_with_retry(
|
|
"""
|
|
SELECT count()
|
|
FROM system.session_log
|
|
WHERE user = 'peter' AND type = 'LoginSuccess' AND interface = 'HTTP'
|
|
AND has(certificate_subjects, 'CN:client1')
|
|
AND certificate_issuer != '' AND certificate_serial != ''
|
|
AND certificate_not_before IS NOT NULL AND certificate_not_after IS NOT NULL
|
|
AND certificate_not_before < certificate_not_after
|
|
""",
|
|
check_callback=lambda r: r.strip() not in ("", "0"),
|
|
).strip()
|
|
assert result not in ("", "0"), result
|
|
|
|
|
|
def test_session_log_certificate_far_future_validity():
|
|
# The 'client_far_future' certificate is valid until the year 2126, which is past the upper bound
|
|
# of DateTime (UInt32 epoch seconds, ~2106). The validity period must be recorded faithfully and
|
|
# not silently wrapped around, so the columns are DateTime64 rather than DateTime.
|
|
instance.query("SYSTEM FLUSH LOGS")
|
|
|
|
assert (
|
|
execute_query_https(
|
|
"SELECT currentUser()",
|
|
user="peter",
|
|
enable_ssl_auth=False,
|
|
cert_name="client_far_future",
|
|
)
|
|
== "peter\n"
|
|
)
|
|
|
|
instance.query("SYSTEM FLUSH LOGS")
|
|
|
|
# certificate_not_after must be recorded as a year well beyond 2106 (here 2126); with a DateTime
|
|
# (UInt32) column the value would have wrapped around to before certificate_not_before instead.
|
|
result = instance.query_with_retry(
|
|
"""
|
|
SELECT toYear(certificate_not_after)
|
|
FROM system.session_log
|
|
WHERE user = 'peter' AND type = 'LoginSuccess' AND interface = 'HTTP'
|
|
AND has(certificate_subjects, 'CN:client_far_future')
|
|
AND certificate_not_before IS NOT NULL AND certificate_not_after IS NOT NULL
|
|
AND certificate_not_before < certificate_not_after
|
|
AND toYear(certificate_not_after) > 2106
|
|
ORDER BY event_time_microseconds DESC LIMIT 1
|
|
""",
|
|
check_callback=lambda r: r.strip() not in ("", "0"),
|
|
).strip()
|
|
assert result == "2126", result
|