Files
gitks/sanitize.rs
T
zhenyi 1c22700769 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.
2026-06-10 18:31:46 +08:00

355 lines
11 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.
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}"
)));
}
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"
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"
)));
}
}
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(|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.
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(())
}