934858bebf
- Add repo_path parameter to cached_response and cached_vec_response functions - Implement structured cache key format with namespace, repo_path, and request proto - Replace global cache with Moka in-memory cache using weight-based eviction - Set 256MB memory cap with 10-minute TTL and 2-minute TTI policy - Add metrics collection for cache operations and evictions - Implement efficient repo-scoped invalidation using key structure - Add detailed documentation comments explaining cache architecture - Remove outdated dependencies and update dependency versions - Add error handling for encoding failures in cache operations - Optimize Vec responses with length-delimited encoding and pre-allocation
385 lines
12 KiB
Rust
385 lines
12 KiB
Rust
//! 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.
|
||
///
|
||
/// Git disallows: space, `~`, `^`, `:`, `?`, `*`, `[`, `\`, and all ASCII
|
||
/// control characters (bytes 0–31 and 127). The control characters are
|
||
/// checked separately via `is_ascii_control()`.
|
||
const FORBIDDEN_REF_CHARS: &[char] = &['~', '^', ':', '?', '*', '[', '\\', ' '];
|
||
|
||
/// Returns true if `c` is an ASCII control character (bytes 0–31, 127).
|
||
fn is_ascii_control(c: char) -> bool {
|
||
let b = c as u32;
|
||
b <= 31 || b == 127
|
||
}
|
||
|
||
/// 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_oid_hex(hex: &str) -> GitResult<()> {
|
||
if hex.is_empty() {
|
||
return Err(GitError::InvalidArgument("oid hex cannot be empty".into()));
|
||
}
|
||
if !(4..=64).contains(&hex.len()) {
|
||
return Err(GitError::InvalidArgument(format!(
|
||
"oid hex length must be 4..=64 chars: {}",
|
||
hex.len()
|
||
)));
|
||
}
|
||
if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||
return Err(GitError::InvalidArgument(format!(
|
||
"oid hex contains non-hex character: {hex}"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
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) || is_ascii_control(c)) {
|
||
return Err(GitError::InvalidArgument(format!(
|
||
"ref name contains forbidden character: {name}"
|
||
)));
|
||
}
|
||
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()));
|
||
}
|
||
if rev.len() > 256 {
|
||
return Err(GitError::InvalidArgument(format!(
|
||
"revision too long (max 256 chars): {}",
|
||
rev.len()
|
||
)));
|
||
}
|
||
if rev.chars().all(|c| c.is_ascii_hexdigit()) && rev.len() >= 4 && rev.len() <= 64 {
|
||
return Ok(());
|
||
}
|
||
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());
|
||
}
|
||
|
||
const MAX_ANCESTRY_DEPTH: u32 = 10000;
|
||
|
||
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
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
|
||
if let Some(caret_pos) = rev.rfind('^') {
|
||
let after_caret = &rev[caret_pos + 1..];
|
||
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
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut base = rev;
|
||
|
||
base = base
|
||
.trim_end_matches("^{tree}")
|
||
.trim_end_matches("^{commit}")
|
||
.trim_end_matches("^{object}");
|
||
|
||
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() {
|
||
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}"
|
||
)));
|
||
}
|
||
|
||
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",
|
||
];
|
||
|
||
for component in path.split('/') {
|
||
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(),
|
||
));
|
||
}
|
||
for pattern in DANGEROUS_CONFIG_KEYS {
|
||
if pattern.contains('*') {
|
||
// e.g. "remote.*.url" — match any "remote.<something>.url"
|
||
if let Some((prefix, suffix)) = pattern.split_once('*')
|
||
&& 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"
|
||
)));
|
||
}
|
||
}
|
||
if !key
|
||
.chars()
|
||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
|
||
{
|
||
return Err(GitError::InvalidArgument(format!(
|
||
"config key contains invalid characters: {key}"
|
||
)));
|
||
}
|
||
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(['$', '`', '(', ')', '{', '}', '|', ';', '&', '<', '>']) {
|
||
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.
|
||
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('\0') {
|
||
return Err(GitError::InvalidArgument(
|
||
"relative_path cannot contain null byte".into(),
|
||
));
|
||
}
|
||
if path.len() > 4096 {
|
||
return Err(GitError::InvalidArgument(
|
||
"relative_path too long (max 4096 chars)".into(),
|
||
));
|
||
}
|
||
if path.contains("..") {
|
||
return Err(GitError::InvalidArgument(format!(
|
||
"path traversal detected: relative_path contains '..': {path}"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|