Skip to content

verify-ca mode incorrectly enforces hostname verification #38

Description

@aesslinger

Summary

ssl_mode=verify-ca is supposed to validate the server's certificate chain against a CA but skip hostname verification — that's the entire distinction between verify-ca and verify-full (matches libpq semantics, and the builtin driver's own doc comment: "validates the certificate chain against a custom root store but skips hostname verification. Matches libpq sslmode=verify-ca behavior").

This plugin's verify-ca path (src/client.rs, build_tls_connector) instead wraps rustls::client::WebPkiServerVerifier:

if needs_cert_validation {
    if let Some(ca_path) = user_ca {
        let roots = load_roots_from_pem(ca_path)?;
        let verifier =
            rustls::client::WebPkiServerVerifier::builder(std::sync::Arc::new(roots))
                .build()
                .map_err(|e| format!("Failed to build certificate verifier: {e}"))?;
        return Ok(rustls::ClientConfig::builder()
            .dangerous()
            .with_custom_certificate_verifier(verifier)
            .with_no_client_auth());
    }
}

WebPkiServerVerifier::verify_server_cert unconditionally calls verify_server_name(&cert, server_name) after chain validation — there's no way to opt out. So verify-ca here actually behaves identically to verify-full: hostname mismatches are rejected even though verify-ca should accept them as long as the chain validates.

Proof

Confirmed via a standalone probe (not part of the test suite, scratch-only): generated a CA + server cert whose CN/SAN (totally-different-hostname.example) deliberately mismatches the hostname passed to verification (my-postgres-host.internal). Calling WebPkiServerVerifier::verify_server_cert directly with that mismatch rejects with InvalidCertificate(NotValidForNameContext { expected: DnsName("my-postgres-host.internal"), presented: ["DnsName(\"totally-different-hostname.example\")"] }) — i.e. exactly the behavior verify-ca should NOT have.

Builtin's approach (for reference)

src-tauri/src/pool_manager.rs's VerifyCaCertVerifier deliberately avoids WebPkiServerVerifier for this exact reason (its own doc comment, pool_manager.rs ~line 583):

Uses verify_server_cert_signed_by_trust_anchor directly rather than wrapping WebPkiServerVerifier — this makes the "skip hostname check" intent explicit, avoids double-verifying the chain, and prevents the fragile .or(Ok(...)) error-recovery pattern.

Its verify_server_cert impl:

fn verify_server_cert(
    &self,
    end_entity: &CertificateDer<'_>,
    intermediates: &[CertificateDer<'_>],
    _server_name: &ServerName<'_>,   // <-- deliberately unused
    _ocsp_response: &[u8],
    now: UnixTime,
) -> Result<ServerCertVerified, TlsError> {
    let cert = ParsedCertificate::try_from(end_entity)?;
    verify_server_cert_signed_by_trust_anchor(
        &cert, &self.roots, intermediates, now, self.supported.all,
    )?;
    Ok(ServerCertVerified::assertion())
}

rustls::client::verify_server_cert_signed_by_trust_anchor is a public, stable function in rustls 0.23 (this plugin already pins rustls = "0.23"), and doesn't take/check server_name at all — it validates the chain only.

Proposed fix

Port a VerifyCaCertVerifier-equivalent struct into src/client.rs, implementing rustls::client::danger::ServerCertVerifier and using verify_server_cert_signed_by_trust_anchor directly (ignoring server_name), instead of WebPkiServerVerifier, for the verify-ca branch of build_tls_connector. verify-full should keep using WebPkiServerVerifier (or the platform verifier) since it should check hostname.

Add a unit test proving verify-ca accepts a chain-valid cert whose hostname doesn't match the connection target — mirroring the probe used to confirm this bug — and that verify-full still rejects the same mismatch.

Discovery context

Found while auditing this plugin for other builtin-driver parity gaps (following #34/#36), which are also TLS/pool-config issues. Not related to those two — this one is specific to verify-ca certificate verification logic.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions