From 9b94ca84518124ff7524f1077fcddfbd58d5c991 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Thu, 29 May 2025 17:17:27 -0700 Subject: [PATCH 1/3] postgres: Allow configuration of SSL negotiation procedure --- sqlx-postgres/src/connection/tls.rs | 66 +++++++++++++------- sqlx-postgres/src/lib.rs | 2 +- sqlx-postgres/src/options/doc.md | 1 + sqlx-postgres/src/options/mod.rs | 40 ++++++++++++ sqlx-postgres/src/options/parse.rs | 33 +++++++++- sqlx-postgres/src/options/ssl_negotiation.rs | 35 +++++++++++ 6 files changed, 152 insertions(+), 25 deletions(-) create mode 100644 sqlx-postgres/src/options/ssl_negotiation.rs diff --git a/sqlx-postgres/src/connection/tls.rs b/sqlx-postgres/src/connection/tls.rs index 16b7333bf5..86803138ff 100644 --- a/sqlx-postgres/src/connection/tls.rs +++ b/sqlx-postgres/src/connection/tls.rs @@ -3,7 +3,7 @@ use crate::net::tls::{self, TlsConfig}; use crate::net::{Socket, SocketIntoBox, WithSocket}; use crate::message::SslRequest; -use crate::{PgConnectOptions, PgSslMode}; +use crate::{PgConnectOptions, PgSslMode, PgSslNegotiation}; pub struct MaybeUpgradeTls<'a>(pub &'a PgConnectOptions); @@ -19,28 +19,52 @@ async fn maybe_upgrade( mut socket: S, options: &PgConnectOptions, ) -> Result, Error> { - // https://www.postgresql.org/docs/12/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS - match options.ssl_mode { - // FIXME: Implement ALLOW - PgSslMode::Allow | PgSslMode::Disable => return Ok(Box::new(socket)), - - PgSslMode::Prefer => { - if !tls::available() { - return Ok(Box::new(socket)); - } - - // try upgrade, but its okay if we fail - if !request_upgrade(&mut socket, options).await? { - return Ok(Box::new(socket)); + // https://www.postgresql.org/docs/17/libpq-connect.html#LIBPQ-CONNECT-SSLNEGOTIATION + match options.ssl_negotiation { + PgSslNegotiation::Postgres => { + // https://www.postgresql.org/docs/12/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS + match options.ssl_mode { + // FIXME: Implement ALLOW + PgSslMode::Allow | PgSslMode::Disable => return Ok(Box::new(socket)), + + PgSslMode::Prefer => { + if !tls::available() { + return Ok(Box::new(socket)); + } + + // try upgrade, but its okay if we fail + if !request_upgrade(&mut socket, options).await? { + return Ok(Box::new(socket)); + } + } + + PgSslMode::Require | PgSslMode::VerifyFull | PgSslMode::VerifyCa => { + tls::error_if_unavailable()?; + + if !request_upgrade(&mut socket, options).await? { + // upgrade failed, die + return Err(Error::Tls("server does not support TLS".into())); + } + } } } - - PgSslMode::Require | PgSslMode::VerifyFull | PgSslMode::VerifyCa => { - tls::error_if_unavailable()?; - - if !request_upgrade(&mut socket, options).await? { - // upgrade failed, die - return Err(Error::Tls("server does not support TLS".into())); + PgSslNegotiation::Direct => { + // Direct TLS negotiation without PostgreSQL handshake + match options.ssl_mode { + PgSslMode::Disable | PgSslMode::Allow | PgSslMode::Prefer => { + return Err(Error::Tls( + format!( + "SSL mode {:?} is incompatible with direct SSL negotiation", + options.ssl_mode + ) + .into(), + )); + } + + PgSslMode::Require | PgSslMode::VerifyFull | PgSslMode::VerifyCa => { + tls::error_if_unavailable()?; + // No need to request an upgrade. We go straight to TLS handshake. + } } } } diff --git a/sqlx-postgres/src/lib.rs b/sqlx-postgres/src/lib.rs index bded75491c..11ba8b35b2 100644 --- a/sqlx-postgres/src/lib.rs +++ b/sqlx-postgres/src/lib.rs @@ -54,7 +54,7 @@ pub use database::Postgres; pub use error::{PgDatabaseError, PgErrorPosition}; pub use listener::{PgListener, PgNotification}; pub use message::PgSeverity; -pub use options::{PgConnectOptions, PgSslMode}; +pub use options::{PgConnectOptions, PgSslMode, PgSslNegotiation}; pub use query_result::PgQueryResult; pub use row::PgRow; pub use statement::PgStatement; diff --git a/sqlx-postgres/src/options/doc.md b/sqlx-postgres/src/options/doc.md index 15c2459c81..000370c10e 100644 --- a/sqlx-postgres/src/options/doc.md +++ b/sqlx-postgres/src/options/doc.md @@ -33,6 +33,7 @@ if a parameter is not passed in via URL, it is populated by reading | `port` | `PGPORT` | `5432` | | `dbname` | `PGDATABASE` | Unset; defaults to the username server-side. | | `sslmode` | `PGSSLMODE` | `prefer`. See [`PgSslMode`] for details. | +| `sslnegotiation` | `PGSSLNEGOTIATION` | `postgres`. See [`PgSslNegotiation`] for details. | | `sslrootcert` | `PGSSLROOTCERT` | Unset. See [Note: SSL](#note-ssl). | | `sslcert` | `PGSSLCERT` | Unset. See [Note: SSL](#note-ssl). | | `sslkey` | `PGSSLKEY` | Unset. See [Note: SSL](#note-ssl). | diff --git a/sqlx-postgres/src/options/mod.rs b/sqlx-postgres/src/options/mod.rs index 723721a97c..61ddc8f8e3 100644 --- a/sqlx-postgres/src/options/mod.rs +++ b/sqlx-postgres/src/options/mod.rs @@ -4,6 +4,7 @@ use std::fmt::{Display, Write}; use std::path::{Path, PathBuf}; pub use ssl_mode::PgSslMode; +pub use ssl_negotiation::PgSslNegotiation; use crate::{connection::LogSettings, net::tls::CertificateInput}; @@ -11,6 +12,7 @@ mod connect; mod parse; mod pgpass; mod ssl_mode; +mod ssl_negotiation; #[doc = include_str!("doc.md")] #[derive(Debug, Clone)] @@ -22,6 +24,7 @@ pub struct PgConnectOptions { pub(crate) password: Option, pub(crate) database: Option, pub(crate) ssl_mode: PgSslMode, + pub(crate) ssl_negotiation: PgSslNegotiation, pub(crate) ssl_root_cert: Option, pub(crate) ssl_client_cert: Option, pub(crate) ssl_client_key: Option, @@ -85,6 +88,10 @@ impl PgConnectOptions { .ok() .and_then(|v| v.parse().ok()) .unwrap_or_default(), + ssl_negotiation: var("PGSSLNEGOTIATION") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or_default(), statement_cache_capacity: 100, application_name: var("PGAPPNAME").ok(), extra_float_digits: Some("2".into()), @@ -218,6 +225,26 @@ impl PgConnectOptions { self } + /// Sets the protocol with which the secure SSL TCP/IP connection will be negotiated with + /// the server. + /// + /// By default, the protocol is [`Postgres`](PgSslNegotiation::Postgres), and the client will + /// first check whether the server supports SSL, and fallback to a non-SSL connection if not. + /// + /// Ignored for Unix domain socket communication. + /// + /// # Example + /// + /// ```rust + /// # use sqlx_postgres::{PgSslNegotiation, PgConnectOptions}; + /// let options = PgConnectOptions::new() + /// .ssl_negotiation(PgSslNegotiation::Postgres); + /// ``` + pub fn ssl_negotiation(mut self, procedure: PgSslNegotiation) -> Self { + self.ssl_negotiation = procedure; + self + } + /// Sets the name of a file containing SSL certificate authority (CA) certificate(s). /// If the file exists, the server's certificate will be verified to be signed by /// one of these authorities. @@ -542,6 +569,19 @@ impl PgConnectOptions { self.ssl_mode } + /// Get the SSL negotiation protocol. + /// + /// # Example + /// + /// ```rust + /// # use sqlx_postgres::{PgConnectOptions, PgSslNegotiation}; + /// let options = PgConnectOptions::new(); + /// assert!(matches!(options.get_ssl_negotiation(), PgSslNegotiation::Postgres)); + /// ``` + pub fn get_ssl_negotiation(&self) -> PgSslNegotiation { + self.ssl_negotiation + } + /// Get the application name. /// /// # Example diff --git a/sqlx-postgres/src/options/parse.rs b/sqlx-postgres/src/options/parse.rs index efbf85d8f6..9c80af8a0c 100644 --- a/sqlx-postgres/src/options/parse.rs +++ b/sqlx-postgres/src/options/parse.rs @@ -1,5 +1,5 @@ use crate::error::Error; -use crate::{PgConnectOptions, PgSslMode}; +use crate::{PgConnectOptions, PgSslMode, PgSslNegotiation}; use sqlx_core::percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC}; use sqlx_core::Url; use std::net::IpAddr; @@ -53,6 +53,10 @@ impl PgConnectOptions { options = options.ssl_mode(value.parse().map_err(Error::config)?); } + "sslnegotiation" | "ssl-negotiation" => { + options = options.ssl_negotiation(value.parse().map_err(Error::config)?); + } + "sslrootcert" | "ssl-root-cert" | "ssl-ca" => { options = options.ssl_root_cert(&*value); } @@ -146,6 +150,13 @@ impl PgConnectOptions { }; url.query_pairs_mut().append_pair("sslmode", ssl_mode); + let ssl_negotiation = match self.ssl_negotiation { + PgSslNegotiation::Postgres => "postgres", + PgSslNegotiation::Direct => "direct", + }; + url.query_pairs_mut() + .append_pair("sslnegotiation", ssl_negotiation); + if let Some(ssl_root_cert) = &self.ssl_root_cert { url.query_pairs_mut() .append_pair("sslrootcert", &ssl_root_cert.to_string()); @@ -266,6 +277,22 @@ fn it_parses_password_with_non_ascii_chars_correctly() { assert_eq!(Some("p@ssw0rd".into()), opts.password); } +#[test] +fn it_parses_sslmode_correctly_from_parameter() { + let url = "postgres://?sslmode=verify-full"; + let opts = PgConnectOptions::from_str(url).unwrap(); + + assert!(matches!(opts.ssl_mode, PgSslMode::VerifyFull)); +} + +#[test] +fn it_parses_sslnegotiation_correctly_from_parameter() { + let url = "postgres://?sslnegotiation=direct"; + let opts = PgConnectOptions::from_str(url).unwrap(); + + assert!(matches!(opts.ssl_negotiation, PgSslNegotiation::Direct)); +} + #[test] fn it_parses_socket_correctly_percent_encoded() { let url = "postgres://%2Fvar%2Flib%2Fpostgres/database"; @@ -310,7 +337,7 @@ fn it_returns_the_parsed_url_when_socket() { let mut expected_url = Url::parse(url).unwrap(); // PgConnectOptions defaults - let query_string = "sslmode=prefer&statement-cache-capacity=100"; + let query_string = "sslmode=prefer&sslnegotiation=postgres&statement-cache-capacity=100"; let port = 5432; expected_url.set_query(Some(query_string)); let _ = expected_url.set_port(Some(port)); @@ -325,7 +352,7 @@ fn it_returns_the_parsed_url_when_host() { let mut expected_url = Url::parse(url).unwrap(); // PgConnectOptions defaults - let query_string = "sslmode=prefer&statement-cache-capacity=100"; + let query_string = "sslmode=prefer&sslnegotiation=postgres&statement-cache-capacity=100"; expected_url.set_query(Some(query_string)); assert_eq!(expected_url, opts.build_url()); diff --git a/sqlx-postgres/src/options/ssl_negotiation.rs b/sqlx-postgres/src/options/ssl_negotiation.rs new file mode 100644 index 0000000000..e48c384afc --- /dev/null +++ b/sqlx-postgres/src/options/ssl_negotiation.rs @@ -0,0 +1,35 @@ +use crate::error::Error; +use std::str::FromStr; + +/// Options for controlling the connection establishment procedure for PostgreSQL SSL connections. +/// +/// It is used by the [`sslnegotiation`](super::PgConnectOptions::ssl_negotiation) method. +#[derive(Debug, Clone, Copy, Default)] +pub enum PgSslNegotiation { + /// The client first asks the server if SSL is supported. + /// + /// This is the default if no other mode is specified. + #[default] + Postgres, + + /// The client starts the standard SSL handshake directly after establishing the TCP/IP + /// connection. + Direct, +} + +impl FromStr for PgSslNegotiation { + type Err = Error; + + fn from_str(s: &str) -> Result { + Ok(match &*s.to_ascii_lowercase() { + "postgres" => PgSslNegotiation::Postgres, + "direct" => PgSslNegotiation::Direct, + + _ => { + return Err(Error::Configuration( + format!("unknown value {s:?} for `ssl_negotiation`").into(), + )); + } + }) + } +} From 5d96ad7e5413317583f1ae55b22a6c0cce9e46b4 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Fri, 30 May 2025 11:36:33 -0700 Subject: [PATCH 2/3] postgres: Use 'postgresql' ALPN for SSL connections --- sqlx-core/Cargo.toml | 2 +- sqlx-core/src/net/tls/mod.rs | 1 + sqlx-core/src/net/tls/tls_native_tls.rs | 4 ++++ sqlx-core/src/net/tls/tls_rustls.rs | 11 ++++++++++- sqlx-mysql/src/connection/tls.rs | 1 + sqlx-postgres/src/connection/tls.rs | 1 + 6 files changed, 18 insertions(+), 2 deletions(-) diff --git a/sqlx-core/Cargo.toml b/sqlx-core/Cargo.toml index f6017a9fee..4bc992e10c 100644 --- a/sqlx-core/Cargo.toml +++ b/sqlx-core/Cargo.toml @@ -37,7 +37,7 @@ async-std = { workspace = true, optional = true } tokio = { workspace = true, optional = true } # TLS -native-tls = { version = "0.2.10", optional = true } +native-tls = { version = "0.2.10", features = ["alpn"], optional = true } rustls = { version = "0.23.15", default-features = false, features = ["std", "tls12"], optional = true } webpki-roots = { version = "0.26", optional = true } diff --git a/sqlx-core/src/net/tls/mod.rs b/sqlx-core/src/net/tls/mod.rs index 7bb1744189..3968512556 100644 --- a/sqlx-core/src/net/tls/mod.rs +++ b/sqlx-core/src/net/tls/mod.rs @@ -64,6 +64,7 @@ pub struct TlsConfig<'a> { pub root_cert_path: Option<&'a CertificateInput>, pub client_cert_path: Option<&'a CertificateInput>, pub client_key_path: Option<&'a CertificateInput>, + pub alpn_protocols: Option>, } pub async fn handshake( diff --git a/sqlx-core/src/net/tls/tls_native_tls.rs b/sqlx-core/src/net/tls/tls_native_tls.rs index 1c40b4b01f..c1c263df49 100644 --- a/sqlx-core/src/net/tls/tls_native_tls.rs +++ b/sqlx-core/src/net/tls/tls_native_tls.rs @@ -53,6 +53,10 @@ pub async fn handshake( builder.add_root_certificate(native_tls::Certificate::from_pem(&data).map_err(Error::tls)?); } + if let Some(protocols) = config.alpn_protocols { + builder.request_alpns(&protocols); + } + // authentication using user's key-file and its associated certificate if let (Some(cert_path), Some(key_path)) = (config.client_cert_path, config.client_key_path) { let cert_path = cert_path.data().await?; diff --git a/sqlx-core/src/net/tls/tls_rustls.rs b/sqlx-core/src/net/tls/tls_rustls.rs index 1a85cf0ff9..b688e4c426 100644 --- a/sqlx-core/src/net/tls/tls_rustls.rs +++ b/sqlx-core/src/net/tls/tls_rustls.rs @@ -123,7 +123,7 @@ where } }; - let config = if tls_config.accept_invalid_certs { + let mut config = if tls_config.accept_invalid_certs { if let Some(user_auth) = user_auth { config .dangerous() @@ -183,6 +183,15 @@ where } }; + if let Some(alpn_protocols) = tls_config.alpn_protocols { + let alpn_protocols: Vec> = alpn_protocols + .into_iter() + .map(|s| s.as_bytes().to_vec()) + .collect(); + + config.alpn_protocols = alpn_protocols; + } + let host = ServerName::try_from(tls_config.hostname.to_owned()).map_err(Error::tls)?; let mut socket = RustlsSocket { diff --git a/sqlx-mysql/src/connection/tls.rs b/sqlx-mysql/src/connection/tls.rs index eb077c621b..3d312b6851 100644 --- a/sqlx-mysql/src/connection/tls.rs +++ b/sqlx-mysql/src/connection/tls.rs @@ -66,6 +66,7 @@ pub(super) async fn maybe_upgrade( root_cert_path: options.ssl_ca.as_ref(), client_cert_path: options.ssl_client_cert.as_ref(), client_key_path: options.ssl_client_key.as_ref(), + alpn_protocols: None, }; // Request TLS upgrade diff --git a/sqlx-postgres/src/connection/tls.rs b/sqlx-postgres/src/connection/tls.rs index 86803138ff..e2f84d9a18 100644 --- a/sqlx-postgres/src/connection/tls.rs +++ b/sqlx-postgres/src/connection/tls.rs @@ -82,6 +82,7 @@ async fn maybe_upgrade( root_cert_path: options.ssl_root_cert.as_ref(), client_cert_path: options.ssl_client_cert.as_ref(), client_key_path: options.ssl_client_key.as_ref(), + alpn_protocols: Some(vec!["postgresql"]), }; tls::handshake(socket, config, SocketIntoBox).await From 63db85e417f304b81830bf791cd987baaefa6e84 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Fri, 30 May 2025 12:04:43 -0700 Subject: [PATCH 3/3] postgres: Add Postgres 17 direct connection test --- .github/workflows/sqlx.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/sqlx.yml b/.github/workflows/sqlx.yml index 1e3513b1ee..3875f0ea50 100644 --- a/.github/workflows/sqlx.yml +++ b/.github/workflows/sqlx.yml @@ -314,6 +314,17 @@ jobs: # but `PgLTree` should just fall back to text format RUSTFLAGS: --cfg postgres_${{ matrix.postgres }}_client_ssl + - if: matrix.tls != 'none' && matrix.postgres == '17' + run: > + cargo test + --no-default-features + --features any,postgres,macros,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }} + env: + DATABASE_URL: postgres://postgres@localhost:5432/sqlx?sslnegotiation=direct&sslmode=require&sslkey=.%2Ftests%2Fkeys%2Fclient.key&sslcert=.%2Ftests%2Fcerts%2Fclient.crt + # FIXME: needed to disable `ltree` tests in Postgres 9.6 + # but `PgLTree` should just fall back to text format + RUSTFLAGS: --cfg postgres_${{ matrix.postgres }}_client_ssl + mysql: name: MySQL runs-on: ubuntu-24.04