diff --git a/Cargo.lock b/Cargo.lock
index 64cfac3..9f8bc3c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1931,7 +1931,7 @@ dependencies = [
[[package]]
name = "linux-patch-api"
-version = "1.3.1"
+version = "1.3.2"
dependencies = [
"actix",
"actix-rt",
diff --git a/src/api/handlers/packages.rs b/src/api/handlers/packages.rs
index e164ca7..bc5b575 100644
--- a/src/api/handlers/packages.rs
+++ b/src/api/handlers/packages.rs
@@ -14,29 +14,18 @@ use tracing::{error, info, warn};
use uuid::Uuid;
use crate::jobs::manager::{JobManager, JobOperation, JobStatus};
-use crate::packages::{InstallOptions, Package, PackageManagerBackend, PackageSpec};
+use crate::packages::{
+ validate_package_name, validate_version_string, InstallOptions, Package, PackageManagerBackend,
+ PackageSpec,
+};
-/// Maximum allowed length for package names
-const MAX_PACKAGE_NAME_LENGTH: usize = 256;
-
-/// Validate package name: must not be empty and must not exceed max length
-fn validate_package_name(name: &str) -> Result<(), String> {
- if name.is_empty() {
- return Err("Package name cannot be empty".to_string());
- }
- if name.len() > MAX_PACKAGE_NAME_LENGTH {
- return Err(format!(
- "Package name exceeds maximum length of {} characters",
- MAX_PACKAGE_NAME_LENGTH
- ));
- }
- Ok(())
-}
-
-/// Validate all package names in a request
+/// Validate all package names and versions in a request
fn validate_package_names(packages: &[PackageSpec]) -> Result<(), String> {
for pkg in packages {
validate_package_name(&pkg.name)?;
+ if let Some(version) = &pkg.version {
+ validate_version_string(version)?;
+ }
}
Ok(())
}
diff --git a/src/api/handlers/patches.rs b/src/api/handlers/patches.rs
index 41b2c75..1b9a68b 100644
--- a/src/api/handlers/patches.rs
+++ b/src/api/handlers/patches.rs
@@ -11,7 +11,7 @@ use tracing::{error, info};
use uuid::Uuid;
use crate::jobs::manager::{JobManager, JobOperation, JobStatus};
-use crate::packages::PackageManagerBackend;
+use crate::packages::{validate_package_name, PackageManagerBackend};
use super::packages::{ApiResponse, JobResponseData};
@@ -88,6 +88,16 @@ pub async fn apply_patches(
let _timestamp = Utc::now().to_rfc3339();
let packages_count = body.packages.as_ref().map(|p| p.len()).unwrap_or(0);
+ // SECURITY: Validate all package names in the request to prevent argument injection
+ if let Some(ref pkgs) = body.packages {
+ for pkg in pkgs {
+ if let Err(e) = validate_package_name(pkg) {
+ let response = ApiResponse::<()>::error("VALIDATION_ERROR", &e, None, false);
+ return HttpResponse::BadRequest().json(response);
+ }
+ }
+ }
+
info!(
request_id = %request_id,
packages = ?body.packages,
diff --git a/src/packages/mod.rs b/src/packages/mod.rs
index 2726b9d..b63481a 100644
--- a/src/packages/mod.rs
+++ b/src/packages/mod.rs
@@ -12,6 +12,112 @@ use serde::{Deserialize, Serialize};
use std::process::Command;
use tracing::info;
+/// Maximum allowed length for package names and version strings
+pub const MAX_NAME_LENGTH: usize = 256;
+
+/// Validate a package name against a strict allowlist pattern.
+/// Prevents argument injection by blocking shell metacharacters,
+/// path separators, whitespace, and leading hyphens.
+/// Pattern: ^[a-zA-Z0-9][a-zA-Z0-9+._-]*$
+pub fn validate_package_name(name: &str) -> Result<(), String> {
+ if name.is_empty() {
+ return Err("Package name cannot be empty".to_string());
+ }
+ if name.len() > MAX_NAME_LENGTH {
+ return Err(format!(
+ "Package name exceeds maximum length of {} characters",
+ MAX_NAME_LENGTH
+ ));
+ }
+ let bytes = name.as_bytes();
+ if !bytes[0].is_ascii_alphanumeric() {
+ return Err(format!(
+ "Package name must start with an alphanumeric character: '{}'",
+ name
+ ));
+ }
+ if !name
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '_' || c == '-')
+ {
+ return Err(format!(
+ "Package name contains invalid characters: '{}'. Only alphanumeric, plus, dot, underscore, and hyphen are allowed",
+ name
+ ));
+ }
+ Ok(())
+}
+
+/// Validate a version string against a strict allowlist pattern.
+/// Allows characters commonly found in package versions (colons for RPM epochs,
+/// tildes for version ordering) while blocking shell metacharacters and path separators.
+/// Pattern: ^[a-zA-Z0-9][a-zA-Z0-9+.:~_-]*$
+pub fn validate_version_string(version: &str) -> Result<(), String> {
+ if version.is_empty() {
+ return Err("Version string cannot be empty".to_string());
+ }
+ if version.len() > MAX_NAME_LENGTH {
+ return Err(format!(
+ "Version string exceeds maximum length of {} characters",
+ MAX_NAME_LENGTH
+ ));
+ }
+ let bytes = version.as_bytes();
+ if !bytes[0].is_ascii_alphanumeric() {
+ return Err(format!(
+ "Version string must start with an alphanumeric character: '{}'",
+ version
+ ));
+ }
+ if !version.chars().all(|c| {
+ c.is_ascii_alphanumeric()
+ || c == '+'
+ || c == '.'
+ || c == '_'
+ || c == '-'
+ || c == ':'
+ || c == '~'
+ }) {
+ return Err(format!(
+ "Version string contains invalid characters: '{}'. Only alphanumeric, plus, dot, underscore, hyphen, colon, and tilde are allowed",
+ version
+ ));
+ }
+ Ok(())
+}
+
+/// Validate a service name against a strict allowlist pattern.
+/// Prevents shell injection and argument injection in systemctl/rc-service commands.
+/// Allows hyphens (common in systemd unit names), dots for unit suffixes, and @ for template instances.
+/// Pattern: ^[a-zA-Z0-9][a-zA-Z0-9_.@+-]*$
+pub fn validate_service_name(name: &str) -> Result<(), String> {
+ if name.is_empty() {
+ return Err("Service name cannot be empty".to_string());
+ }
+ if name.len() > MAX_NAME_LENGTH {
+ return Err(format!(
+ "Service name exceeds maximum length of {} characters",
+ MAX_NAME_LENGTH
+ ));
+ }
+ let bytes = name.as_bytes();
+ if !bytes[0].is_ascii_alphanumeric() {
+ return Err(format!(
+ "Service name must start with an alphanumeric character: '{}'",
+ name
+ ));
+ }
+ if !name.chars().all(|c| {
+ c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '@' || c == '+' || c == '-'
+ }) {
+ return Err(format!(
+ "Service name contains invalid characters: '{}'. Only alphanumeric, underscore, dot, at-sign, plus, and hyphen are allowed",
+ name
+ ));
+ }
+ Ok(())
+}
+
/// Package status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum PackageStatus {
@@ -311,10 +417,20 @@ impl PackageManagerBackend for AptBackend {
args.push("--no-install-recommends".to_string());
}
+ // SECURITY: --force-yes bypasses GPG signature verification and is a security risk.
+ // Only allow when explicitly requested; log a warning.
if options.force {
+ tracing::warn!(
+ "--force-yes requested: package signature verification will be bypassed"
+ );
args.push("--force-yes".to_string());
}
+ // SECURITY: Insert -- separator before user-supplied package names to prevent
+ // argument injection. Without this, a package name like "--allow-unauthenticated"
+ // would be interpreted as an apt option rather than a package name.
+ args.push("--".to_string());
+
for pkg in packages {
let pkg_arg = if let Some(version) = &pkg.version {
format!("{}={}", pkg.name, version)
@@ -334,16 +450,18 @@ impl PackageManagerBackend for AptBackend {
}
fn update_package(&self, name: &str) -> Result<()> {
- self.run_apt(&["install", "-y", "--only-upgrade", name])?;
+ // SECURITY: -- separator prevents argument injection via package name
+ self.run_apt(&["install", "-y", "--only-upgrade", "--", name])?;
info!("Updated package: {}", name);
Ok(())
}
fn remove_package(&self, name: &str, purge: bool) -> Result<()> {
+ // SECURITY: -- separator prevents argument injection via package name
let args = if purge {
- vec!["purge", "-y", name]
+ vec!["purge", "-y", "--", name]
} else {
- vec!["remove", "-y", name]
+ vec!["remove", "-y", "--", name]
};
self.run_apt(&args)?;
@@ -392,7 +510,8 @@ impl PackageManagerBackend for AptBackend {
fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> {
let args = match packages {
Some(pkgs) => {
- let mut a = vec!["install", "-y"];
+ // SECURITY: -- separator prevents argument injection via package names
+ let mut a: Vec<&str> = vec!["install", "-y", "--"];
for pkg in pkgs {
a.push(pkg);
}
@@ -506,10 +625,8 @@ impl PackageManagerBackend for AptBackend {
}
fn get_service_status(&self, name: &str) -> Result