pub fn validate_connection_string(conn_str: &str) -> Result<String, String> {
if conn_str.trim().is_empty() {
return Err("Connection string is empty".into());
}
let shell_chars = ['`', '$', '|', '&', ';', '\n', '\r', '\0'];
for ch in &shell_chars {
if conn_str.contains(*ch) {
return Err(format!(
"Connection string contains forbidden character: '{}'",
ch.escape_default()
));
}
}
if conn_str.contains("$(") || conn_str.contains("${") {
return Err("Connection string contains shell command substitution".into());
}
if conn_str.starts_with("postgresql://")
|| conn_str.starts_with("postgres://")
|| conn_str.starts_with("mysql://")
{
validate_database_url(conn_str)
} else if conn_str.starts_with("http://") || conn_str.starts_with("https://") {
Ok(conn_str.to_string())
} else {
validate_file_path(conn_str)?;
Ok(conn_str.to_string())
}
}
fn validate_database_url(url: &str) -> Result<String, String> {
let dangerous_params = [
"sslrootcert=/etc",
"sslcert=/proc",
"init_command=",
"options=-c",
"application_name=';",
];
let lower = url.to_lowercase();
for param in &dangerous_params {
if lower.contains(param) {
return Err(format!(
"Connection URL contains suspicious parameter: {}",
param
));
}
}
if url::Url::parse(url).is_err() {
return Err("Connection string is not a valid URL".into());
}
Ok(url.to_string())
}
pub fn validate_file_path(path: &str) -> Result<(), String> {
if path.contains("..") {
return Err("Path contains '..' (path traversal)".into());
}
if path.contains('\0') {
return Err("Path contains null byte".into());
}
let blocked_prefixes = [
"/etc/", "/proc/", "/sys/", "/dev/", "/root/", "/boot/",
"/var/run/", "/var/log/", "/tmp/.", "/home/",
"C:\\Windows\\", "C:\\Users\\",
];
let normalized = path.replace('\\', "/").to_lowercase();
for prefix in &blocked_prefixes {
if normalized.starts_with(&prefix.to_lowercase()) {
return Err(format!("Access to '{}' is blocked", prefix));
}
}
let blocked_names = [
"passwd", "shadow", "id_rsa", "id_ed25519", "authorized_keys",
".ssh", ".env", ".git", "credentials", "secret", ".bash_history",
".pgpass", ".my.cnf", "wp-config.php",
];
let lower_path = path.to_lowercase();
for name in &blocked_names {
if lower_path.contains(name) {
return Err(format!(
"Path contains sensitive filename pattern: '{}'",
name
));
}
}
if !path.starts_with('/') && !path.starts_with("C:\\") && !path.starts_with("D:\\") {
return Err("File path must be absolute".into());
}
Ok(())
}
pub fn sanitize_error_message(err: &str) -> String {
let mut result = err.to_string();
result = redact_paths(&result);
result = redact_internal_ips(&result);
result = remove_stack_traces(&result);
if result.len() > 500 {
result.truncate(500);
result.push_str("...");
}
result
}
fn redact_paths(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut i = 0;
let bytes = s.as_bytes();
while i < bytes.len() {
if bytes[i] == b'/' && i + 1 < bytes.len() && (bytes[i + 1].is_ascii_alphanumeric() || bytes[i + 1] == b'.') {
let path_end = find_path_end(s, i);
if path_end > i + 3 && s[i..path_end].contains('/') && s[i..path_end].matches('/').count() >= 2 {
result.push_str("[path redacted]");
i = path_end;
continue;
}
}
result.push(bytes[i] as char);
i += 1;
}
result
}
fn find_path_end(s: &str, start: usize) -> usize {
let mut end = start;
for (i, ch) in s[start..].char_indices() {
if ch.is_whitespace() || ch == '\'' || ch == '"' || ch == ')' || ch == ']' {
return start + i;
}
end = start + i + ch.len_utf8();
}
end
}
fn redact_internal_ips(s: &str) -> String {
let mut result = s.to_string();
for prefix in &["10.", "172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
"172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
"172.30.", "172.31.", "192.168."] {
while let Some(pos) = result.find(prefix) {
let ip_end = result[pos..].find(|c: char| !c.is_ascii_digit() && c != '.').map(|i| pos + i).unwrap_or(result.len());
let ip_candidate = &result[pos..ip_end];
if ip_candidate.matches('.').count() >= 3 {
result.replace_range(pos..ip_end, "[internal-ip]");
} else {
break;
}
}
}
result
}
fn remove_stack_traces(s: &str) -> String {
let lines: Vec<&str> = s.lines().collect();
let mut result = Vec::new();
for line in lines {
let trimmed = line.trim();
if trimmed.starts_with("at ") && trimmed.contains(':') {
continue;
}
if trimmed.starts_with("thread '") && trimmed.contains("panicked at") {
result.push("Internal error occurred");
continue;
}
if trimmed.starts_with("stack backtrace:") {
break; }
result.push(line);
}
result.join("\n")
}
pub fn sanitize_header_value(value: &str) -> String {
value.replace('\r', "").replace('\n', "").replace('\0', "")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allows_valid_postgres_url() {
assert!(validate_connection_string("postgresql://user:pass@host:5432/db").is_ok());
}
#[test]
fn allows_valid_mysql_url() {
assert!(validate_connection_string("mysql://user:pass@host:3306/db").is_ok());
}
#[test]
fn blocks_shell_metacharacters() {
assert!(validate_connection_string("postgresql://host/db; rm -rf /").is_err());
assert!(validate_connection_string("postgresql://host/db`whoami`").is_err());
assert!(validate_connection_string("postgresql://host/$(cat /etc/passwd)").is_err());
}
#[test]
fn blocks_path_traversal() {
assert!(validate_file_path("/data/../etc/passwd").is_err());
assert!(validate_file_path("/data/../../root/.ssh/id_rsa").is_err());
}
#[test]
fn blocks_sensitive_paths() {
assert!(validate_file_path("/etc/passwd").is_err());
assert!(validate_file_path("/proc/self/environ").is_err());
assert!(validate_file_path("/root/.ssh/id_rsa").is_err());
}
#[test]
fn blocks_relative_paths() {
assert!(validate_file_path("relative/path/db.sqlite").is_err());
}
#[test]
fn allows_valid_sqlite_path() {
assert!(validate_file_path("/opt/data/sensors.db").is_ok());
assert!(validate_file_path("/var/lib/prometheus/datasets/imported.sqlite3").is_ok());
}
#[test]
fn sanitizes_error_messages() {
let raw = "Failed to connect to /opt/internal/db at 192.168.1.50:5432";
let sanitized = sanitize_error_message(raw);
assert!(!sanitized.contains("/opt/internal/db"), "Path should be redacted");
assert!(!sanitized.contains("192.168.1.50"), "Internal IP should be redacted");
}
#[test]
fn strips_header_injection() {
assert_eq!(sanitize_header_value("value\r\nX-Injected: true"), "valueX-Injected: true");
}
}