addressing some semgrep issues
This commit is contained in:
@@ -73,6 +73,7 @@ regex = { workspace = true }
|
||||
|
||||
# Version matching
|
||||
semver = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
mockall = { workspace = true }
|
||||
|
||||
@@ -658,6 +658,11 @@ pub struct PackRegistryConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub verify_checksums: bool,
|
||||
|
||||
/// Additional remote hosts allowed for pack archive/git downloads.
|
||||
/// Hosts from enabled registry indices are implicitly allowed.
|
||||
#[serde(default)]
|
||||
pub allowed_source_hosts: Vec<String>,
|
||||
|
||||
/// Allow HTTP (non-HTTPS) registries
|
||||
#[serde(default)]
|
||||
pub allow_http: bool,
|
||||
@@ -680,6 +685,7 @@ impl Default for PackRegistryConfig {
|
||||
cache_enabled: true,
|
||||
timeout: default_registry_timeout(),
|
||||
verify_checksums: true,
|
||||
allowed_source_hosts: Vec::new(),
|
||||
allow_http: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::models::Runtime;
|
||||
use crate::repositories::action::ActionRepository;
|
||||
use crate::repositories::runtime::{self, RuntimeRepository};
|
||||
use crate::repositories::FindById as _;
|
||||
use regex::Regex;
|
||||
use serde_json::Value as JsonValue;
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -94,10 +95,7 @@ pub struct PackEnvironmentManager {
|
||||
impl PackEnvironmentManager {
|
||||
/// Create a new pack environment manager
|
||||
pub fn new(pool: PgPool, config: &Config) -> Self {
|
||||
let base_path = PathBuf::from(&config.packs_base_dir)
|
||||
.parent()
|
||||
.map(|p| p.join("packenvs"))
|
||||
.unwrap_or_else(|| PathBuf::from("/opt/attune/packenvs"));
|
||||
let base_path = PathBuf::from(&config.runtime_envs_dir);
|
||||
|
||||
Self { pool, base_path }
|
||||
}
|
||||
@@ -399,19 +397,19 @@ impl PackEnvironmentManager {
|
||||
}
|
||||
|
||||
fn calculate_env_path(&self, pack_ref: &str, runtime: &Runtime) -> Result<PathBuf> {
|
||||
let runtime_name_lower = runtime.name.to_lowercase();
|
||||
let template = runtime
|
||||
.installers
|
||||
.get("base_path_template")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("/opt/attune/packenvs/{pack_ref}/{runtime_name_lower}");
|
||||
.unwrap_or("{pack_ref}/{runtime_name_lower}");
|
||||
|
||||
let runtime_name_lower = runtime.name.to_lowercase();
|
||||
let path_str = template
|
||||
.replace("{pack_ref}", pack_ref)
|
||||
.replace("{runtime_ref}", &runtime.r#ref)
|
||||
.replace("{runtime_name_lower}", &runtime_name_lower);
|
||||
|
||||
Ok(PathBuf::from(path_str))
|
||||
resolve_env_path(&self.base_path, &path_str)
|
||||
}
|
||||
|
||||
async fn upsert_environment_record(
|
||||
@@ -528,6 +526,7 @@ impl PackEnvironmentManager {
|
||||
let mut install_log = String::new();
|
||||
|
||||
// Create environment directory
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- env_path comes from validated runtime-env path construction under runtime_envs_dir.
|
||||
let env_path = PathBuf::from(&pack_env.env_path);
|
||||
if env_path.exists() {
|
||||
warn!(
|
||||
@@ -659,6 +658,8 @@ impl PackEnvironmentManager {
|
||||
env_path,
|
||||
&pack_path_str,
|
||||
)?;
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The candidate command path is validated and confined before any execution is attempted.
|
||||
let command = validate_installer_command(&command, pack_path, Path::new(env_path))?;
|
||||
|
||||
let args_template = installer
|
||||
.get("args")
|
||||
@@ -680,12 +681,17 @@ impl PackEnvironmentManager {
|
||||
|
||||
let cwd_template = installer.get("cwd").and_then(|v| v.as_str());
|
||||
let cwd = if let Some(cwd_t) = cwd_template {
|
||||
Some(self.resolve_template(
|
||||
cwd_t,
|
||||
pack_ref,
|
||||
runtime_ref,
|
||||
env_path,
|
||||
&pack_path_str,
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Installer cwd values are validated to stay under the pack root or environment directory.
|
||||
Some(validate_installer_path(
|
||||
&self.resolve_template(
|
||||
cwd_t,
|
||||
pack_ref,
|
||||
runtime_ref,
|
||||
env_path,
|
||||
&pack_path_str,
|
||||
)?,
|
||||
pack_path,
|
||||
Path::new(env_path),
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
@@ -763,6 +769,7 @@ impl PackEnvironmentManager {
|
||||
async fn execute_installer_action(&self, action: &InstallerAction) -> Result<String> {
|
||||
debug!("Executing: {} {:?}", action.command, action.args);
|
||||
|
||||
// nosemgrep: rust.actix.command-injection.rust-actix-command-injection.rust-actix-command-injection -- action.command is accepted only after strict validation of executable shape and allowed path roots.
|
||||
let mut cmd = Command::new(&action.command);
|
||||
cmd.args(&action.args);
|
||||
|
||||
@@ -800,7 +807,9 @@ impl PackEnvironmentManager {
|
||||
// Check file_exists condition
|
||||
if let Some(file_path_template) = condition.get("file_exists").and_then(|v| v.as_str()) {
|
||||
let file_path = file_path_template.replace("{pack_path}", &pack_path.to_string_lossy());
|
||||
return Ok(PathBuf::from(file_path).exists());
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Conditional file checks are validated to stay under trusted pack/environment roots before filesystem access.
|
||||
let validated = validate_installer_path(&file_path, pack_path, &self.base_path)?;
|
||||
return Ok(PathBuf::from(validated).exists());
|
||||
}
|
||||
|
||||
// Default: condition is true
|
||||
@@ -816,6 +825,93 @@ impl PackEnvironmentManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_env_path(base_path: &Path, path_str: &str) -> Result<PathBuf> {
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- This helper normalizes env paths and preserves legacy absolute templates while still rejecting parent traversal.
|
||||
let raw_path = Path::new(path_str);
|
||||
if raw_path.is_absolute() {
|
||||
return normalize_relative_or_absolute_path(raw_path);
|
||||
}
|
||||
|
||||
let joined = base_path.join(raw_path);
|
||||
normalize_relative_or_absolute_path(&joined)
|
||||
}
|
||||
|
||||
fn normalize_relative_or_absolute_path(path: &Path) -> Result<PathBuf> {
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
|
||||
std::path::Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR.to_string()),
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir => {
|
||||
return Err(Error::validation(format!(
|
||||
"Parent-directory traversal is not allowed in installer paths: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
std::path::Component::Normal(part) => normalized.push(part),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn validate_installer_command(command: &str, pack_path: &Path, env_path: &Path) -> Result<String> {
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Command validation inspects the path form before enforcing allowed executable rules.
|
||||
let command_path = Path::new(command);
|
||||
if command_path.is_absolute() {
|
||||
return validate_installer_path(command, pack_path, env_path);
|
||||
}
|
||||
|
||||
if command.contains(std::path::MAIN_SEPARATOR) {
|
||||
return Err(Error::validation(format!(
|
||||
"Installer command must be a bare executable name or an allowed absolute path: {}",
|
||||
command
|
||||
)));
|
||||
}
|
||||
|
||||
let command_name_re = Regex::new(r"^[A-Za-z0-9._+-]+$").expect("valid installer regex");
|
||||
if !command_name_re.is_match(command) {
|
||||
return Err(Error::validation(format!(
|
||||
"Installer command contains invalid characters: {}",
|
||||
command
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(command.to_string())
|
||||
}
|
||||
|
||||
fn validate_installer_path(path_str: &str, pack_path: &Path, env_path: &Path) -> Result<String> {
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Path validation normalizes candidate installer paths before enforcing root confinement.
|
||||
let path = normalize_path(Path::new(path_str));
|
||||
let normalized_pack_path = normalize_path(pack_path);
|
||||
let normalized_env_path = normalize_path(env_path);
|
||||
if path.starts_with(&normalized_pack_path) || path.starts_with(&normalized_env_path) {
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
} else {
|
||||
Err(Error::validation(format!(
|
||||
"Installer path must remain under the pack or environment directory: {}",
|
||||
path_str
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_path(path: &Path) -> PathBuf {
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
|
||||
std::path::Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR.to_string()),
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir => {
|
||||
normalized.pop();
|
||||
}
|
||||
std::path::Component::Normal(part) => normalized.push(part),
|
||||
}
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
/// Collect the lowercase runtime names that require environment setup for a pack.
|
||||
///
|
||||
/// This queries the pack's actions, resolves their runtimes, and returns the names
|
||||
|
||||
@@ -349,6 +349,7 @@ mod tests {
|
||||
cache_enabled: true,
|
||||
timeout: 120,
|
||||
verify_checksums: true,
|
||||
allowed_source_hosts: Vec::new(),
|
||||
allow_http: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -11,10 +11,14 @@
|
||||
use super::{Checksum, InstallSource, PackIndexEntry, RegistryClient};
|
||||
use crate::config::PackRegistryConfig;
|
||||
use crate::error::{Error, Result};
|
||||
use std::collections::HashSet;
|
||||
use std::net::{IpAddr, Ipv6Addr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
use tokio::net::lookup_host;
|
||||
use tokio::process::Command;
|
||||
use url::Url;
|
||||
|
||||
/// Progress callback type
|
||||
pub type ProgressCallback = Arc<dyn Fn(ProgressEvent) + Send + Sync>;
|
||||
@@ -53,6 +57,12 @@ pub struct PackInstaller {
|
||||
/// Whether to verify checksums
|
||||
verify_checksums: bool,
|
||||
|
||||
/// Whether HTTP remote sources are allowed
|
||||
allow_http: bool,
|
||||
|
||||
/// Remote hosts allowed for archive/git installs
|
||||
allowed_remote_hosts: Option<HashSet<String>>,
|
||||
|
||||
/// Progress callback (optional)
|
||||
progress_callback: Option<ProgressCallback>,
|
||||
}
|
||||
@@ -106,17 +116,32 @@ impl PackInstaller {
|
||||
.await
|
||||
.map_err(|e| Error::internal(format!("Failed to create temp directory: {}", e)))?;
|
||||
|
||||
let (registry_client, verify_checksums) = if let Some(config) = registry_config {
|
||||
let verify_checksums = config.verify_checksums;
|
||||
(Some(RegistryClient::new(config)?), verify_checksums)
|
||||
} else {
|
||||
(None, false)
|
||||
};
|
||||
let (registry_client, verify_checksums, allow_http, allowed_remote_hosts) =
|
||||
if let Some(config) = registry_config {
|
||||
let verify_checksums = config.verify_checksums;
|
||||
let allow_http = config.allow_http;
|
||||
let allowed_remote_hosts = collect_allowed_remote_hosts(&config)?;
|
||||
let allowed_remote_hosts = if allowed_remote_hosts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(allowed_remote_hosts)
|
||||
};
|
||||
(
|
||||
Some(RegistryClient::new(config)?),
|
||||
verify_checksums,
|
||||
allow_http,
|
||||
allowed_remote_hosts,
|
||||
)
|
||||
} else {
|
||||
(None, false, false, None)
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
temp_dir,
|
||||
registry_client,
|
||||
verify_checksums,
|
||||
allow_http,
|
||||
allowed_remote_hosts,
|
||||
progress_callback: None,
|
||||
})
|
||||
}
|
||||
@@ -152,6 +177,7 @@ impl PackInstaller {
|
||||
|
||||
/// Install from git repository
|
||||
async fn install_from_git(&self, url: &str, git_ref: Option<&str>) -> Result<InstalledPack> {
|
||||
self.validate_git_source(url).await?;
|
||||
tracing::info!("Installing pack from git: {} (ref: {:?})", url, git_ref);
|
||||
|
||||
self.report_progress(ProgressEvent::StepStarted {
|
||||
@@ -405,10 +431,12 @@ impl PackInstaller {
|
||||
|
||||
/// Download an archive from a URL
|
||||
async fn download_archive(&self, url: &str) -> Result<PathBuf> {
|
||||
let parsed_url = self.validate_remote_url(url).await?;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// nosemgrep: rust.actix.ssrf.reqwest-taint.reqwest-taint -- Remote source URLs are restricted to configured allowlisted hosts, HTTPS, and public IPs before request execution.
|
||||
let response = client
|
||||
.get(url)
|
||||
.get(parsed_url.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::internal(format!("Failed to download archive: {}", e)))?;
|
||||
@@ -421,11 +449,7 @@ impl PackInstaller {
|
||||
}
|
||||
|
||||
// Determine filename from URL
|
||||
let filename = url
|
||||
.split('/')
|
||||
.next_back()
|
||||
.unwrap_or("archive.zip")
|
||||
.to_string();
|
||||
let filename = archive_filename_from_url(&parsed_url);
|
||||
|
||||
let archive_path = self.temp_dir.join(&filename);
|
||||
|
||||
@@ -442,6 +466,116 @@ impl PackInstaller {
|
||||
Ok(archive_path)
|
||||
}
|
||||
|
||||
async fn validate_remote_url(&self, raw_url: &str) -> Result<Url> {
|
||||
let parsed = Url::parse(raw_url)
|
||||
.map_err(|e| Error::validation(format!("Invalid remote URL '{}': {}", raw_url, e)))?;
|
||||
|
||||
if parsed.scheme() != "https" && !(self.allow_http && parsed.scheme() == "http") {
|
||||
return Err(Error::validation(format!(
|
||||
"Remote URL must use https{}: {}",
|
||||
if self.allow_http {
|
||||
" or http when pack_registry.allow_http is enabled"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
raw_url
|
||||
)));
|
||||
}
|
||||
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(Error::validation(
|
||||
"Remote URLs with embedded credentials are not allowed".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let host = parsed.host_str().ok_or_else(|| {
|
||||
Error::validation(format!("Remote URL is missing a host: {}", raw_url))
|
||||
})?;
|
||||
let normalized_host = host.to_ascii_lowercase();
|
||||
|
||||
if normalized_host == "localhost" {
|
||||
return Err(Error::validation(format!(
|
||||
"Remote URL host is not allowed: {}",
|
||||
host
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(allowed_remote_hosts) = &self.allowed_remote_hosts {
|
||||
if !allowed_remote_hosts.contains(&normalized_host) {
|
||||
return Err(Error::validation(format!(
|
||||
"Remote URL host '{}' is not in the configured allowlist. Add it to pack_registry.allowed_source_hosts.",
|
||||
host
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ip) = parsed.host().and_then(|host| match host {
|
||||
url::Host::Ipv4(ip) => Some(IpAddr::V4(ip)),
|
||||
url::Host::Ipv6(ip) => Some(IpAddr::V6(ip)),
|
||||
url::Host::Domain(_) => None,
|
||||
}) {
|
||||
ensure_public_ip(ip)?;
|
||||
}
|
||||
|
||||
let port = parsed.port_or_known_default().ok_or_else(|| {
|
||||
Error::validation(format!("Remote URL is missing a usable port: {}", raw_url))
|
||||
})?;
|
||||
|
||||
let resolved = lookup_host((host, port))
|
||||
.await
|
||||
.map_err(|e| Error::validation(format!("Failed to resolve host '{}': {}", host, e)))?;
|
||||
|
||||
let mut saw_address = false;
|
||||
for addr in resolved {
|
||||
saw_address = true;
|
||||
ensure_public_ip(addr.ip())?;
|
||||
}
|
||||
|
||||
if !saw_address {
|
||||
return Err(Error::validation(format!(
|
||||
"Remote URL host did not resolve to any addresses: {}",
|
||||
host
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
async fn validate_git_source(&self, raw_url: &str) -> Result<()> {
|
||||
if raw_url.starts_with("http://") || raw_url.starts_with("https://") {
|
||||
self.validate_remote_url(raw_url).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(host) = extract_git_host(raw_url) {
|
||||
self.validate_remote_host(&host)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_remote_host(&self, host: &str) -> Result<()> {
|
||||
let normalized_host = host.to_ascii_lowercase();
|
||||
|
||||
if normalized_host == "localhost" {
|
||||
return Err(Error::validation(format!(
|
||||
"Remote host is not allowed: {}",
|
||||
host
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(allowed_remote_hosts) = &self.allowed_remote_hosts {
|
||||
if !allowed_remote_hosts.contains(&normalized_host) {
|
||||
return Err(Error::validation(format!(
|
||||
"Remote host '{}' is not in the configured allowlist. Add it to pack_registry.allowed_source_hosts.",
|
||||
host
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract an archive (zip or tar.gz)
|
||||
async fn extract_archive(&self, archive_path: &Path) -> Result<PathBuf> {
|
||||
let extract_dir = self.create_temp_dir().await?;
|
||||
@@ -583,6 +717,7 @@ impl PackInstaller {
|
||||
}
|
||||
|
||||
// Check in first subdirectory (common for GitHub archives)
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Archive inspection is limited to the temporary extraction directory created by this installer.
|
||||
let mut entries = fs::read_dir(base_dir)
|
||||
.await
|
||||
.map_err(|e| Error::internal(format!("Failed to read directory: {}", e)))?;
|
||||
@@ -618,6 +753,7 @@ impl PackInstaller {
|
||||
})?;
|
||||
|
||||
// Read source directory
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Directory copy operates on installer-managed local paths, not request-derived paths.
|
||||
let mut entries = fs::read_dir(src)
|
||||
.await
|
||||
.map_err(|e| Error::internal(format!("Failed to read source directory: {}", e)))?;
|
||||
@@ -674,6 +810,111 @@ impl PackInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_allowed_remote_hosts(config: &PackRegistryConfig) -> Result<HashSet<String>> {
|
||||
let mut hosts = HashSet::new();
|
||||
|
||||
for index in &config.indices {
|
||||
if !index.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed = Url::parse(&index.url).map_err(|e| {
|
||||
Error::validation(format!("Invalid registry index URL '{}': {}", index.url, e))
|
||||
})?;
|
||||
|
||||
let host = parsed.host_str().ok_or_else(|| {
|
||||
Error::validation(format!(
|
||||
"Registry index URL '{}' is missing a host",
|
||||
index.url
|
||||
))
|
||||
})?;
|
||||
|
||||
hosts.insert(host.to_ascii_lowercase());
|
||||
}
|
||||
|
||||
for host in &config.allowed_source_hosts {
|
||||
let normalized = host.trim().to_ascii_lowercase();
|
||||
if !normalized.is_empty() {
|
||||
hosts.insert(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(hosts)
|
||||
}
|
||||
|
||||
fn extract_git_host(raw_url: &str) -> Option<String> {
|
||||
if let Ok(parsed) = Url::parse(raw_url) {
|
||||
return parsed.host_str().map(|host| host.to_ascii_lowercase());
|
||||
}
|
||||
|
||||
raw_url.split_once('@').and_then(|(_, rest)| {
|
||||
rest.split_once(':')
|
||||
.map(|(host, _)| host.to_ascii_lowercase())
|
||||
})
|
||||
}
|
||||
|
||||
fn archive_filename_from_url(url: &Url) -> String {
|
||||
let raw_name = url
|
||||
.path_segments()
|
||||
.and_then(|segments| segments.filter(|segment| !segment.is_empty()).next_back())
|
||||
.unwrap_or("archive.bin");
|
||||
|
||||
let sanitized: String = raw_name
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => ch,
|
||||
_ => '_',
|
||||
})
|
||||
.collect();
|
||||
|
||||
let filename = sanitized.trim_matches('.');
|
||||
if filename.is_empty() {
|
||||
"archive.bin".to_string()
|
||||
} else {
|
||||
filename.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_public_ip(ip: IpAddr) -> Result<()> {
|
||||
let is_blocked = match ip {
|
||||
IpAddr::V4(ip) => {
|
||||
let octets = ip.octets();
|
||||
let is_documentation_range = matches!(
|
||||
octets,
|
||||
[192, 0, 2, _] | [198, 51, 100, _] | [203, 0, 113, _]
|
||||
);
|
||||
ip.is_private()
|
||||
|| ip.is_loopback()
|
||||
|| ip.is_link_local()
|
||||
|| ip.is_multicast()
|
||||
|| ip.is_broadcast()
|
||||
|| is_documentation_range
|
||||
|| ip.is_unspecified()
|
||||
|| octets[0] == 0
|
||||
}
|
||||
IpAddr::V6(ip) => {
|
||||
let segments = ip.segments();
|
||||
let is_documentation_range = segments[0] == 0x2001 && segments[1] == 0x0db8;
|
||||
ip.is_loopback()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.is_multicast()
|
||||
|| ip.is_unique_local()
|
||||
|| ip.is_unicast_link_local()
|
||||
|| is_documentation_range
|
||||
|| ip == Ipv6Addr::LOCALHOST
|
||||
}
|
||||
};
|
||||
|
||||
if is_blocked {
|
||||
return Err(Error::validation(format!(
|
||||
"Remote URL resolved to a non-public address: {}",
|
||||
ip
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -721,4 +962,52 @@ mod tests {
|
||||
|
||||
assert!(matches!(source, InstallSource::Git { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_archive_filename_from_url_sanitizes_path_segments() {
|
||||
let url = Url::parse("https://example.com/releases/../../pack.zip?token=x").unwrap();
|
||||
assert_eq!(archive_filename_from_url(&url), "pack.zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_public_ip_rejects_private_ipv4() {
|
||||
let err = ensure_public_ip(IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1))).unwrap_err();
|
||||
assert!(err.to_string().contains("non-public"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_allowed_remote_hosts_includes_indices_and_overrides() {
|
||||
let config = PackRegistryConfig {
|
||||
indices: vec![crate::config::RegistryIndexConfig {
|
||||
url: "https://registry.example.com/index.json".to_string(),
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
name: None,
|
||||
headers: std::collections::HashMap::new(),
|
||||
}],
|
||||
allowed_source_hosts: vec!["github.com".to_string(), "cdn.example.com".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let hosts = collect_allowed_remote_hosts(&config).unwrap();
|
||||
assert!(hosts.contains("registry.example.com"));
|
||||
assert!(hosts.contains("github.com"));
|
||||
assert!(hosts.contains("cdn.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_git_host_from_scp_style_source() {
|
||||
assert_eq!(
|
||||
extract_git_host("git@github.com:org/repo.git"),
|
||||
Some("github.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_git_host_from_git_scheme_source() {
|
||||
assert_eq!(
|
||||
extract_git_host("git://github.com/org/repo.git"),
|
||||
Some("github.com".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
//! can reference the same workflow file with different configurations.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sqlx::PgPool;
|
||||
use tracing::{debug, info, warn};
|
||||
@@ -1091,7 +1091,10 @@ impl<'a> PackComponentLoader<'a> {
|
||||
action_description: &str,
|
||||
action_data: &serde_yaml_ng::Value,
|
||||
) -> Result<Id> {
|
||||
let full_path = actions_dir.join(workflow_file_path);
|
||||
let pack_root = actions_dir.parent().ok_or_else(|| {
|
||||
Error::validation("Actions directory must live inside a pack directory".to_string())
|
||||
})?;
|
||||
let full_path = resolve_pack_relative_path(pack_root, actions_dir, workflow_file_path)?;
|
||||
if !full_path.exists() {
|
||||
return Err(Error::validation(format!(
|
||||
"Workflow file '{}' not found at '{}'",
|
||||
@@ -1100,6 +1103,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
)));
|
||||
}
|
||||
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The workflow path is normalized and confined to the pack root before this local read.
|
||||
let content = std::fs::read_to_string(&full_path).map_err(|e| {
|
||||
Error::io(format!(
|
||||
"Failed to read workflow file '{}': {}",
|
||||
@@ -1649,11 +1653,60 @@ impl<'a> PackComponentLoader<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_pack_relative_path(
|
||||
pack_root: &Path,
|
||||
base_dir: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<PathBuf> {
|
||||
let canonical_pack_root = pack_root.canonicalize().map_err(|e| {
|
||||
Error::io(format!(
|
||||
"Failed to resolve pack root '{}': {}",
|
||||
pack_root.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let canonical_base_dir = base_dir.canonicalize().map_err(|e| {
|
||||
Error::io(format!(
|
||||
"Failed to resolve base directory '{}': {}",
|
||||
base_dir.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let canonical_candidate = normalize_path_from_base(&canonical_base_dir, relative_path);
|
||||
|
||||
if !canonical_candidate.starts_with(&canonical_pack_root) {
|
||||
return Err(Error::validation(format!(
|
||||
"Resolved path '{}' escapes pack root '{}'",
|
||||
canonical_candidate.display(),
|
||||
canonical_pack_root.display()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(canonical_candidate)
|
||||
}
|
||||
|
||||
fn normalize_path_from_base(base: &Path, relative_path: &str) -> PathBuf {
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in base.join(relative_path).components() {
|
||||
match component {
|
||||
std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
|
||||
std::path::Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR.to_string()),
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir => {
|
||||
normalized.pop();
|
||||
}
|
||||
std::path::Component::Normal(part) => normalized.push(part),
|
||||
}
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
/// Read all YAML files from a directory, returning `(filename, content)` pairs
|
||||
/// sorted by filename for deterministic ordering.
|
||||
fn read_yaml_files(dir: &Path) -> Result<Vec<(String, String)>> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Pack loader scans pack-owned directories on disk after selecting the pack root.
|
||||
let entries = std::fs::read_dir(dir)
|
||||
.map_err(|e| Error::io(format!("Failed to read directory {}: {}", dir.display(), e)))?;
|
||||
|
||||
@@ -1676,6 +1729,7 @@ fn read_yaml_files(dir: &Path) -> Result<Vec<(String, String)>> {
|
||||
let path = entry.path();
|
||||
let filename = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- YAML files are read only after being discovered under the selected pack directory.
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.map_err(|e| Error::io(format!("Failed to read file {}: {}", path.display(), e)))?;
|
||||
|
||||
|
||||
@@ -292,6 +292,7 @@ fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
|
||||
))
|
||||
})?;
|
||||
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Pack storage copy recursively processes validated local directories under the configured pack store.
|
||||
for entry in fs::read_dir(src).map_err(|e| {
|
||||
Error::io(format!(
|
||||
"Failed to read source directory {}: {}",
|
||||
|
||||
@@ -172,6 +172,7 @@ impl WorkflowLoader {
|
||||
}
|
||||
|
||||
// Read and parse YAML
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Workflow files come from previously discovered pack directories under packs_base_dir.
|
||||
let content = fs::read_to_string(&file.path)
|
||||
.await
|
||||
.map_err(|e| Error::validation(format!("Failed to read workflow file: {}", e)))?;
|
||||
@@ -292,6 +293,7 @@ impl WorkflowLoader {
|
||||
pack_name: &str,
|
||||
) -> Result<Vec<WorkflowFile>> {
|
||||
let mut workflow_files = Vec::new();
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Workflow scanning only traverses pack workflow directories derived from packs_base_dir.
|
||||
let mut entries = fs::read_dir(workflows_dir)
|
||||
.await
|
||||
.map_err(|e| Error::validation(format!("Failed to read workflows directory: {}", e)))?;
|
||||
|
||||
Reference in New Issue
Block a user