From 1c227007694add3a490574d59a59ece09ba66f5c Mon Sep 17 00:00:00 2001 From: zhenyi <434836402@qq.com> Date: Wed, 10 Jun 2026 18:31:46 +0800 Subject: [PATCH] feat(sanitize): add remote URL and refspec validation Add validate_remote_url() to reject non-transport schemes (file://, ext::) and validate_refspec() to reject shell metacharacters in refspec strings. --- sanitize.rs | 83 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 65 insertions(+), 18 deletions(-) diff --git a/sanitize.rs b/sanitize.rs index 99d8f32..41c40c1 100644 --- a/sanitize.rs +++ b/sanitize.rs @@ -49,7 +49,6 @@ pub fn validate_ref_name(name: &str) -> GitResult<()> { "ref name contains forbidden character: {name}" ))); } - // Ref names must not exceed a reasonable length if name.len() > 255 { return Err(GitError::InvalidArgument(format!( "ref name too long (max 255 chars): {name}" @@ -66,18 +65,15 @@ pub fn validate_revision(rev: &str) -> GitResult<()> { if rev.is_empty() { return Err(GitError::InvalidArgument("revision cannot be empty".into())); } - // Prevent DoS via extremely long revision strings if rev.len() > 256 { return Err(GitError::InvalidArgument(format!( "revision too long (max 256 chars): {}", rev.len() ))); } - // Pure hex OID — always safe if rev.chars().all(|c| c.is_ascii_hexdigit()) && rev.len() >= 4 && rev.len() <= 64 { return Ok(()); } - // HEAD is always safe if rev == "HEAD" { return Ok(()); } @@ -86,10 +82,8 @@ pub fn validate_revision(rev: &str) -> GitResult<()> { return validate_ref_name(rest.trim()); } - // Validate ~N and ^N numeric suffixes to prevent DoS const MAX_ANCESTRY_DEPTH: u32 = 10000; - // Check for ~N syntax if let Some(tilde_pos) = rev.rfind('~') { let num_part = &rev[tilde_pos + 1..]; if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) { @@ -105,10 +99,8 @@ pub fn validate_revision(rev: &str) -> GitResult<()> { } } - // Check for ^N syntax (not ^{tree}) if let Some(caret_pos) = rev.rfind('^') { let after_caret = &rev[caret_pos + 1..]; - // Skip ^{tree} style operators if !after_caret.starts_with('{') && !after_caret.is_empty() && let Some(first_char) = after_caret.chars().next() @@ -132,17 +124,13 @@ pub fn validate_revision(rev: &str) -> GitResult<()> { } } - // Strip trailing operators and validate the base ref - // Only strip digits that are part of ~N or ^N patterns, not arbitrary trailing digits let mut base = rev; - // Strip ^{tree}, ^{commit}, ^{object} suffixes base = base .trim_end_matches("^{tree}") .trim_end_matches("^{commit}") .trim_end_matches("^{object}"); - // Strip ~N or ^N suffix if present if let Some(tilde_pos) = base.rfind('~') { let after_tilde = &base[tilde_pos + 1..]; if !after_tilde.is_empty() && after_tilde.chars().all(|c| c.is_ascii_digit()) { @@ -159,7 +147,6 @@ pub fn validate_revision(rev: &str) -> GitResult<()> { } if base.is_empty() { - // Pure operator like "^" — unlikely but not dangerous return Ok(()); } validate_ref_name(base)?; @@ -197,7 +184,6 @@ pub fn validate_file_path(path: &str) -> GitResult<()> { ))); } - // Prevent modification of .git directory if path == ".git" || path.starts_with(".git/") || path.contains("/.git/") @@ -216,9 +202,7 @@ pub fn validate_file_path(path: &str) -> GitResult<()> { "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", ]; - // Check each path component for component in path.split('/') { - // Get filename without extension let name_part = component.split('.').next().unwrap_or(component); let name_upper = name_part.to_uppercase(); @@ -256,7 +240,6 @@ pub fn validate_config_key(key: &str) -> GitResult<()> { "config key cannot be empty".into(), )); } - // Check for wildcard patterns like "remote.*.url" for pattern in DANGEROUS_CONFIG_KEYS { if pattern.contains('*') { // e.g. "remote.*.url" — match any "remote..url" @@ -272,7 +255,6 @@ pub fn validate_config_key(key: &str) -> GitResult<()> { ))); } } - // Config keys must be valid format: section.subsection.key if !key .chars() .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') @@ -284,6 +266,71 @@ pub fn validate_config_key(key: &str) -> GitResult<()> { Ok(()) } +/// Allowed URL schemes for git remotes. +const ALLOWED_REMOTE_SCHEMES: &[&str] = &[ + "http://", + "https://", + "ssh://", + "git://", + "git+ssh://", +]; + +/// Validate a remote URL for git operations. +/// +/// Only allows standard transport protocols. Rejects `file://`, `ext::`, +/// and other schemes that could access local resources or execute commands. +pub fn validate_remote_url(url: &str) -> GitResult<()> { + if url.is_empty() { + return Err(GitError::InvalidArgument( + "remote URL cannot be empty".into(), + )); + } + if url.len() > 4096 { + return Err(GitError::InvalidArgument( + "remote URL too long (max 4096 chars)".into(), + )); + } + if url.contains('\0') || url.contains('\n') || url.contains('\r') { + return Err(GitError::InvalidArgument( + "remote URL contains invalid characters".into(), + )); + } + if !ALLOWED_REMOTE_SCHEMES.iter().any(|s| url.starts_with(s)) { + return Err(GitError::InvalidArgument(format!( + "remote URL must start with one of: {}. Got: {url}", + ALLOWED_REMOTE_SCHEMES.join(", ") + ))); + } + Ok(()) +} + +/// Validate a git refspec string (e.g. `+refs/heads/*:refs/heads/*`). +/// +/// Refspecs must not contain null bytes, newlines, or shell metacharacters. +pub fn validate_refspec(refspec: &str) -> GitResult<()> { + if refspec.is_empty() { + return Err(GitError::InvalidArgument( + "refspec cannot be empty".into(), + )); + } + if refspec.contains('\0') || refspec.contains('\n') || refspec.contains('\r') { + return Err(GitError::InvalidArgument( + "refspec contains invalid characters".into(), + )); + } + if refspec.contains(|c: char| matches!(c, '$' | '`' | '(' | ')' | '{' | '}' | '|' | ';' | '&' | '<' | '>')) { + return Err(GitError::InvalidArgument(format!( + "refspec contains shell metacharacter: {refspec}" + ))); + } + if refspec.len() > 1024 { + return Err(GitError::InvalidArgument( + "refspec too long (max 1024 chars)".into(), + )); + } + Ok(()) +} + /// Validate a storage-relative path (used in resolve_for_init and from_repository_header). /// /// Must not contain path traversal, must be a simple relative path.