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.
This commit is contained in:
+65
-18
@@ -49,7 +49,6 @@ pub fn validate_ref_name(name: &str) -> GitResult<()> {
|
|||||||
"ref name contains forbidden character: {name}"
|
"ref name contains forbidden character: {name}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
// Ref names must not exceed a reasonable length
|
|
||||||
if name.len() > 255 {
|
if name.len() > 255 {
|
||||||
return Err(GitError::InvalidArgument(format!(
|
return Err(GitError::InvalidArgument(format!(
|
||||||
"ref name too long (max 255 chars): {name}"
|
"ref name too long (max 255 chars): {name}"
|
||||||
@@ -66,18 +65,15 @@ pub fn validate_revision(rev: &str) -> GitResult<()> {
|
|||||||
if rev.is_empty() {
|
if rev.is_empty() {
|
||||||
return Err(GitError::InvalidArgument("revision cannot be empty".into()));
|
return Err(GitError::InvalidArgument("revision cannot be empty".into()));
|
||||||
}
|
}
|
||||||
// Prevent DoS via extremely long revision strings
|
|
||||||
if rev.len() > 256 {
|
if rev.len() > 256 {
|
||||||
return Err(GitError::InvalidArgument(format!(
|
return Err(GitError::InvalidArgument(format!(
|
||||||
"revision too long (max 256 chars): {}",
|
"revision too long (max 256 chars): {}",
|
||||||
rev.len()
|
rev.len()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
// Pure hex OID — always safe
|
|
||||||
if rev.chars().all(|c| c.is_ascii_hexdigit()) && rev.len() >= 4 && rev.len() <= 64 {
|
if rev.chars().all(|c| c.is_ascii_hexdigit()) && rev.len() >= 4 && rev.len() <= 64 {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// HEAD is always safe
|
|
||||||
if rev == "HEAD" {
|
if rev == "HEAD" {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -86,10 +82,8 @@ pub fn validate_revision(rev: &str) -> GitResult<()> {
|
|||||||
return validate_ref_name(rest.trim());
|
return validate_ref_name(rest.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate ~N and ^N numeric suffixes to prevent DoS
|
|
||||||
const MAX_ANCESTRY_DEPTH: u32 = 10000;
|
const MAX_ANCESTRY_DEPTH: u32 = 10000;
|
||||||
|
|
||||||
// Check for ~N syntax
|
|
||||||
if let Some(tilde_pos) = rev.rfind('~') {
|
if let Some(tilde_pos) = rev.rfind('~') {
|
||||||
let num_part = &rev[tilde_pos + 1..];
|
let num_part = &rev[tilde_pos + 1..];
|
||||||
if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) {
|
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('^') {
|
if let Some(caret_pos) = rev.rfind('^') {
|
||||||
let after_caret = &rev[caret_pos + 1..];
|
let after_caret = &rev[caret_pos + 1..];
|
||||||
// Skip ^{tree} style operators
|
|
||||||
if !after_caret.starts_with('{')
|
if !after_caret.starts_with('{')
|
||||||
&& !after_caret.is_empty()
|
&& !after_caret.is_empty()
|
||||||
&& let Some(first_char) = after_caret.chars().next()
|
&& 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;
|
let mut base = rev;
|
||||||
|
|
||||||
// Strip ^{tree}, ^{commit}, ^{object} suffixes
|
|
||||||
base = base
|
base = base
|
||||||
.trim_end_matches("^{tree}")
|
.trim_end_matches("^{tree}")
|
||||||
.trim_end_matches("^{commit}")
|
.trim_end_matches("^{commit}")
|
||||||
.trim_end_matches("^{object}");
|
.trim_end_matches("^{object}");
|
||||||
|
|
||||||
// Strip ~N or ^N suffix if present
|
|
||||||
if let Some(tilde_pos) = base.rfind('~') {
|
if let Some(tilde_pos) = base.rfind('~') {
|
||||||
let after_tilde = &base[tilde_pos + 1..];
|
let after_tilde = &base[tilde_pos + 1..];
|
||||||
if !after_tilde.is_empty() && after_tilde.chars().all(|c| c.is_ascii_digit()) {
|
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() {
|
if base.is_empty() {
|
||||||
// Pure operator like "^" — unlikely but not dangerous
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
validate_ref_name(base)?;
|
validate_ref_name(base)?;
|
||||||
@@ -197,7 +184,6 @@ pub fn validate_file_path(path: &str) -> GitResult<()> {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent modification of .git directory
|
|
||||||
if path == ".git"
|
if path == ".git"
|
||||||
|| path.starts_with(".git/")
|
|| path.starts_with(".git/")
|
||||||
|| path.contains("/.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",
|
"COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Check each path component
|
|
||||||
for component in path.split('/') {
|
for component in path.split('/') {
|
||||||
// Get filename without extension
|
|
||||||
let name_part = component.split('.').next().unwrap_or(component);
|
let name_part = component.split('.').next().unwrap_or(component);
|
||||||
let name_upper = name_part.to_uppercase();
|
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(),
|
"config key cannot be empty".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Check for wildcard patterns like "remote.*.url"
|
|
||||||
for pattern in DANGEROUS_CONFIG_KEYS {
|
for pattern in DANGEROUS_CONFIG_KEYS {
|
||||||
if pattern.contains('*') {
|
if pattern.contains('*') {
|
||||||
// e.g. "remote.*.url" — match any "remote.<something>.url"
|
// e.g. "remote.*.url" — match any "remote.<something>.url"
|
||||||
@@ -272,7 +255,6 @@ pub fn validate_config_key(key: &str) -> GitResult<()> {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Config keys must be valid format: section.subsection.key
|
|
||||||
if !key
|
if !key
|
||||||
.chars()
|
.chars()
|
||||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
|
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
|
||||||
@@ -284,6 +266,71 @@ pub fn validate_config_key(key: &str) -> GitResult<()> {
|
|||||||
Ok(())
|
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).
|
/// 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.
|
/// Must not contain path traversal, must be a simple relative path.
|
||||||
|
|||||||
Reference in New Issue
Block a user