fix: support explicit PostgreSQL CA bundles without plaintext fallback

This commit is contained in:
Valdemar Lorenzen
2026-10-20 12:13:20 +02:00
parent e965fc9e38
commit 499e070397
2 changed files with 58 additions and 3 deletions
+46 -3
View File
@@ -1,7 +1,7 @@
use std::{path::Path, time::Duration};
use native_tls::TlsConnector;
use postgres::{Client, Config};
use native_tls::{Certificate, TlsConnector};
use postgres::{Client, Config, config::SslMode};
use postgres_native_tls::MakeTlsConnector;
use crate::Result;
@@ -61,7 +61,11 @@ impl Database {
if config.get_connect_timeout().is_none() {
config.connect_timeout(Duration::from_secs(5));
}
let tls = MakeTlsConnector::new(TlsConnector::new()?);
let root_cert = std::env::var_os("PGSSLROOTCERT");
let tls = MakeTlsConnector::new(postgres_tls(
config.get_ssl_mode(),
root_cert.as_deref().map(Path::new),
)?);
let mut client = config.connect(tls)?;
// Views can invoke functions: enforce read-only on the server, not just in the UI.
client.batch_execute(
@@ -276,6 +280,28 @@ impl Database {
}
}
fn postgres_tls(mode: SslMode, root_cert: Option<&Path>) -> Result<TlsConnector> {
let mut tls = TlsConnector::builder();
if let Some(path) = root_cert {
if mode != SslMode::Require {
return Err("PGSSLROOTCERT requires Require TLS (sslmode=require); plaintext fallback is not allowed.".into());
}
let pem = std::fs::read(path)
.map_err(|error| format!("Cannot read PGSSLROOTCERT CA file: {error}"))?;
let certificates = Certificate::stack_from_pem(&pem)
.map_err(|error| format!("Invalid PGSSLROOTCERT PEM CA bundle: {error}"))?;
if certificates.is_empty() {
return Err("PGSSLROOTCERT 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()?)
}
fn page_offset(page: usize) -> Result<i64> {
i64::try_from(page)?
.checked_mul(PAGE_SIZE as i64)
@@ -303,6 +329,23 @@ pub fn display_text(value: &str) -> String {
mod tests {
use super::*;
#[test]
fn explicit_ca_bundle_fails_closed_on_insecure_modes_and_invalid_files() -> Result<()> {
let bundle = tempfile::NamedTempFile::new()?;
for mode in [SslMode::Prefer, SslMode::Disable] {
let error = postgres_tls(mode, Some(bundle.path())).unwrap_err();
assert!(error.to_string().contains("sslmode=require"));
}
for contents in ["", "not a PEM certificate", "-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----"] {
std::fs::write(bundle.path(), contents)?;
assert!(postgres_tls(SslMode::Require, Some(bundle.path())).is_err());
}
let missing = bundle.path().with_extension("missing");
let error = postgres_tls(SslMode::Require, Some(&missing)).unwrap_err();
assert!(error.to_string().contains("Cannot read PGSSLROOTCERT"));
Ok(())
}
#[test]
fn sqlite_identity_is_filename_without_terminal_controls() -> Result<()> {
let directory = tempfile::tempdir()?;