fix: accept RDS certificates without EKU using verified OpenSSL TLS

This commit is contained in:
Valdemar Lorenzen
2026-10-20 13:40:04 +02:00
parent 08afa1e495
commit 2aac7361d6
4 changed files with 152 additions and 25 deletions
Generated
+25
View File
@@ -271,8 +271,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"crossterm", "crossterm",
"native-tls", "native-tls",
"openssl",
"postgres", "postgres",
"postgres-native-tls", "postgres-native-tls",
"postgres-openssl",
"ratatui", "ratatui",
"rusqlite", "rusqlite",
"shellexpand", "shellexpand",
@@ -941,6 +943,18 @@ dependencies = [
"tokio-postgres", "tokio-postgres",
] ]
[[package]]
name = "postgres-openssl"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06743eefaa1a5c0ef2ccb6d9abf6528790a229eabd62ddcabf9b2a3aeff09fa4"
dependencies = [
"openssl",
"tokio",
"tokio-openssl",
"tokio-postgres",
]
[[package]] [[package]]
name = "postgres-protocol" name = "postgres-protocol"
version = "0.6.12" version = "0.6.12"
@@ -1475,6 +1489,17 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-openssl"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59df6849caa43bb7567f9a36f863c447d95a11d5903c9cc334ba32576a27eadd"
dependencies = [
"openssl",
"openssl-sys",
"tokio",
]
[[package]] [[package]]
name = "tokio-postgres" name = "tokio-postgres"
version = "0.7.18" version = "0.7.18"
+2
View File
@@ -10,6 +10,8 @@ crossterm = "0.29"
postgres = "0.19" postgres = "0.19"
postgres-native-tls = "0.5" postgres-native-tls = "0.5"
native-tls = "0.2" native-tls = "0.2"
postgres-openssl = "0.5"
openssl = "0.10"
rusqlite = { version = "0.40", default-features = false, features = ["bundled"] } rusqlite = { version = "0.40", default-features = false, features = ["bundled"] }
shellexpand = { version = "3.1", features = ["path"] } shellexpand = { version = "3.1", features = ["path"] }
sqlparser = { version = "0.63", default-features = false, features = ["std"] } sqlparser = { version = "0.63", default-features = false, features = ["std"] }
+122 -25
View File
@@ -1,6 +1,10 @@
use std::{path::Path, time::Duration}; use std::{path::Path, time::Duration};
use native_tls::{Certificate, TlsConnector}; use native_tls::TlsConnector;
use openssl::{
ssl::{SslConnector, SslMethod, SslVerifyMode, SslVersion},
x509::{X509, store::X509StoreBuilder},
};
use postgres::{Client, Config, config::SslMode}; use postgres::{Client, Config, config::SslMode};
use postgres_native_tls::MakeTlsConnector; use postgres_native_tls::MakeTlsConnector;
@@ -61,8 +65,12 @@ impl Database {
if config.get_connect_timeout().is_none() { if config.get_connect_timeout().is_none() {
config.connect_timeout(Duration::from_secs(5)); config.connect_timeout(Duration::from_secs(5));
} }
let tls = MakeTlsConnector::new(postgres_tls(config.get_ssl_mode(), root_cert)?); let mut client = if let Some(path) = root_cert {
let mut client = config.connect(tls)?; let tls = postgres_openssl::MakeTlsConnector::new(postgres_tls(config.get_ssl_mode(), path)?);
config.connect(tls)?
} else {
config.connect(MakeTlsConnector::new(TlsConnector::new()?))?
};
// Views can invoke functions: enforce read-only on the server, not just in the UI. // Views can invoke functions: enforce read-only on the server, not just in the UI.
client.batch_execute( client.batch_execute(
"SET default_transaction_read_only = on; SET statement_timeout = '5s';", "SET default_transaction_read_only = on; SET statement_timeout = '5s';",
@@ -276,26 +284,28 @@ impl Database {
} }
} }
fn postgres_tls(mode: SslMode, root_cert: Option<&Path>) -> Result<TlsConnector> { fn postgres_tls(mode: SslMode, root_cert: &Path) -> Result<SslConnector> {
let mut tls = TlsConnector::builder(); if mode != SslMode::Require {
if let Some(path) = root_cert { return Err("A CA bundle requires Require TLS (sslmode=require); plaintext fallback is not allowed.".into());
if mode != SslMode::Require {
return Err("A CA bundle requires Require TLS (sslmode=require); plaintext fallback is not allowed.".into());
}
let pem = std::fs::read(path)
.map_err(|error| format!("Cannot read CA bundle: {error}"))?;
let certificates = Certificate::stack_from_pem(&pem)
.map_err(|error| format!("Invalid PEM CA bundle: {error}"))?;
if certificates.is_empty() {
return Err("CA bundle must contain at least one PEM CA certificate.".into());
}
// An explicit bundle replaces system roots; certificate and hostname checks stay enabled.
tls.disable_built_in_roots(true);
for certificate in certificates {
tls.add_root_certificate(certificate);
}
} }
Ok(tls.build()?) let pem = std::fs::read(root_cert)
.map_err(|error| format!("Cannot read CA bundle: {error}"))?;
let certificates = X509::stack_from_pem(&pem)
.map_err(|error| format!("Invalid PEM CA bundle: {error}"))?;
if certificates.is_empty() {
return Err("CA bundle must contain at least one PEM CA certificate.".into());
}
let mut roots = X509StoreBuilder::new()?;
for certificate in certificates {
roots.add_cert(certificate)?;
}
// RDS may omit EKU. OpenSSL accepts absent EKU, but rejects incompatible usage.
// Replace default roots; normal chain, validity, purpose, and hostname checks stay enabled.
let mut tls = SslConnector::builder(SslMethod::tls_client())?;
tls.set_cert_store(roots.build());
tls.set_min_proto_version(Some(SslVersion::TLS1_2))?;
tls.set_verify(SslVerifyMode::PEER);
Ok(tls.build())
} }
fn page_offset(page: usize) -> Result<i64> { fn page_offset(page: usize) -> Result<i64> {
@@ -325,18 +335,105 @@ pub fn display_text(value: &str) -> String {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn private_ca_tls_accepts_absent_eku_but_rejects_wrong_usage_host_and_expiry() -> Result<()> {
use openssl::{
asn1::Asn1Time,
bn::BigNum,
hash::MessageDigest,
pkey::PKey,
rsa::Rsa,
ssl::SslAcceptor,
x509::{X509NameBuilder, extension::{BasicConstraints, ExtendedKeyUsage, SubjectAlternativeName}},
};
use std::net::{TcpListener, TcpStream};
const NOW: i64 = 1_800_000_000;
let before = Asn1Time::from_unix(NOW - 86_400)?;
let after = Asn1Time::from_unix(NOW + 86_400)?;
let ca_key = PKey::from_rsa(Rsa::generate(2048)?)?;
let server_key = PKey::from_rsa(Rsa::generate(2048)?)?;
let mut name = X509NameBuilder::new()?;
name.append_entry_by_text("CN", "dbdash test CA")?;
let name = name.build();
let mut ca = X509::builder()?;
ca.set_version(2)?;
let serial = BigNum::from_u32(1)?.to_asn1_integer()?;
ca.set_serial_number(&serial)?;
ca.set_subject_name(&name)?;
ca.set_issuer_name(&name)?;
ca.set_pubkey(&ca_key)?;
ca.set_not_before(&before)?;
ca.set_not_after(&after)?;
ca.append_extension(BasicConstraints::new().critical().ca().build()?)?;
ca.sign(&ca_key, MessageDigest::sha256())?;
let ca = ca.build();
let bundle = tempfile::NamedTempFile::new()?;
std::fs::write(bundle.path(), ca.to_pem()?)?;
let mut name = X509NameBuilder::new()?;
name.append_entry_by_text("CN", "localhost")?;
let name = name.build();
for (client_only, hostname, clock, accepted) in [
(false, "localhost", NOW, true),
(true, "localhost", NOW, false),
(false, "wrong.example", NOW, false),
(false, "localhost", NOW + 172_800, false),
] {
let mut leaf = X509::builder()?;
leaf.set_version(2)?;
let serial = BigNum::from_u32(2)?.to_asn1_integer()?;
leaf.set_serial_number(&serial)?;
leaf.set_subject_name(&name)?;
leaf.set_issuer_name(ca.subject_name())?;
leaf.set_pubkey(&server_key)?;
leaf.set_not_before(&before)?;
leaf.set_not_after(&after)?;
leaf.append_extension(BasicConstraints::new().critical().build()?)?;
let san = SubjectAlternativeName::new().dns("localhost")
.build(&leaf.x509v3_context(Some(&ca), None))?;
leaf.append_extension(san)?;
if client_only {
leaf.append_extension(ExtendedKeyUsage::new().client_auth().build()?)?;
}
leaf.sign(&ca_key, MessageDigest::sha256())?;
let mut acceptor = SslAcceptor::mozilla_intermediate(SslMethod::tls_server())?;
acceptor.set_private_key(&server_key)?;
acceptor.set_certificate(&leaf.build())?;
let acceptor = acceptor.build();
let listener = TcpListener::bind("127.0.0.1:0")?;
let socket = TcpStream::connect(listener.local_addr()?)?;
socket.set_read_timeout(Some(Duration::from_secs(3)))?;
socket.set_write_timeout(Some(Duration::from_secs(3)))?;
let server = std::thread::spawn(move || {
let (socket, _) = listener.accept().unwrap();
socket.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
socket.set_write_timeout(Some(Duration::from_secs(3))).unwrap();
let _ = acceptor.accept(socket);
});
let connector = postgres_tls(SslMode::Require, bundle.path())?;
let mut configuration = connector.configure()?;
configuration.param_mut().set_time(clock);
let result = configuration.connect(hostname, socket);
server.join().unwrap();
assert_eq!(result.is_ok(), accepted,
"client_only={client_only}, hostname={hostname}, clock={clock}: {result:?}");
}
Ok(())
}
#[test] #[test]
fn explicit_ca_bundle_fails_closed_on_insecure_modes_and_invalid_files() -> Result<()> { fn explicit_ca_bundle_fails_closed_on_insecure_modes_and_invalid_files() -> Result<()> {
let bundle = tempfile::NamedTempFile::new()?; let bundle = tempfile::NamedTempFile::new()?;
for mode in [SslMode::Prefer, SslMode::Disable] { for mode in [SslMode::Prefer, SslMode::Disable] {
assert!(postgres_tls(mode, Some(bundle.path())).is_err()); assert!(postgres_tls(mode, bundle.path()).is_err());
} }
for contents in ["", "not a PEM certificate", "-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----"] { for contents in ["", "not a PEM certificate", "-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----"] {
std::fs::write(bundle.path(), contents)?; std::fs::write(bundle.path(), contents)?;
assert!(postgres_tls(SslMode::Require, Some(bundle.path())).is_err()); assert!(postgres_tls(SslMode::Require, bundle.path()).is_err());
} }
let missing = bundle.path().with_extension("missing"); let missing = bundle.path().with_extension("missing");
assert!(postgres_tls(SslMode::Require, Some(&missing)).is_err()); assert!(postgres_tls(SslMode::Require, &missing).is_err());
Ok(()) Ok(())
} }
+3
View File
@@ -45,6 +45,9 @@ For CLI PostgreSQL connections, use --ca-bundle PATH before the connection strin
and specify sslmode=require. Unset PGSSLROOTCERT; legacy launchers are rejected and specify sslmode=require. Unset PGSSLROOTCERT; legacy launchers are rejected
instead of silently losing their custom CA verification. instead of silently losing their custom CA verification.
Only the selected bundle is trusted for that connection; system trust is unchanged. Only the selected bundle is trusted for that connection; system trust is unchanged.
Explicit CA bundles use OpenSSL verification for compatibility with RDS certificates
that omit Extended Key Usage; incompatible EKU, invalid chains, dates, and hostnames
are still rejected. System trust uses the native platform TLS verifier.
Certificates and hostnames are verified before authentication; no plaintext fallback Certificates and hostnames are verified before authentication; no plaintext fallback
with a CA bundle. Missing, empty, or invalid selected files block connection. with a CA bundle. Missing, empty, or invalid selected files block connection.
Use a bundle from the trusted issuer, not an unverified server's certificate. Use a bundle from the trusted issuer, not an unverified server's certificate.