fix(security): sanitize dependency names & connection strings against command/SQL injection (#9590)

* fix(security): sanitize dependency names & connection strings against command/SQL injection

Follow-up to the PowerShell module-name injection fix (#9587, CWE-78): the
same audit surfaced the identical "secondary identifier interpolated into an
interpreter/SQL command without escaping" pattern in a few other executors.

- R executor (the real twin, HIGH): package name/version parsed from a
  user-supplied renv.lock were interpolated raw into an `Rscript -e
  "...renv::install(\"{pkg}@{version}\"...)"` eval string. A double-quote in
  the name/version broke out → arbitrary R (unsandboxed under DISABLE_NSJAIL /
  non-Linux). Now validated in parse_renv_lock (charset) and escaped at the
  sink as defense-in-depth (also escapes the lib path, which holds backslashes
  on Windows).

- DuckDB ATTACH (MED): the connection string built from resource fields
  (host/db/user/password) is embedded in a single-quoted DuckDB literal; escape
  quotes so a field value can't break out of the ATTACH statement.

- PgDatabase::to_uri: URL-encode host and dbname (user/password already were),
  so '@'/'/'/'?'/'&' can't reshape the parsed URI (feeds live PG connect and
  DuckDB ATTACH).

- DuckDB CREATE SECRET (FFI): wrap the interpolated S3 key/secret/endpoint in
  the existing sql_single_quote() helper, consistent with the resource-limits
  setup right above it.

- PowerShell: also escape the configured private repo URL/PAT in the install
  template (same sink as the module names; the escape landed after #9587 was
  squash-merged so it was not in the merged change).

Adds unit tests for the R validation/escaping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): escape ducklake/snowflake/bigquery identifiers; preserve IPv6 host

- to_uri: don't percent-encode bracketed IPv6 literal hosts ([::1]) — encoding
  their brackets/colons would stop them parsing as a host (review fix).
- duckdb ducklake ATTACH: the catalog conn string, storage and data_path are
  embedded in single-quoted DuckDB literals; escape quotes so a resource field
  can't break out (the ducklake path bypassed the ATTACH escape added earlier).
- snowflake: validate account_identifier (it forms the request hostname).
- bigquery: validate project_id (it forms a request URL path segment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-06-15 19:06:00 +02:00
committed by GitHub
parent 82e2197922
commit aff0a4ec18
7 changed files with 167 additions and 13 deletions
+11 -2
View File
@@ -579,13 +579,22 @@ impl PgDatabase {
Some(s) => s.to_string(),
None => "prefer".to_string(),
};
// Encode host/dbname too: an unencoded '@', '/', '?' or '&' would otherwise
// reshape the parsed URI (inject libpq params / alter host). Bracketed IPv6
// literals ([::1]) are passed through unencoded — percent-encoding their
// '['/']'/':' would stop them parsing as a host.
let host = if self.host.starts_with('[') && self.host.ends_with(']') {
self.host.clone()
} else {
urlencoding::encode(&self.host).into_owned()
};
format!(
"postgres://{user}:{password}@{host}:{port}/{dbname}?sslmode={sslmode}",
user = urlencoding::encode(&self.user.as_deref().unwrap_or("postgres")),
password = urlencoding::encode(&self.password.as_deref().unwrap_or("")),
host = &self.host,
host = host,
port = self.port.unwrap_or(5432),
dbname = self.dbname,
dbname = urlencoding::encode(&self.dbname),
sslmode = sslmode
)
}
@@ -266,6 +266,12 @@ fn setup_duckdb_connection(
.unwrap_or(("http", &base_internal_url));
let s3_endpoint_ssl = s3_endpoint_ssl == "https";
// Escape values interpolated into single-quoted SQL literals, consistent with
// configure_duckdb_resource_limits above (a stray quote would break the statement).
let s3_access_key = sql_single_quote(s3_access_key);
let s3_secret_key = sql_single_quote(s3_secret_key);
let endpoint = sql_single_quote(&format!("{s3_endpoint}/api/w/{w_id}/s3_proxy"));
conn.execute_batch(&format!(
"INSTALL httpfs; LOAD httpfs;
INSTALL azure; LOAD azure;
@@ -274,7 +280,7 @@ fn setup_duckdb_connection(
PROVIDER config,
KEY_ID '{s3_access_key}',
SECRET '{s3_secret_key}',
ENDPOINT '{s3_endpoint}/api/w/{w_id}/s3_proxy',
ENDPOINT '{endpoint}',
URL_STYLE path,
USE_SSL {s3_endpoint_ssl}
);
@@ -282,7 +288,7 @@ fn setup_duckdb_connection(
TYPE gcs,
KEY_ID '{s3_access_key}',
SECRET '{s3_secret_key}',
ENDPOINT '{s3_endpoint}/api/w/{w_id}/s3_proxy',
ENDPOINT '{endpoint}',
USE_SSL {s3_endpoint_ssl}
);
",
@@ -378,6 +378,19 @@ pub async fn do_bigquery(
.await
.map_err(|e| Error::ExecutionErr(e.to_string()))?;
// Validate before it is interpolated into request URLs as a path segment
// (https://bigquery.googleapis.com/.../projects/<project_id>/...).
if project_id.is_empty()
|| !project_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':'))
{
return Err(Error::ExecutionErr(format!(
"Invalid BigQuery project id '{}': only alphanumeric, '.', '-', '_' and ':' allowed",
project_id.chars().take(64).collect::<String>()
)));
}
let mut sig = parse_bigquery_sig(&query)
.map_err(|x| Error::ExecutionErr(x.to_string()))?
.args;
+13 -4
View File
@@ -638,9 +638,13 @@ async fn db_resource_to_attach_statements(
db_type: &str,
extra_args: Option<&str>,
) -> Result<Vec<String>> {
// Escape single quotes: the connection string is built from resource fields
// (host/db/user/password) and embedded in a single-quoted DuckDB literal, so an
// unescaped quote in any field would otherwise break out of the ATTACH statement.
let conn_str = format_attach_db_conn_str(db_resource, db_type)?.replace('\'', "''");
let attach_str = format!(
"ATTACH '{}' as {} (TYPE {}{});",
format_attach_db_conn_str(db_resource, db_type)?,
conn_str,
ident_name,
db_type,
extra_args.unwrap_or("")
@@ -690,13 +694,18 @@ async fn transform_attach_ducklake(
hidden_passwords.lock().unwrap().push(pwd.to_string());
}
let db_conn_str = format_attach_db_conn_str(ducklake.catalog_resource, db_type)?;
// Escape single quotes: db_conn_str, storage and data_path are embedded in
// single-quoted DuckDB literals below, so an unescaped quote in a resource
// field would break out of the ATTACH statement.
let db_conn_str =
format_attach_db_conn_str(ducklake.catalog_resource, db_type)?.replace('\'', "''");
let storage = ducklake
.storage
.storage
.as_deref()
.unwrap_or(DEFAULT_STORAGE);
let data_path = ducklake.storage.path;
.unwrap_or(DEFAULT_STORAGE)
.replace('\'', "''");
let data_path = ducklake.storage.path.replace('\'', "''");
let extra_args = if let Some(default_extra_args) = ducklake.extra_args {
format!("{},{}", extra_args, default_extra_args)
+6 -2
View File
@@ -554,13 +554,17 @@ pub async fn handle_powershell_job(
.replace("{job_id}", &job.id.to_string())
.replace("{has_private_repo}", &format!("${has_private_repo}"))
.replace("{has_credentials}", &format!("${has_credentials}"))
// Escape single quotes: these are interpolated into single-quoted
// PowerShell literals ($privateRepoUrl/$privateRepoPat) in the install
// script, so an unescaped quote in the configured repo URL/PAT would
// break out of the literal (same sink as the module names above).
.replace(
"{private_repo_url}",
&powershell_repo_url.unwrap_or_default(),
&powershell_repo_url.unwrap_or_default().replace("'", "''"),
)
.replace(
"{private_repo_pat}",
&powershell_repo_pat.unwrap_or_default(),
&powershell_repo_pat.unwrap_or_default().replace("'", "''"),
)
.replace("{modules}", &modules_list);
let mut cmd = Command::new(POWERSHELL_PATH.as_str());
+98 -3
View File
@@ -314,6 +314,38 @@ struct RenvPackage {
dependencies: Vec<String>,
}
/// Reject renv package names/versions that could break out of the R string
/// literal they are interpolated into (`renv::install("name@version", ...)`),
/// preventing command injection (CWE-78) via crafted renv.lock content.
/// CRAN package names are letters/digits/'.' starting with a letter; versions
/// are dotted numerics optionally with '-'/'_'/'+' separators.
fn validate_renv_package(name: &str, version: &str) -> Result<(), Error> {
let name_ok = name
.chars()
.next()
.map_or(false, |c| c.is_ascii_alphabetic())
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.');
let version_ok = !version.is_empty()
&& version
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'));
if !name_ok || !version_ok {
return Err(Error::ExecutionErr(format!(
"Invalid renv package name '{}' / version '{}': name must be alphanumeric or '.', \
version alphanumeric or '.-_+'",
name.chars().take(50).collect::<String>(),
version.chars().take(50).collect::<String>(),
)));
}
Ok(())
}
/// Escape a value for safe interpolation into an R double-quoted string literal:
/// backslash and double-quote are the only metacharacters inside `"..."`.
fn escape_r_double_quoted(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
/// Parse renv.lock JSON and extract package info including dependency edges.
fn parse_renv_lock(lockfile: &str) -> Result<Vec<RenvPackage>, Error> {
let lock: serde_json::Value = serde_json::from_str(lockfile)
@@ -378,6 +410,7 @@ fn parse_renv_lock(lockfile: &str) -> Result<Vec<RenvPackage>, Error> {
// Skip renv itself — it's already loaded and reinstalling it while
// loaded triggers a noisy "Restart your R session" message.
if !pkg_name.is_empty() && !version.is_empty() && pkg_name != "renv" {
validate_renv_package(&pkg_name, &version)?;
result.push(RenvPackage { name: pkg_name, version, repo_url, dependencies });
}
}
@@ -493,9 +526,9 @@ async fn install<'a>(
r#"options(renv.verbose = {verbose_r}, renv.config.install.verbose = {install_verbose_r}, renv.config.restart.enabled = FALSE); renv::install("{pkg}@{version}", library = "{lib}", dependencies = FALSE)"#,
verbose_r = verbose_r,
install_verbose_r = install_verbose_r,
pkg = dependency.custom_payload.pkg,
version = dependency.custom_payload.version,
lib = install_lib,
pkg = escape_r_double_quoted(&dependency.custom_payload.pkg),
version = escape_r_double_quoted(&dependency.custom_payload.version),
lib = escape_r_double_quoted(&install_lib),
),
// install.packages fallback (no version pinning):
// &format!(
@@ -730,3 +763,65 @@ tryCatch({{
spread = spread,
))
}
#[cfg(test)]
mod tests {
use super::{escape_r_double_quoted, parse_renv_lock, validate_renv_package};
#[test]
fn test_validate_renv_package_accepts_real() {
for (n, v) in [
("ggplot2", "3.4.4"),
("data.table", "1.14.8"),
("Rcpp", "1.0.11"),
("renv", "1.0.3"),
("pkg", "1.2-3"),
("x", "0.9.8.9000"),
] {
assert!(
validate_renv_package(n, v).is_ok(),
"{n}@{v} should be valid"
);
}
}
#[test]
fn test_validate_renv_package_rejects_injection() {
// name breakouts, version breakouts, leading non-letter, empty
for (n, v) in [
("ggplot2\", library=system(\"id\"))#", "1.0"),
("ok", "1.0\"); system(\"id\"); (\""),
("ok", "1.0\\\"x"),
("9pkg", "1.0"),
("pkg name", "1.0"),
("ok", ""),
] {
assert!(
validate_renv_package(n, v).is_err(),
"{n:?}@{v:?} should be rejected"
);
}
}
#[test]
fn test_escape_r_double_quoted() {
assert_eq!(escape_r_double_quoted(r#"a"b"#), r#"a\"b"#);
assert_eq!(escape_r_double_quoted(r"a\b"), r"a\\b");
// backslash escaped before quote so \" cannot be reinterpreted
assert_eq!(escape_r_double_quoted(r#"\""#), r#"\\\""#);
}
#[test]
fn test_parse_renv_lock_rejects_unsafe_name() {
let lock = r#"{"Packages": {"evil": {"Package": "evil\"); system(\"id\"); (\"", "Version": "1.0"}}}"#;
assert!(parse_renv_lock(lock).is_err());
}
#[test]
fn test_parse_renv_lock_accepts_clean() {
let lock = r#"{"Packages": {"ggplot2": {"Package": "ggplot2", "Version": "3.4.4"}}}"#;
let pkgs = parse_renv_lock(lock).unwrap();
assert_eq!(pkgs.len(), 1);
assert_eq!(pkgs[0].name, "ggplot2");
}
}
@@ -604,6 +604,24 @@ pub async fn do_snowflake(
return Err(Error::BadRequest("Missing database argument".to_string()));
};
// Validate before it is interpolated into request URLs as the hostname
// (https://<account_identifier>.snowflakecomputing.com/...).
if database.account_identifier.is_empty()
|| !database
.account_identifier
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
{
return Err(Error::BadRequest(format!(
"Invalid Snowflake account identifier '{}': only alphanumeric, '.', '-' and '_' allowed",
database
.account_identifier
.chars()
.take(64)
.collect::<String>()
)));
}
let annotations = windmill_common::worker::SqlAnnotations::parse(query);
let collection_strategy = if annotations.return_last_result {
SqlResultCollectionStrategy::LastStatementAllRows