refactor(server): replace custom remote clients with macro-based implementation
- Replaced manual remote client functions with remote_client! macro for archive, blame, branch, commit, and diff services - Simplified remote client creation logic using declarative macro approach - Maintained same functionality while reducing code duplication across services security(bare): enhance path traversal protection with comprehensive validation - Added early relative_path validation to prevent path traversal attacks - Implemented unified path validation to avoid TOCTOU race conditions - Enhanced canonicalization checks for both existing and non-existent paths - Added detailed logging for path traversal detection attempts feat(cache): migrate from CLruCache to Moka with TTL and invalidation support - Replaced clru dependency with moka for improved caching capabilities - Added 300-second time-to-live for cache entries - Implemented repository-specific cache invalidation mechanism - Enhanced cache operations with thread-safe async support refactor(commit): improve security validation for commit operations - Added ref name validation to prevent command injection in cherry_pick_commit - Implemented revision validation for commit selectors - Added comprehensive input validation for create_commit parameters - Enhanced file path validation to prevent traversal
This commit is contained in:
+307
@@ -0,0 +1,307 @@
|
||||
//! Input sanitization for git subprocess arguments.
|
||||
//!
|
||||
//! Prevents command injection by validating user-supplied strings before
|
||||
//! passing them to git commands.
|
||||
|
||||
use crate::error::GitError;
|
||||
use crate::error::GitResult;
|
||||
|
||||
/// Characters that are never allowed in git ref names / revision strings.
|
||||
const FORBIDDEN_REF_CHARS: &[char] = &[
|
||||
'~', '^', ':', '?', '*', '[', '\\', ' ', '\n', '\r', '\t', '\0',
|
||||
];
|
||||
|
||||
/// Validate a git reference name (branch, tag, etc.).
|
||||
///
|
||||
/// Git ref rules (from `git check-ref-format`):
|
||||
/// - Cannot contain forbidden chars
|
||||
/// - Cannot start or end with '.'
|
||||
/// - Cannot end with '/'
|
||||
/// - Cannot contain '..'
|
||||
/// - Cannot contain '@{'
|
||||
/// - Cannot be empty
|
||||
pub fn validate_ref_name(name: &str) -> GitResult<()> {
|
||||
if name.is_empty() {
|
||||
return Err(GitError::InvalidArgument("ref name cannot be empty".into()));
|
||||
}
|
||||
if name.starts_with('.') || name.ends_with('.') {
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"ref name cannot start or end with '.': {name}"
|
||||
)));
|
||||
}
|
||||
if name.ends_with('/') {
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"ref name cannot end with '/': {name}"
|
||||
)));
|
||||
}
|
||||
if name.contains("..") {
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"ref name cannot contain '..': {name}"
|
||||
)));
|
||||
}
|
||||
if name.contains("@{") {
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"ref name cannot contain '@{{': {name}"
|
||||
)));
|
||||
}
|
||||
if name.contains(|c: char| FORBIDDEN_REF_CHARS.contains(&c)) {
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"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}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a revision string (branch name, tag name, or short expression).
|
||||
///
|
||||
/// Allows OID hex strings, ref names, and a small set of revision operators
|
||||
/// (HEAD, ^{tree}, ~N, ^N) that are safe when passed as a single argument.
|
||||
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(());
|
||||
}
|
||||
// Allow ref:refs/heads/... (git internal format)
|
||||
if let Some(rest) = rev.strip_prefix("ref:") {
|
||||
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()) {
|
||||
let depth: u32 = num_part
|
||||
.parse()
|
||||
.map_err(|_| GitError::InvalidArgument("invalid ~N syntax".into()))?;
|
||||
if depth > MAX_ANCESTRY_DEPTH {
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"~N depth too large: {} (max {})",
|
||||
depth, MAX_ANCESTRY_DEPTH
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
&& first_char.is_ascii_digit()
|
||||
{
|
||||
let num_part: String = after_caret
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect();
|
||||
if !num_part.is_empty() {
|
||||
let depth: u32 = num_part
|
||||
.parse()
|
||||
.map_err(|_| GitError::InvalidArgument("invalid ^N syntax".into()))?;
|
||||
if depth > MAX_ANCESTRY_DEPTH {
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"^N depth too large: {} (max {})",
|
||||
depth, MAX_ANCESTRY_DEPTH
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
base = &base[..tilde_pos];
|
||||
}
|
||||
} else if let Some(caret_pos) = base.rfind('^') {
|
||||
let after_caret = &base[caret_pos + 1..];
|
||||
if !after_caret.starts_with('{')
|
||||
&& !after_caret.is_empty()
|
||||
&& after_caret.chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
base = &base[..caret_pos];
|
||||
}
|
||||
}
|
||||
|
||||
if base.is_empty() {
|
||||
// Pure operator like "^" — unlikely but not dangerous
|
||||
return Ok(());
|
||||
}
|
||||
validate_ref_name(base)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a file path within a commit action.
|
||||
///
|
||||
/// Must be a relative path (no leading '/'), no '..' traversal,
|
||||
/// no null bytes, no .git directory access, and reasonable length.
|
||||
pub fn validate_file_path(path: &str) -> GitResult<()> {
|
||||
if path.is_empty() {
|
||||
return Err(GitError::InvalidArgument(
|
||||
"file path cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if path.starts_with('/') {
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"file path must be relative, not absolute: {path}"
|
||||
)));
|
||||
}
|
||||
if path.contains("..") {
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"file path cannot contain '..': {path}"
|
||||
)));
|
||||
}
|
||||
if path.contains('\0') {
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"file path cannot contain null byte: {path}"
|
||||
)));
|
||||
}
|
||||
if path.len() > 4096 {
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"file path too long (max 4096 chars): {path}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Prevent modification of .git directory
|
||||
if path == ".git"
|
||||
|| path.starts_with(".git/")
|
||||
|| path.contains("/.git/")
|
||||
|| path.ends_with("/.git")
|
||||
{
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"cannot modify .git directory: {path}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Windows reserved names check
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
const RESERVED_NAMES: &[&str] = &[
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
|
||||
"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();
|
||||
|
||||
if RESERVED_NAMES.contains(&name_upper.as_str()) {
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"Windows reserved device name: {component}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Git config keys that are dangerous to set remotely.
|
||||
/// Setting these could allow arbitrary command execution or bypass security.
|
||||
const DANGEROUS_CONFIG_KEYS: &[&str] = &[
|
||||
"core.sshCommand",
|
||||
"core.gitProxy",
|
||||
"http.proxy",
|
||||
"https.proxy",
|
||||
"remote.*.url",
|
||||
"credential.*",
|
||||
"safe.directory",
|
||||
"core.hooksPath",
|
||||
"receive.fsckObjects",
|
||||
"receive.denyCurrentBranch",
|
||||
"receive.denyDeleteCurrent",
|
||||
];
|
||||
|
||||
/// Check if a git config key is safe to set remotely.
|
||||
pub fn validate_config_key(key: &str) -> GitResult<()> {
|
||||
if key.is_empty() {
|
||||
return Err(GitError::InvalidArgument(
|
||||
"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.<something>.url"
|
||||
let (prefix, suffix) = pattern.split_once('*').unwrap();
|
||||
if key.starts_with(prefix) && key.ends_with(suffix) {
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"config key '{key}' matches dangerous pattern '{pattern}'"
|
||||
)));
|
||||
}
|
||||
} else if key == *pattern {
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"config key '{key}' is not allowed to be set remotely"
|
||||
)));
|
||||
}
|
||||
}
|
||||
// Config keys must be valid format: section.subsection.key
|
||||
if !key
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
|
||||
{
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"config key contains invalid characters: {key}"
|
||||
)));
|
||||
}
|
||||
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.
|
||||
pub fn validate_relative_path(path: &str) -> GitResult<()> {
|
||||
if path.is_empty() {
|
||||
return Err(GitError::InvalidArgument(
|
||||
"relative_path cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if path.starts_with('/') {
|
||||
return Err(GitError::InvalidArgument(
|
||||
"relative_path must be relative, not absolute".into(),
|
||||
));
|
||||
}
|
||||
if path.contains("..") {
|
||||
return Err(GitError::InvalidArgument(format!(
|
||||
"path traversal detected: relative_path contains '..': {path}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user