Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
147 changes: 139 additions & 8 deletions src/db.rs
Original file line number Diff line number Diff line change
@@ -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<Client> {
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")?;

Expand All @@ -17,6 +38,56 @@ pub async fn connect(url: &str) -> Result<Client> {
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<String>,
pub rows: Vec<Map<String, Value>>,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -93,10 +167,8 @@ async fn run_in_readonly_session(
.collect()
};

let json_rows: Vec<Map<String, Value>> = result_rows
.iter()
.map(|row| row_to_json_map(row))
.collect();
let json_rows: Vec<Map<String, Value>> =
result_rows.iter().map(|row| row_to_json_map(row)).collect();

Ok(QueryExecution {
row_count: json_rows.len(),
Expand All @@ -117,7 +189,12 @@ fn row_to_json_map(row: &Row) -> Map<String, Value> {
}

fn cell_to_json(row: &Row, idx: usize, type_name: &str) -> Value {
if row.try_get::<_, Option<String>>(idx).ok().flatten().is_none() {
if row
.try_get::<_, Option<String>>(idx)
.ok()
.flatten()
.is_none()
{
if row.try_get::<_, Option<i32>>(idx).ok().flatten().is_none()
&& row.try_get::<_, Option<i64>>(idx).ok().flatten().is_none()
&& row.try_get::<_, Option<f64>>(idx).ok().flatten().is_none()
Expand Down Expand Up @@ -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`"));
}
}
Loading