fix: prevent path traversal, enable CSP, and harden URL domain extraction

Validate that resolved list paths stay within the workspace root to prevent
directory traversal via malicious list names. Enable Content Security Policy
in Tauri config instead of leaving it null. Fix CLI domain extraction to
strip userinfo (user:pass@) from URLs before using as keyring service name.
This commit is contained in:
Tristan Michael 2026-04-02 08:23:07 -07:00
parent 40142cb1ca
commit 68f1bff93b
3 changed files with 40 additions and 16 deletions

View file

@ -23,7 +23,7 @@
} }
], ],
"security": { "security": {
"csp": null "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; connect-src ipc: http://ipc.localhost"
} }
}, },
"bundle": { "bundle": {

View file

@ -204,18 +204,20 @@ fn print_workspace_status(name: &str, path: &std::path::Path, webdav_url: Option
Ok(()) Ok(())
} }
/// Extract domain from a URL for credential storage. /// Extract host from a URL for credential storage.
fn extract_domain(url: &str) -> String { fn extract_domain(url: &str) -> String {
url.split("://") // Strip scheme
.nth(1) let after_scheme = url.split("://").nth(1).unwrap_or(url);
.unwrap_or(url) // Strip path
.split('/') let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
.next() // Strip userinfo (user:pass@host)
.unwrap_or(url) let host_port = if let Some(at_pos) = authority.rfind('@') {
.split(':') &authority[at_pos + 1..]
.next() } else {
.unwrap_or(url) authority
.to_string() };
// Strip port
host_port.split(':').next().unwrap_or(host_port).to_string()
} }
/// Prompt the user for text input. /// Prompt the user for text input.

View file

@ -149,8 +149,30 @@ impl FileSystemStorage {
Err(Error::ListNotFound(list_id.to_string())) Err(Error::ListNotFound(list_id.to_string()))
} }
fn list_dir_path_by_name(&self, name: &str) -> PathBuf { fn list_dir_path_by_name(&self, name: &str) -> Result<PathBuf> {
self.root_path.join(name) let path = self.root_path.join(name);
// Prevent path traversal: resolved path must stay within root
let canonical_root = self.root_path.canonicalize()
.unwrap_or_else(|_| self.root_path.clone());
let canonical_path = if path.exists() {
path.canonicalize().unwrap_or_else(|_| path.clone())
} else {
// For non-existent paths, normalize by resolving the parent
if let Some(parent) = path.parent() {
let canonical_parent = if parent.exists() {
parent.canonicalize().unwrap_or_else(|_| parent.to_path_buf())
} else {
parent.to_path_buf()
};
canonical_parent.join(path.file_name().unwrap_or_default())
} else {
path.clone()
}
};
if !canonical_path.starts_with(&canonical_root) {
return Err(Error::InvalidData(format!("Invalid list name: path escapes workspace")));
}
Ok(path)
} }
fn sanitize_filename(name: &str) -> String { fn sanitize_filename(name: &str) -> String {
@ -371,7 +393,7 @@ impl Storage for FileSystemStorage {
} }
fn create_list(&mut self, name: String) -> Result<TaskList> { fn create_list(&mut self, name: String) -> Result<TaskList> {
let list_dir = self.list_dir_path_by_name(&name); let list_dir = self.list_dir_path_by_name(&name)?;
if list_dir.exists() { if list_dir.exists() {
return Err(Error::InvalidData(format!("List '{}' already exists", name))); return Err(Error::InvalidData(format!("List '{}' already exists", name)));
@ -473,7 +495,7 @@ impl Storage for FileSystemStorage {
fn rename_list(&mut self, list_id: Uuid, new_name: String) -> Result<()> { fn rename_list(&mut self, list_id: Uuid, new_name: String) -> Result<()> {
let old_dir = self.list_dir_path(list_id)?; let old_dir = self.list_dir_path(list_id)?;
let new_dir = self.list_dir_path_by_name(&new_name); let new_dir = self.list_dir_path_by_name(&new_name)?;
if new_dir.exists() { if new_dir.exists() {
return Err(Error::InvalidData(format!("A list named '{}' already exists", new_name))); return Err(Error::InvalidData(format!("A list named '{}' already exists", new_name)));