fix(clickhouse): honor verify=false in the Rust HTTP writer - #8419
fix(clickhouse): honor verify=false in the Rust HTTP writer#8419bhataprameya wants to merge 2 commits into
Conversation
The Rust consumer writer always verified ClickHouse TLS certificates, while the Python clients honor the cluster verify setting. Plumb verify into the Rust consumer config and disable certificate and hostname checks only for HTTPS when verify is explicitly false.
phacops
left a comment
There was a problem hiding this comment.
Thanks for cutting this down — leaving the Python default alone is the important difference vs last time. Fail-closed on the Rust side (None keeps verifying) is what we want.
A few things before this can land:
The description overstates what this actually does. Unset CLICKHOUSE_VERIFY is still None, Python still skips verification, and Rust still verifies. The only path this fixes is an explicit false. That's fine, but please say so in the PR body — CLICKHOUSE_SECURE=true with the env var unset will still fail in the Rust consumer.
_coerce_verify also only runs when we serialize for Rust. CLICKHOUSE_VERIFY comes in as a string from the environment, and the Python clients still treat "false" as truthy. If we're going to coerce, it should live on ClickhouseCluster.get_verify() so everybody sees the same value. Just don't turn None into True.
Tests are thin for something that turns off TLS checks. serde of the config field + a parametrize of the private helper doesn't cover the writer, and assert verify is None on the default fixture isn't really propagation. Need a test that False actually flows through resolve_storage_config, and something that covers the secure && verify == Some(false) gate (pulling that condition out into a tiny helper is fine).
A tracing::warn when we disable verification would also help — easy to miss otherwise.
(We're still not passing ca_certs through, so a private CA still can't be trusted properly. That's a follow-up, not a blocker.)
| builder = builder | ||
| .tls_danger_accept_invalid_certs(true) | ||
| .tls_danger_accept_invalid_hostnames(true); | ||
| } |
There was a problem hiding this comment.
This is the bit that actually matters and it isn't tested — the config.rs tests only check serde.
tls_danger_accept_invalid_hostnames is also a bigger hammer than "accept my private CA". It matches Python's CERT_NONE, so I'm not asking you to drop it, but the comment should say we disable hostname checks too, not just an untrusted issuer.
A warn log here would be good.
There was a problem hiding this comment.
Done. The condition is now a tls_verification_disabled helper with a matrix test covering all four secure/verify combinations, the comment calls out that hostname checks are disabled too, and there's a tracing::warn when verification is off.
| # only an explicit false disables verification. | ||
| if isinstance(verify, str): | ||
| return verify.strip().lower() not in ("false", "0") | ||
| return verify |
There was a problem hiding this comment.
This only affects the Rust payload. Python HTTPBatchWriter is still cert_reqs="REQUIRED" if verify else "CERT_NONE" and clickhouse-connect does bool(self.verify), so a string "false" from the env keeps verifying on the Python side.
Can we coerce once in get_verify() instead? Keep None as None.
There was a problem hiding this comment.
Moved the coercion into ClickhouseCluster.get_verify(): only explicit false/0 disable, None stays None. The pool call sites now use get_verify() too, so Python clients and the Rust payload see the same value.
|
|
||
| assert len(resolved.storages) == 1 | ||
| assert resolved.storages[0].clickhouse_table_name in ("errors_local", "errors_dist") | ||
| assert resolved.storages[0].clickhouse_cluster.verify is None |
There was a problem hiding this comment.
This is just the default. Doesn't show that an explicit False survives resolve_storage_config / asdict.
There was a problem hiding this comment.
Added test_resolve_storage_config_propagates_verify_false, which asserts an explicit False survives resolve_storage_config and lands as false in the asdict payload.
Coerce the raw CLICKHOUSE_VERIFY env string in ClickhouseCluster.get_verify so Python clients and the Rust consumer config see the same value; None stays None. Extract the secure/verify gate into tls_verification_disabled with a matrix test, log a warning when verification is disabled, and test that an explicit False survives resolve_storage_config.
|
Thanks for the review. All addressed in ecefd9f:
|
| secure: bool, | ||
| ca_certs: str | None, | ||
| verify: bool | None, | ||
| verify: bool | str | None, |
There was a problem hiding this comment.
why do we need this to also be a str?
| if isinstance(verify, str): | ||
| return verify.strip().lower() not in ("false", "0") | ||
| return verify |
There was a problem hiding this comment.
I really would MUCH prefer if __verify just couldn't be passed in as a string, but if it NEEDS to be a string, then we should coerce it into a bool at the constructor level, or earlier, and have this function go back to doing what it used to do before.
| secure=cluster.get("secure", False), | ||
| ca_certs=cluster.get("ca_certs", None), | ||
| verify=cluster.get("verify", False), | ||
| verify=cluster.get("verify"), |
There was a problem hiding this comment.
| verify=cluster.get("verify"), | |
| verify=cluster.get("verify", True), |
Much better to be explicit about the expectations, this way anyone can tell at a quick glance that the default is True.
| secure=cluster.get("secure", False), | ||
| ca_certs=cluster.get("ca_certs", None), | ||
| verify=cluster.get("verify", False), | ||
| verify=cluster.get("verify"), |
There was a problem hiding this comment.
| verify=cluster.get("verify"), | |
| verify=cluster.get("verify", True), |
same reasoning as this comment: https://github.com/getsentry/snuba/pull/8419/changes#r3925857030
| password: str | ||
| database: str | ||
| secure: bool | ||
| verify: bool | None |
There was a problem hiding this comment.
do we need this to be nullable? I don't personally see much value in that, and given we want None to mean verify = True, I can see this being a bit of a footgun in Python code.
| assert resolved.clickhouse_cluster.verify is False | ||
| assert dataclasses.asdict(resolved)["clickhouse_cluster"]["verify"] is False |
There was a problem hiding this comment.
why do we need both these asserts?
| monkeypatch: pytest.MonkeyPatch, | ||
| ) -> None: | ||
| storage = get_writable_storage(StorageKey.ERRORS) | ||
| monkeypatch.setattr(storage.get_cluster(), "get_verify", lambda: False) |
There was a problem hiding this comment.
This feels needlessly brittle. Is it possible to test this without monkeypatching?
Context
verifysetting (CLICKHOUSE_VERIFY). WithsecureHTTPS and a self-signed/private-CA certificate, Rust consumers fail withcertificate verify failed: unable to get local issuer certificateeven when verification is explicitly disabled.verify=false. UnsetCLICKHOUSE_VERIFYis unchanged: Python skips verification, Rust still verifies, soCLICKHOUSE_SECURE=truewith the env var unset still fails in the Rust consumer.Changes
CLICKHOUSE_VERIFYenv string once inClickhouseCluster.get_verify()(only explicitfalse/0disable;NonestaysNone) so Python clients and the Rust consumer config see the same value.verifyinto the Rust consumer config; the writer disables certificate and hostname verification (tls_danger_accept_invalid_certs+tls_danger_accept_invalid_hostnames, matching Python'sCERT_NONE) only whensecure && verify == Some(false), and logs a warning when it does.verifykey asNoneinstead ofFalseso "unset" stays distinct from "explicitly disabled" (both are falsy at existing Python call sites, so no behavior change).ca_certsis still not passed to the Rust writer, so a private CA cannot be trusted properly yet. That is a follow-up, not part of this PR.Testing
cargo test --lib: serde defaults (Nonewhen absent,Some(false)when explicit) and thetls_verification_disabledgate matrix.pytest tests/clusters/test_verify.py tests/consumers/test_consumer_config.py:get_verifycoercion matrix; explicitFalsepropagation throughresolve_storage_config/asdict.cargo clippy --all-targets -- -D warnings,cargo fmt --check, ruff, mypy: clean.Legal Boilerplate
Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms.