Skip to content

Commit d65aa2b

Browse files
committed
fix(cortex-update): address security vulnerabilities and clippy warnings
- Enforce HTTPS for update URLs (reject insecure URLs except localhost) - Add InsecureUrl error variant for security violations - Validate asset download URLs for HTTPS - Fix TOCTOU race condition in archive extraction (single-pass) - Collapse nested if statements - Remove unneeded return statements - Change &PathBuf to &Path parameter type - Add #[allow(dead_code)] for utility functions
1 parent a7652b4 commit d65aa2b

File tree

9 files changed

+111
-62
lines changed

9 files changed

+111
-62
lines changed

src/cortex-update/src/api.rs

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,28 +90,33 @@ impl CortexSoftwareClient {
9090
impl CortexSoftwareClient {
9191
/// Create a new client with the default URL.
9292
pub fn new() -> Self {
93-
Self::with_url(SOFTWARE_URL.to_string())
93+
// Default URL is known to be HTTPS, so unwrap is safe
94+
Self::with_url(SOFTWARE_URL.to_string()).expect("Default SOFTWARE_URL must be HTTPS")
9495
}
9596

9697
/// Create a new client with a custom URL.
97-
pub fn with_url(base_url: String) -> Self {
98+
///
99+
/// # Errors
100+
///
101+
/// Returns `UpdateError::InsecureUrl` if the URL does not use HTTPS
102+
/// (except for localhost/127.0.0.1 which are allowed for development).
103+
pub fn with_url(base_url: String) -> UpdateResult<Self> {
98104
// Validate URL uses HTTPS for security (allow http for localhost development only)
99105
let url_lower = base_url.to_lowercase();
100106
if !url_lower.starts_with("https://")
101107
&& !url_lower.starts_with("http://localhost")
102108
&& !url_lower.starts_with("http://127.0.0.1")
103109
{
104-
tracing::warn!(
105-
"Custom update URL does not use HTTPS. This could allow man-in-the-middle attacks: {}",
106-
base_url
107-
);
110+
return Err(UpdateError::InsecureUrl {
111+
url: base_url,
112+
});
108113
}
109114

110115
let client = create_client_builder()
111116
.build()
112117
.unwrap_or_else(|_| Client::new());
113118

114-
Self { client, base_url }
119+
Ok(Self { client, base_url })
115120
}
116121

117122
/// Get the latest release for a channel.
@@ -216,6 +221,17 @@ impl CortexSoftwareClient {
216221
where
217222
F: FnMut(u64, u64), // (downloaded, total)
218223
{
224+
// Validate asset URL uses HTTPS (allow localhost for development)
225+
let url_lower = asset.url.to_lowercase();
226+
if !url_lower.starts_with("https://")
227+
&& !url_lower.starts_with("http://localhost")
228+
&& !url_lower.starts_with("http://127.0.0.1")
229+
{
230+
return Err(UpdateError::InsecureUrl {
231+
url: asset.url.clone(),
232+
});
233+
}
234+
219235
let response =
220236
self.client
221237
.get(&asset.url)

src/cortex-update/src/config.rs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -106,17 +106,17 @@ impl UpdateConfig {
106106
.map(|h| h.join(".cortex").join("update.json"))
107107
.filter(|p| p.exists());
108108

109-
if let Some(path) = config_path {
110-
if let Ok(content) = std::fs::read_to_string(&path) {
111-
match serde_json::from_str(&content) {
112-
Ok(config) => return config,
113-
Err(e) => {
114-
tracing::warn!(
115-
"Failed to parse update config at {}: {}. Using defaults.",
116-
path.display(),
117-
e
118-
);
119-
}
109+
if let Some(path) = config_path
110+
&& let Ok(content) = std::fs::read_to_string(&path)
111+
{
112+
match serde_json::from_str(&content) {
113+
Ok(config) => return config,
114+
Err(e) => {
115+
tracing::warn!(
116+
"Failed to parse update config at {}: {}. Using defaults.",
117+
path.display(),
118+
e
119+
);
120120
}
121121
}
122122
}

src/cortex-update/src/download.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ impl Downloader {
120120
}
121121

122122
/// Download a signature file.
123+
#[allow(dead_code)]
123124
pub async fn download_signature(&self, url: &str, version: &str) -> UpdateResult<PathBuf> {
124125
let filename = format!("{}.sig", version);
125126
let dest = self.temp_dir.join(filename);
@@ -131,11 +132,13 @@ impl Downloader {
131132
}
132133

133134
/// Get the temp directory path.
135+
#[allow(dead_code)]
134136
pub fn temp_dir(&self) -> &Path {
135137
&self.temp_dir
136138
}
137139

138140
/// Clean up temp files.
141+
#[allow(dead_code)]
139142
pub fn cleanup(&self) -> UpdateResult<()> {
140143
if self.temp_dir.exists() {
141144
std::fs::remove_dir_all(&self.temp_dir)?;

src/cortex-update/src/error.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ pub enum UpdateError {
9090
// Cancelled
9191
#[error("Update cancelled by user")]
9292
Cancelled,
93+
94+
// Security errors
95+
#[error("Insecure URL rejected (HTTPS required): {url}")]
96+
InsecureUrl { url: String },
9397
}
9498

9599
impl UpdateError {

src/cortex-update/src/install.rs

Lines changed: 51 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ impl Installer {
120120
}
121121

122122
/// Extract a tar.gz archive.
123+
///
124+
/// Uses single-pass extraction with inline validation to prevent TOCTOU race conditions.
123125
async fn extract_tar_gz(&self, archive_path: &Path, dest: &Path) -> UpdateResult<PathBuf> {
124126
use flate2::read::GzDecoder;
125127
use std::fs::File;
@@ -129,13 +131,14 @@ impl Installer {
129131
let gz = GzDecoder::new(file);
130132
let mut archive = Archive::new(gz);
131133

132-
// Validate paths before extraction to prevent path traversal attacks
133-
for entry in archive.entries().map_err(|e| UpdateError::ExtractionFailed {
134+
// Single-pass: validate and extract each entry to prevent TOCTOU race conditions
135+
for entry_result in archive.entries().map_err(|e| UpdateError::ExtractionFailed {
134136
message: e.to_string(),
135137
})? {
136-
let entry = entry.map_err(|e| UpdateError::ExtractionFailed {
138+
let mut entry = entry_result.map_err(|e| UpdateError::ExtractionFailed {
137139
message: e.to_string(),
138140
})?;
141+
139142
let path = entry.path().map_err(|e| UpdateError::ExtractionFailed {
140143
message: e.to_string(),
141144
})?;
@@ -149,62 +152,79 @@ impl Installer {
149152
message: format!("Path traversal detected in archive: {}", path.display()),
150153
});
151154
}
152-
}
153-
154-
// Re-open and extract after validation
155-
let file = File::open(archive_path)?;
156-
let gz = GzDecoder::new(file);
157-
let mut archive = Archive::new(gz);
158155

159-
archive
160-
.unpack(dest)
161-
.map_err(|e| UpdateError::ExtractionFailed {
162-
message: e.to_string(),
163-
})?;
156+
// Extract the entry immediately after validation
157+
entry
158+
.unpack_in(dest)
159+
.map_err(|e| UpdateError::ExtractionFailed {
160+
message: e.to_string(),
161+
})?;
162+
}
164163

165164
self.find_binary(dest).await
166165
}
167166

168167
/// Extract a zip archive.
168+
///
169+
/// Uses single-pass extraction with inline validation to prevent TOCTOU race conditions.
169170
async fn extract_zip(&self, archive_path: &Path, dest: &Path) -> UpdateResult<PathBuf> {
170171
let file = std::fs::File::open(archive_path)?;
171172
let mut archive =
172173
zip::ZipArchive::new(file).map_err(|e| UpdateError::ExtractionFailed {
173174
message: e.to_string(),
174175
})?;
175176

176-
// Validate all paths before extraction to prevent path traversal attacks
177+
// Single-pass: validate and extract each entry to prevent TOCTOU race conditions
177178
for i in 0..archive.len() {
178-
let file = archive
179+
let mut zip_file = archive
179180
.by_index(i)
180181
.map_err(|e| UpdateError::ExtractionFailed {
181182
message: e.to_string(),
182183
})?;
183-
let path = std::path::Path::new(file.name());
184+
185+
let path = std::path::Path::new(zip_file.name());
184186

185187
// Check for path traversal
186188
if path
187189
.components()
188190
.any(|c| matches!(c, std::path::Component::ParentDir))
189191
{
190192
return Err(UpdateError::ExtractionFailed {
191-
message: format!("Path traversal detected in archive: {}", file.name()),
193+
message: format!("Path traversal detected in archive: {}", zip_file.name()),
192194
});
193195
}
194-
}
195196

196-
// Re-open and extract after validation
197-
let file = std::fs::File::open(archive_path)?;
198-
let mut archive =
199-
zip::ZipArchive::new(file).map_err(|e| UpdateError::ExtractionFailed {
200-
message: e.to_string(),
201-
})?;
197+
// Determine the output path
198+
let outpath = dest.join(zip_file.name());
202199

203-
archive
204-
.extract(dest)
205-
.map_err(|e| UpdateError::ExtractionFailed {
206-
message: e.to_string(),
207-
})?;
200+
// Extract the entry immediately after validation
201+
if zip_file.is_dir() {
202+
std::fs::create_dir_all(&outpath)?;
203+
} else {
204+
// Ensure parent directory exists
205+
if let Some(parent) = outpath.parent() {
206+
std::fs::create_dir_all(parent)?;
207+
}
208+
let mut outfile =
209+
std::fs::File::create(&outpath).map_err(|e| UpdateError::ExtractionFailed {
210+
message: format!("Failed to create file {}: {}", outpath.display(), e),
211+
})?;
212+
std::io::copy(&mut zip_file, &mut outfile).map_err(|e| {
213+
UpdateError::ExtractionFailed {
214+
message: format!("Failed to extract {}: {}", outpath.display(), e),
215+
}
216+
})?;
217+
218+
// Set file permissions on Unix
219+
#[cfg(unix)]
220+
{
221+
use std::os::unix::fs::PermissionsExt;
222+
if let Some(mode) = zip_file.unix_mode() {
223+
std::fs::set_permissions(&outpath, std::fs::Permissions::from_mode(mode))?;
224+
}
225+
}
226+
}
227+
}
208228

209229
self.find_binary(dest).await
210230
}
@@ -328,6 +348,7 @@ impl Installer {
328348
}
329349

330350
/// Check if we have permission to write to the installation directory.
351+
#[allow(dead_code)]
331352
pub fn check_write_permission() -> UpdateResult<()> {
332353
let exe_path = std::env::current_exe()?;
333354
let parent = exe_path

src/cortex-update/src/manager.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
//! Update manager - main API for update operations.
22
3-
use std::path::PathBuf;
4-
53
use crate::CURRENT_VERSION;
64
use crate::api::{CortexSoftwareClient, ReleaseAsset, ReleaseInfo};
75
use crate::config::{ReleaseChannel, UpdateConfig};
@@ -61,7 +59,7 @@ impl UpdateManager {
6159
/// Create with a specific config.
6260
pub fn with_config(config: UpdateConfig) -> UpdateResult<Self> {
6361
let client = if let Some(url) = &config.custom_url {
64-
CortexSoftwareClient::with_url(url.clone())
62+
CortexSoftwareClient::with_url(url.clone())?
6563
} else {
6664
CortexSoftwareClient::new()
6765
};
@@ -89,10 +87,12 @@ impl UpdateManager {
8987
pub async fn check_update(&self) -> UpdateResult<Option<UpdateInfo>> {
9088
// Try to use cache first
9189
if let Some(cache) = VersionCache::load() {
92-
if cache.is_valid(&self.config) {
93-
if cache.has_update() && !self.config.is_version_skipped(&cache.latest.version) {
94-
return Ok(Some(self.build_update_info(&cache.latest)?));
95-
}
90+
if cache.is_valid(&self.config)
91+
&& cache.has_update()
92+
&& !self.config.is_version_skipped(&cache.latest.version)
93+
{
94+
return Ok(Some(self.build_update_info(&cache.latest)?));
95+
} else if cache.is_valid(&self.config) {
9696
return Ok(None);
9797
}
9898
}

src/cortex-update/src/method.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
//! Installation method detection.
22
33
use serde::{Deserialize, Serialize};
4+
use std::path::Path;
5+
#[cfg(test)]
46
use std::path::PathBuf;
57

68
/// Installation method for Cortex CLI.
@@ -27,10 +29,10 @@ impl InstallMethod {
2729
/// Detect the installation method based on environment and paths.
2830
pub fn detect() -> Self {
2931
// 1. Check executable path first
30-
if let Ok(exe_path) = std::env::current_exe() {
31-
if let Some(method) = Self::detect_from_path(&exe_path) {
32-
return method;
33-
}
32+
if let Ok(exe_path) = std::env::current_exe()
33+
&& let Some(method) = Self::detect_from_path(&exe_path)
34+
{
35+
return method;
3436
}
3537

3638
// 2. Platform-specific defaults
@@ -40,7 +42,7 @@ impl InstallMethod {
4042
if which_exists("winget") {
4143
return Self::WinGet;
4244
}
43-
return Self::PowerShellScript;
45+
Self::PowerShellScript
4446
}
4547

4648
#[cfg(not(windows))]
@@ -49,12 +51,12 @@ impl InstallMethod {
4951
if which_exists("brew") {
5052
return Self::Homebrew;
5153
}
52-
return Self::CurlScript;
54+
Self::CurlScript
5355
}
5456
}
5557

5658
/// Detect from executable path.
57-
fn detect_from_path(path: &PathBuf) -> Option<Self> {
59+
fn detect_from_path(path: &Path) -> Option<Self> {
5860
let path_str = path.to_string_lossy().to_lowercase();
5961

6062
// Homebrew paths (macOS and Linux)

src/cortex-update/src/verify.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pub async fn verify_sha256(path: &Path, expected: &str) -> UpdateResult<()> {
3434
}
3535

3636
/// Verify SHA256 checksum synchronously (for smaller files).
37+
#[allow(dead_code)]
3738
pub fn verify_sha256_sync(path: &Path, expected: &str) -> UpdateResult<()> {
3839
let content = std::fs::read(path)?;
3940
let result = Sha256::digest(&content);
@@ -49,6 +50,7 @@ pub fn verify_sha256_sync(path: &Path, expected: &str) -> UpdateResult<()> {
4950
}
5051

5152
/// Calculate SHA256 hash of a file.
53+
#[allow(dead_code)]
5254
pub async fn calculate_sha256(path: &Path) -> UpdateResult<String> {
5355
let mut file = tokio::fs::File::open(path).await?;
5456
let mut hasher = Sha256::new();

src/cortex-update/src/version.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ fn parse_version(version: &str) -> (u32, u32, u32, String) {
125125
}
126126

127127
/// Check if a version meets the minimum requirement.
128+
#[allow(dead_code)]
128129
pub fn meets_minimum(current: &str, minimum: &str) -> bool {
129130
compare_versions(current, minimum) != VersionComparison::Older
130131
}

0 commit comments

Comments
 (0)