Skip to content

Commit 17c3624

Browse files
committed
Harden input validation, CSP, and temp file handling
- Add missing image name validation to install/uninstall commands - Switch mount options sanitization from blacklist to whitelist - Use unique sentinel for silent auth expiry to avoid collisions - Use tempfile crate for askpass script (random filenames) - Remove unused data: URIs from CSP img-src and font-src - Align device path validation between frontend and backend - Fix mount status text when no disks are connected
1 parent 518ead0 commit 17c3624

10 files changed

Lines changed: 52 additions & 45 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ portable-pty = "0.9"
2626
tauri-plugin-process = "2.3.1"
2727
tauri-plugin-notification = "2.3.3"
2828
tauri-plugin-dialog = "2"
29+
tempfile = "3.25.0"
2930

3031
[target.'cfg(target_os = "macos")'.dependencies]
3132
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSRunningApplication"] }

src-tauri/src/cli.rs

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ fn execute_with_sudo(args: &[&str], passphrase: Option<&str>, silent: bool) -> R
257257
None => {
258258
if silent {
259259
log::debug!("sudo: native auth expired, silent mode — skipping password dialog");
260-
return Err("AUTH_EXPIRED".to_string());
260+
return Err("ALFS_SILENT_AUTH_EXPIRED".to_string());
261261
}
262262
log::debug!("sudo: native auth unavailable, falling back to password dialog");
263263
}
@@ -345,33 +345,32 @@ fn execute_with_sudo(args: &[&str], passphrase: Option<&str>, silent: bool) -> R
345345

346346
fn create_askpass_script() -> Result<String, String> {
347347
use std::io::Write;
348-
use std::os::unix::fs::OpenOptionsExt;
349-
350-
// Use unique filename with PID and timestamp to prevent race conditions
351-
let pid = std::process::id();
352-
let timestamp = std::time::SystemTime::now()
353-
.duration_since(std::time::UNIX_EPOCH)
354-
.map(|d| d.as_nanos())
355-
.unwrap_or(0);
356-
let script_path = format!("/tmp/anylinuxfs-askpass-{}-{}.sh", pid, timestamp);
348+
use std::os::unix::fs::PermissionsExt;
357349

358350
// Create askpass script that uses osascript to prompt for password
359351
// The password goes directly from osascript to sudo, never through our app
360352
let script_content = r#"#!/bin/bash
361353
osascript -e 'Tell application "System Events" to display dialog "anylinuxfs requires administrator privileges." & return & return & "Enter your password:" with hidden answer default answer "" buttons {"Cancel", "OK"} default button "OK" with title "Authentication Required" with icon caution' -e 'text returned of result' 2>/dev/null
362354
"#;
363355

364-
// Create file with restrictive permissions from the start (0700)
365-
// This prevents TOCTOU race conditions
366-
let mut file = fs::OpenOptions::new()
367-
.write(true)
368-
.create_new(true) // Fail if file already exists
369-
.mode(0o700)
370-
.open(&script_path)
356+
// Use tempfile for a cryptographically random filename, preventing symlink attacks
357+
let mut file = tempfile::Builder::new()
358+
.prefix("anylinuxfs-askpass-")
359+
.suffix(".sh")
360+
.tempfile()
371361
.map_err(|e| format!("Failed to create askpass script: {}", e))?;
372362

363+
// Set restrictive permissions (owner-only executable)
364+
file.as_file().set_permissions(fs::Permissions::from_mode(0o700))
365+
.map_err(|e| format!("Failed to set askpass script permissions: {}", e))?;
366+
373367
file.write_all(script_content.as_bytes())
374368
.map_err(|e| format!("Failed to write askpass script: {}", e))?;
375369

376-
Ok(script_path)
370+
// Persist the file (disables auto-delete) so sudo can read it;
371+
// the caller is responsible for removing it after use
372+
let (_, path) = file.keep()
373+
.map_err(|e| format!("Failed to persist askpass script: {}", e))?;
374+
375+
Ok(path.to_string_lossy().to_string())
377376
}

src-tauri/src/commands/disk.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@ fn validate_device_path(device: &str) -> Result<(), String> {
1919
return Err("Device path cannot contain '..'".to_string());
2020
}
2121
if device.starts_with("/dev/") {
22-
// Normal device: allow alphanumeric, slash, dash, underscore
23-
let valid_chars = device.chars().all(|c| {
24-
c.is_ascii_alphanumeric() || c == '/' || c == '-' || c == '_'
22+
// Normal device: only allow alphanumeric, dash, underscore after /dev/ prefix
23+
let suffix = &device["/dev/".len()..];
24+
let valid_chars = suffix.chars().all(|c| {
25+
c.is_ascii_alphanumeric() || c == '-' || c == '_'
2526
});
26-
if !valid_chars {
27+
if suffix.is_empty() || !valid_chars {
2728
return Err("Device path contains invalid characters".to_string());
2829
}
2930
} else if device.starts_with("raid:") || device.starts_with("lvm:") {
@@ -496,10 +497,12 @@ pub async fn mount_disk(app: AppHandle, device: String, passphrase: Option<Strin
496497
// Validate device path before use
497498
validate_device_path(&device)?;
498499

499-
// Sanitize extra_options to prevent command injection
500+
// Sanitize extra_options with a whitelist to prevent command injection
500501
if let Some(ref opts) = extra_options {
501-
let forbidden = [';', '|', '&', '`', '$', '(', ')', '\n', '\r'];
502-
if opts.chars().any(|c| forbidden.contains(&c)) {
502+
let valid = opts.chars().all(|c| {
503+
c.is_ascii_alphanumeric() || matches!(c, ',' | '.' | '_' | '-' | '=' | '/')
504+
});
505+
if !valid {
503506
return Err("Mount options contain invalid characters".to_string());
504507
}
505508
}

src-tauri/src/commands/image.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
use serde::Serialize;
22
use crate::cli::execute_command;
33

4+
/// Validate image name format to prevent path traversal or command injection.
5+
/// Image names should only contain alphanumeric characters, hyphens, dots, and underscores.
6+
pub fn validate_image_name(image: &str) -> Result<(), String> {
7+
if image.is_empty() {
8+
return Err("Image name cannot be empty".to_string());
9+
}
10+
let valid = image.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_');
11+
if !valid {
12+
return Err(format!("Invalid image name '{}': contains invalid characters", image));
13+
}
14+
if image.contains("..") {
15+
return Err("Image name cannot contain '..'".to_string());
16+
}
17+
Ok(())
18+
}
19+
420
#[derive(Debug, Clone, Serialize)]
521
pub struct VmImage {
622
pub name: String,
@@ -33,6 +49,7 @@ pub fn list_images() -> Result<Vec<VmImage>, String> {
3349

3450
#[tauri::command]
3551
pub async fn install_image(name: String) -> Result<(), String> {
52+
validate_image_name(&name)?;
3653
tokio::task::spawn_blocking(move || {
3754
execute_command(&["image", "install", &name], false, None, false)?;
3855
Ok(())
@@ -43,6 +60,7 @@ pub async fn install_image(name: String) -> Result<(), String> {
4360

4461
#[tauri::command]
4562
pub async fn uninstall_image(name: String) -> Result<(), String> {
63+
validate_image_name(&name)?;
4664
tokio::task::spawn_blocking(move || {
4765
execute_command(&["image", "uninstall", &name], false, None, false)?;
4866
Ok(())

src-tauri/src/commands/shell.rs

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,7 @@ use std::io::{Read, Write};
33
use std::sync::{Arc, Mutex};
44
use tauri::{AppHandle, Emitter};
55
use crate::cli::get_path;
6-
7-
/// Validate image name format to prevent path traversal or command injection
8-
/// Image names should only contain alphanumeric characters, hyphens, dots, and underscores
9-
fn validate_image_name(image: &str) -> Result<(), String> {
10-
if image.is_empty() {
11-
return Err("Image name cannot be empty".to_string());
12-
}
13-
let valid = image.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_');
14-
if !valid {
15-
return Err(format!("Invalid image name '{}': contains invalid characters", image));
16-
}
17-
if image.contains("..") {
18-
return Err("Image name cannot contain '..'".to_string());
19-
}
20-
Ok(())
21-
}
6+
use super::image::validate_image_name;
227

238
pub struct PtyState {
249
writer: Option<Box<dyn Write + Send>>,

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
}
2828
],
2929
"security": {
30-
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost"
30+
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset:; font-src 'self'; connect-src ipc: http://ipc.localhost"
3131
}
3232
},
3333
"bundle": {

src/components/MountStatus.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@
128128
<div class="status-info">
129129
<div class="status-label">No disk mounted</div>
130130
<div class="status-details">
131-
<span class="detail-item">Select a partition below to mount</span>
131+
<span class="detail-item">{$disks.disks.length > 0 ? 'Select a partition below to mount' : 'Connect a drive to get started'}</span>
132132
</div>
133133
</div>
134134
</div>

src/lib/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const Limits = {
2222
} as const;
2323

2424
// Device validation
25-
const DEVICE_PATH_REGEX = /^\/dev\/[a-zA-Z0-9_-]+[a-zA-Z0-9_\-s]*$/;
25+
const DEVICE_PATH_REGEX = /^\/dev\/[a-zA-Z0-9_-]+$/;
2626
const RAID_PATH_REGEX = /^raid:[a-zA-Z0-9:_-]+$/;
2727
const LVM_PATH_REGEX = /^lvm:[a-zA-Z0-9:_-]+$/;
2828

src/lib/stores/disks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ function createDisksStore() {
4848
}));
4949
} catch (e) {
5050
const rawError = String(e);
51-
if (silent && rawError.includes('AUTH_EXPIRED')) {
51+
if (silent && rawError.includes('ALFS_SILENT_AUTH_EXPIRED')) {
5252
// Credentials expired during auto-refresh — disable admin mode quietly
5353
currentAdminMode = false;
5454
update((s) => ({

0 commit comments

Comments
 (0)