diff --git a/Cargo.toml b/Cargo.toml index 6ed4599..595317c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,9 @@ path = "src/main.rs" anyhow = "1" clap = { version = "4", features = ["derive"] } dirs = "6" +form_urlencoded = "1.2.2" +native-tls = "0.2.18" +postgres-native-tls = "0.5.3" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/README.md b/README.md index 8abe9a8..acb5fe8 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,20 @@ Set the connection URL after init: export QUERYGATE_APP_DATABASE_URL="postgres://querygate_app:...@host:5432/mydb" ``` +For databases that require TLS, include the standard Postgres SSL mode in the connection URL: + +```bash +export QUERYGATE_APP_DATABASE_URL="postgres://querygate_app:...@host:5432/mydb?sslmode=require" +``` + +If you need the Node.js-style `rejectUnauthorized=false` behavior for a database with an untrusted or mismatched certificate, QueryGate accepts that URL parameter and disables TLS certificate verification for that connection: + +```bash +export QUERYGATE_APP_DATABASE_URL="postgres://querygate_app:...@host:5432/mydb?sslmode=require&rejectUnauthorized=false" +``` + +Only use `rejectUnauthorized=false` for trusted networks or development databases. It keeps the connection encrypted, but it disables verification that the server certificate belongs to the database host. + ### Policy format Example `~/.queryGate/config.yaml`: diff --git a/src/db.rs b/src/db.rs index 7c60927..f2cefb9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,10 +1,31 @@ use crate::policy::DatabaseProfile; use anyhow::{Context, Result}; +use native_tls::TlsConnector; +use postgres_native_tls::MakeTlsConnector; use serde_json::{Map, Value}; -use tokio_postgres::{Client, NoTls, Row}; +use tokio_postgres::{Client, Row}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct TlsOptions { + accept_invalid_certs: bool, +} pub async fn connect(url: &str) -> Result { - let (client, connection) = tokio_postgres::connect(url, NoTls) + let (url, tls_options) = prepare_connection_url(url)?; + + let mut tls = TlsConnector::builder(); + if tls_options.accept_invalid_certs { + tracing::warn!( + "TLS certificate verification disabled for postgres connection because rejectUnauthorized=false was set" + ); + tls.danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true); + } + + let tls = tls.build().context("failed to create TLS connector")?; + let tls = MakeTlsConnector::new(tls); + + let (client, connection) = tokio_postgres::connect(&url, tls) .await .context("failed to connect to database")?; @@ -17,6 +38,56 @@ pub async fn connect(url: &str) -> Result { Ok(client) } +fn prepare_connection_url(url: &str) -> Result<(String, TlsOptions)> { + let Some(query_start) = url.find('?') else { + return Ok((url.to_string(), TlsOptions::default())); + }; + + let before_query = &url[..query_start]; + let query_and_fragment = &url[query_start + 1..]; + let (query, fragment) = match query_and_fragment.find('#') { + Some(fragment_start) => ( + &query_and_fragment[..fragment_start], + &query_and_fragment[fragment_start..], + ), + None => (query_and_fragment, ""), + }; + + let mut options = TlsOptions::default(); + let mut stripped_reject_unauthorized = false; + let mut passthrough_params: Vec<(String, String)> = Vec::new(); + + for (key, value) in form_urlencoded::parse(query.as_bytes()) { + if key == "rejectUnauthorized" { + stripped_reject_unauthorized = true; + match value.as_ref() { + "false" => options.accept_invalid_certs = true, + "true" => {} + other => anyhow::bail!( + "invalid rejectUnauthorized value `{other}`; expected `true` or `false`" + ), + } + } else { + passthrough_params.push((key.into_owned(), value.into_owned())); + } + } + + if !stripped_reject_unauthorized { + return Ok((url.to_string(), options)); + } + + let query = form_urlencoded::Serializer::new(String::new()) + .extend_pairs(passthrough_params) + .finish(); + let url = if query.is_empty() { + format!("{before_query}{fragment}") + } else { + format!("{before_query}?{query}{fragment}") + }; + + Ok((url, options)) +} + pub struct QueryExecution { pub columns: Vec, pub rows: Vec>, @@ -50,7 +121,10 @@ async fn run_in_readonly_session( transaction .execute( - &format!("SET LOCAL statement_timeout = '{}ms'", profile.statement_timeout_ms), + &format!( + "SET LOCAL statement_timeout = '{}ms'", + profile.statement_timeout_ms + ), &[], ) .await @@ -93,10 +167,8 @@ async fn run_in_readonly_session( .collect() }; - let json_rows: Vec> = result_rows - .iter() - .map(|row| row_to_json_map(row)) - .collect(); + let json_rows: Vec> = + result_rows.iter().map(|row| row_to_json_map(row)).collect(); Ok(QueryExecution { row_count: json_rows.len(), @@ -117,7 +189,12 @@ fn row_to_json_map(row: &Row) -> Map { } fn cell_to_json(row: &Row, idx: usize, type_name: &str) -> Value { - if row.try_get::<_, Option>(idx).ok().flatten().is_none() { + if row + .try_get::<_, Option>(idx) + .ok() + .flatten() + .is_none() + { if row.try_get::<_, Option>(idx).ok().flatten().is_none() && row.try_get::<_, Option>(idx).ok().flatten().is_none() && row.try_get::<_, Option>(idx).ok().flatten().is_none() @@ -159,3 +236,57 @@ fn cell_to_json(row: &Row, idx: usize, type_name: &str) -> Value { let display = format!("{:?}", type_name); Value::String(display) } + +#[cfg(test)] +mod tests { + use super::{prepare_connection_url, TlsOptions}; + + #[test] + fn leaves_regular_connection_url_unchanged() { + let url = "postgres://user:pass@example.com/app?sslmode=require"; + + let (prepared, tls_options) = prepare_connection_url(url).unwrap(); + + assert_eq!(prepared, url); + assert_eq!(tls_options, TlsOptions::default()); + } + + #[test] + fn strips_reject_unauthorized_false_and_disables_verification() { + let url = "postgres://user:pass@example.com/app?sslmode=require&rejectUnauthorized=false&application_name=querygate"; + + let (prepared, tls_options) = prepare_connection_url(url).unwrap(); + + assert_eq!( + prepared, + "postgres://user:pass@example.com/app?sslmode=require&application_name=querygate" + ); + assert_eq!( + tls_options, + TlsOptions { + accept_invalid_certs: true + } + ); + } + + #[test] + fn strips_reject_unauthorized_true_without_changing_tls_defaults() { + let url = "postgres://user:pass@example.com/app?rejectUnauthorized=true"; + + let (prepared, tls_options) = prepare_connection_url(url).unwrap(); + + assert_eq!(prepared, "postgres://user:pass@example.com/app"); + assert_eq!(tls_options, TlsOptions::default()); + } + + #[test] + fn rejects_invalid_reject_unauthorized_value() { + let err = + prepare_connection_url("postgres://user:pass@example.com/app?rejectUnauthorized=maybe") + .unwrap_err(); + + assert!(err + .to_string() + .contains("invalid rejectUnauthorized value `maybe`")); + } +}