diff --git a/Cargo.lock b/Cargo.lock index a473b3f..6396fb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,18 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "arboard" version = "3.6.1" @@ -557,6 +569,36 @@ dependencies = [ "displaydoc", ] +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix", +] + [[package]] name = "cc" version = "1.2.56" @@ -961,7 +1003,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1188,7 +1230,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1385,6 +1427,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "futf" version = "0.1.5" @@ -1670,11 +1723,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "gio" version = "0.18.4" @@ -2338,6 +2404,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -2381,6 +2453,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -2412,6 +2486,22 @@ dependencies = [ "memoffset", ] +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + [[package]] name = "ipnet" version = "2.12.0" @@ -2557,6 +2647,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2756,6 +2852,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "memchr" version = "2.8.0" @@ -2981,7 +3083,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3591,6 +3693,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3784,6 +3896,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.7.3" @@ -4096,7 +4214,17 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", ] [[package]] @@ -4423,7 +4551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4658,7 +4786,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5084,7 +5212,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5099,12 +5227,27 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -5147,6 +5290,7 @@ version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -5182,6 +5326,7 @@ version = "0.1.0" dependencies = [ "arboard", "base64", + "cap-std", "crossbeam-channel", "dirs 6.0.0", "ed25519-dalek", @@ -5201,7 +5346,9 @@ dependencies = [ "tao", "thiserror 2.0.18", "tray-icon", + "unicode-normalization", "url", + "uuid", "volt-permissions", "wry", ] @@ -5330,6 +5477,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -5389,6 +5545,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver 1.0.27", +] + [[package]] name = "wayland-backend" version = "0.3.14" @@ -5586,7 +5776,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -6046,11 +6236,103 @@ dependencies = [ "version_check", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.11.0", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver 1.0.27", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "write16" diff --git a/crates/volt-core/Cargo.toml b/crates/volt-core/Cargo.toml index 7734af2..36e678d 100644 --- a/crates/volt-core/Cargo.toml +++ b/crates/volt-core/Cargo.toml @@ -31,6 +31,9 @@ global-hotkey = "0.7" dirs = "6" notify = "9.0.0-rc.2" crossbeam-channel = "0.5" +cap-std = "3" +unicode-normalization = "0.1" +uuid = { version = "1", features = ["v4"] } [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18" diff --git a/crates/volt-core/src/fs/helpers.rs b/crates/volt-core/src/fs/helpers.rs new file mode 100644 index 0000000..e60c3c2 --- /dev/null +++ b/crates/volt-core/src/fs/helpers.rs @@ -0,0 +1,90 @@ +use cap_std::ambient_authority; +use cap_std::fs::Dir; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::FsError; + +pub(super) fn open_scoped_dir(base: &Path) -> Result<(PathBuf, Dir), FsError> { + let canonical_base = canonical_base_dir(base)?; + let dir = Dir::open_ambient_dir(&canonical_base, ambient_authority()).map_err(FsError::Io)?; + Ok((canonical_base, dir)) +} + +pub(super) fn scoped_path(path: &str) -> &Path { + if path.is_empty() { + Path::new(".") + } else { + Path::new(path) + } +} + +/// Materialize a directory chain below `base` one component at a time and +/// reject symlink substitutions while walking it. +pub(super) fn ensure_scoped_directory(base: &Path, directory: &Path) -> Result<(), FsError> { + let canonical_base = canonical_base_dir(base)?; + let relative = directory + .strip_prefix(&canonical_base) + .map_err(|_| FsError::OutOfScope)?; + let mut current = canonical_base.clone(); + + for component in relative.components() { + current.push(component.as_os_str()); + match fs::symlink_metadata(¤t) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err(FsError::Security(format!( + "symlink component is not allowed: '{}'", + current.display() + ))); + } + if !metadata.is_dir() { + return Err(FsError::Security(format!( + "path component is not a directory: '{}'", + current.display() + ))); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(¤t)?; + } + Err(error) => return Err(FsError::Io(error)), + } + + let canonical_current = current.canonicalize()?; + if !canonical_current.starts_with(&canonical_base) { + return Err(FsError::OutOfScope); + } + current = canonical_current; + } + + Ok(()) +} + +/// Ensure the parent directory for a to-be-created path exists within the +/// scoped base directory before returning that path to external callers. +pub(super) fn ensure_scoped_parent_dirs(base: &Path, resolved: &Path) -> Result<(), FsError> { + let Some(parent) = resolved.parent() else { + return Err(FsError::Security( + "Cannot resolve parent directory".to_string(), + )); + }; + ensure_scoped_directory(base, parent) +} + +pub(super) fn ensure_not_symlink(path: &Path) -> Result<(), FsError> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(FsError::Security(format!( + "symlink targets are not allowed: '{}'", + path.display() + ))), + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(FsError::Io(error)), + } +} + +pub(super) fn canonical_base_dir(base: &Path) -> Result { + base.canonicalize() + .map_err(|_| FsError::Security("Base directory does not exist".to_string())) +} diff --git a/crates/volt-core/src/fs/mod.rs b/crates/volt-core/src/fs/mod.rs index cef5698..b3042ca 100644 --- a/crates/volt-core/src/fs/mod.rs +++ b/crates/volt-core/src/fs/mod.rs @@ -1,10 +1,14 @@ -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::time::UNIX_EPOCH; +mod helpers; +mod resolve; +mod scoped; + use thiserror::Error; -use crate::security::validate_path; +pub use resolve::{safe_resolve, safe_resolve_for_create}; +pub use scoped::{ + copy, exists, mkdir, read_dir, read_file, read_file_text, remove, rename, replace_file, stat, + write_file, +}; #[derive(Error, Debug)] pub enum FsError { @@ -18,157 +22,6 @@ pub enum FsError { OutOfScope, } -/// Safely resolve a user-provided relative path against a base directory. -/// Rejects absolute paths, path traversal, and ensures the result is under base. -pub fn safe_resolve(base: &Path, user_path: &str) -> Result { - // Validate the path for traversal attacks and reserved names - validate_path(user_path).map_err(FsError::Security)?; - - let resolved = base.join(user_path); - - // Canonicalize both paths and verify the resolved path is under base. - // If the file doesn't exist yet, canonicalize the parent. - let canonical_base = base - .canonicalize() - .map_err(|_| FsError::Security("Base directory does not exist".to_string()))?; - - // Try to canonicalize the full path first (works if file exists) - let canonical_resolved = if resolved.exists() { - resolved.canonicalize()? - } else { - // If the file doesn't exist, canonicalize the parent directory - let parent = resolved - .parent() - .ok_or_else(|| FsError::Security("Cannot resolve parent directory".to_string()))?; - - if !parent.exists() { - // Parent also doesn't exist - walk up to find nearest existing ancestor - // Find the nearest existing ancestor and canonicalize from there - let mut ancestor = parent.to_path_buf(); - let mut trailing_components = Vec::new(); - while !ancestor.exists() { - if let Some(name) = ancestor.file_name() { - trailing_components.push(name.to_os_string()); - } else { - return Err(FsError::Security( - "Cannot resolve path ancestor".to_string(), - )); - } - ancestor = ancestor - .parent() - .ok_or_else(|| FsError::Security("Cannot resolve path ancestor".to_string()))? - .to_path_buf(); - } - let mut canonical = ancestor.canonicalize()?; - for component in trailing_components.into_iter().rev() { - canonical.push(component); - } - if let Some(file_name) = resolved.file_name() { - canonical.push(file_name); - } - canonical - } else { - let canonical_parent = parent.canonicalize()?; - let file_name = resolved - .file_name() - .ok_or_else(|| FsError::Security("Invalid file name".to_string()))?; - canonical_parent.join(file_name) - } - }; - - // Verify the resolved path starts with the base - if !canonical_resolved.starts_with(&canonical_base) { - return Err(FsError::OutOfScope); - } - - Ok(canonical_resolved) -} - -/// Resolve a path for create/write flows while securely materializing any -/// missing parent directories inside the scoped base directory. -pub fn safe_resolve_for_create(base: &Path, user_path: &str) -> Result { - let resolved = safe_resolve(base, user_path)?; - ensure_scoped_parent_dirs(base, &resolved)?; - ensure_not_symlink(&resolved)?; - Ok(resolved) -} - -/// Read a file's contents as bytes. -pub fn read_file(base: &Path, path: &str) -> Result, FsError> { - let resolved = safe_resolve(base, path)?; - Ok(fs::read(resolved)?) -} - -/// Read a file's contents as a UTF-8 string. -pub fn read_file_text(base: &Path, path: &str) -> Result { - let resolved = safe_resolve(base, path)?; - Ok(fs::read_to_string(resolved)?) -} - -/// Write data to a file, creating it if it doesn't exist. -pub fn write_file(base: &Path, path: &str, data: &[u8]) -> Result<(), FsError> { - let resolved = safe_resolve_for_create(base, path)?; - - if resolved.exists() { - fs::write(resolved, data)?; - return Ok(()); - } - - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(resolved)?; - file.write_all(data)?; - Ok(()) -} - -/// List entries in a directory. -pub fn read_dir(base: &Path, path: &str) -> Result, FsError> { - let resolved = safe_resolve(base, path)?; - let mut entries = Vec::new(); - for entry in fs::read_dir(resolved)? { - let entry = entry?; - if let Some(name) = entry.file_name().to_str() { - entries.push(name.to_string()); - } - } - Ok(entries) -} - -/// Get metadata for a path. -pub fn stat(base: &Path, path: &str) -> Result { - let resolved = safe_resolve(base, path)?; - let meta = fs::metadata(resolved)?; - - let modified_ms = meta - .modified() - .ok() - .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_secs_f64() * 1000.0) - .unwrap_or(0.0); - - let created_ms = meta - .created() - .ok() - .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_secs_f64() * 1000.0); - - Ok(FileInfo { - size: meta.len(), - is_file: meta.is_file(), - is_dir: meta.is_dir(), - readonly: meta.permissions().readonly(), - modified_ms, - created_ms, - }) -} - -/// Check whether a path exists within the scoped base directory. -pub fn exists(base: &Path, path: &str) -> Result { - let resolved = safe_resolve(base, path)?; - Ok(resolved.exists()) -} - /// File metadata info returned by stat(). #[derive(Debug, Clone)] pub struct FileInfo { @@ -183,152 +36,5 @@ pub struct FileInfo { pub created_ms: Option, } -/// Create a directory (and parents if needed). -pub fn mkdir(base: &Path, path: &str) -> Result<(), FsError> { - let resolved = safe_resolve(base, path)?; - ensure_scoped_directory(base, &resolved) -} - -/// Remove a file or directory. -/// If the path is a directory, removal is recursive. -pub fn remove(base: &Path, path: &str) -> Result<(), FsError> { - let resolved = safe_resolve(base, path)?; - ensure_not_symlink(&resolved)?; - let canonical_base = base - .canonicalize() - .map_err(|_| FsError::Security("Base directory does not exist".to_string()))?; - if resolved == canonical_base { - return Err(FsError::Security( - "Refusing to remove the base directory".to_string(), - )); - } - - if resolved.is_dir() { - Ok(fs::remove_dir_all(resolved)?) - } else { - Ok(fs::remove_file(resolved)?) - } -} - -/// Rename (move) a file or directory within the scope. -/// Both `from` and `to` must be within the base scope. -/// Uses `std::fs::rename` which is atomic on same-filesystem. -pub fn rename(base: &Path, from: &str, to: &str) -> Result<(), FsError> { - let resolved_from = safe_resolve(base, from)?; - let resolved_to = safe_resolve_for_create(base, to)?; - - if !resolved_from.exists() { - return Err(FsError::Io(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("source path does not exist: {from}"), - ))); - } - - if resolved_to.exists() { - return Err(FsError::Security(format!( - "FS_ALREADY_EXISTS: destination already exists: {to}" - ))); - } - - fs::rename(resolved_from, resolved_to)?; - Ok(()) -} - -/// Copy a file within the scope. -/// Both `from` and `to` must be within the base scope. -/// Only files can be copied; use mkdir + recursive copy for directories. -pub fn copy(base: &Path, from: &str, to: &str) -> Result<(), FsError> { - let resolved_from = safe_resolve(base, from)?; - let resolved_to = safe_resolve_for_create(base, to)?; - - if !resolved_from.exists() { - return Err(FsError::Io(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("source path does not exist: {from}"), - ))); - } - - if !resolved_from.is_file() { - return Err(FsError::Security( - "copy only supports files, not directories".to_string(), - )); - } - - if resolved_to.exists() { - return Err(FsError::Security(format!( - "FS_ALREADY_EXISTS: destination already exists: {to}" - ))); - } - - fs::copy(resolved_from, resolved_to)?; - Ok(()) -} - -fn ensure_scoped_directory(base: &Path, directory: &Path) -> Result<(), FsError> { - let canonical_base = canonical_base_dir(base)?; - let relative = directory - .strip_prefix(&canonical_base) - .map_err(|_| FsError::OutOfScope)?; - let mut current = canonical_base.clone(); - - for component in relative.components() { - current.push(component.as_os_str()); - match fs::symlink_metadata(¤t) { - Ok(metadata) => { - if metadata.file_type().is_symlink() { - return Err(FsError::Security(format!( - "symlink component is not allowed: '{}'", - current.display() - ))); - } - if !metadata.is_dir() { - return Err(FsError::Security(format!( - "path component is not a directory: '{}'", - current.display() - ))); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - fs::create_dir(¤t)?; - } - Err(error) => return Err(FsError::Io(error)), - } - - let canonical_current = current.canonicalize()?; - if !canonical_current.starts_with(&canonical_base) { - return Err(FsError::OutOfScope); - } - current = canonical_current; - } - - Ok(()) -} - -fn ensure_scoped_parent_dirs(base: &Path, resolved: &Path) -> Result<(), FsError> { - let Some(parent) = resolved.parent() else { - return Err(FsError::Security( - "Cannot resolve parent directory".to_string(), - )); - }; - ensure_scoped_directory(base, parent) -} - -fn ensure_not_symlink(path: &Path) -> Result<(), FsError> { - match fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() => Err(FsError::Security(format!( - "symlink targets are not allowed: '{}'", - path.display() - ))), - Ok(_) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(FsError::Io(error)), - } -} - -fn canonical_base_dir(base: &Path) -> Result { - base.canonicalize() - .map_err(|_| FsError::Security("Base directory does not exist".to_string())) -} - #[cfg(test)] mod tests; diff --git a/crates/volt-core/src/fs/resolve.rs b/crates/volt-core/src/fs/resolve.rs new file mode 100644 index 0000000..fe003b4 --- /dev/null +++ b/crates/volt-core/src/fs/resolve.rs @@ -0,0 +1,80 @@ +use std::path::{Path, PathBuf}; + +use crate::security::validate_path; + +use super::FsError; +use super::helpers::{ensure_not_symlink, ensure_scoped_parent_dirs}; + +/// Safely resolve a user-provided relative path against a base directory. +/// Rejects absolute paths, path traversal, and ensures the result is under base. +pub fn safe_resolve(base: &Path, user_path: &str) -> Result { + validate_path(user_path).map_err(FsError::Security)?; + + let canonical_base = base + .canonicalize() + .map_err(|_| FsError::Security("Base directory does not exist".to_string()))?; + let resolved = canonical_base.join(user_path); + + let canonical_resolved = if user_path.is_empty() || user_path == "." { + canonical_base.clone() + } else if resolved.exists() { + resolved.canonicalize()? + } else { + let parent = resolved + .parent() + .ok_or_else(|| FsError::Security("Cannot resolve parent directory".to_string()))?; + + if !parent.exists() { + let mut ancestor = parent.to_path_buf(); + let mut trailing_components = Vec::new(); + while !ancestor.exists() { + if let Some(name) = ancestor.file_name() { + trailing_components.push(name.to_os_string()); + } else { + return Err(FsError::Security( + "Cannot resolve path ancestor".to_string(), + )); + } + ancestor = ancestor + .parent() + .ok_or_else(|| FsError::Security("Cannot resolve path ancestor".to_string()))? + .to_path_buf(); + } + let mut canonical = ancestor.canonicalize()?; + for component in trailing_components.into_iter().rev() { + canonical.push(component); + } + if let Some(file_name) = resolved.file_name() { + canonical.push(file_name); + } + canonical + } else { + ensure_not_symlink(parent)?; + let canonical_parent = parent.canonicalize()?; + let file_name = resolved + .file_name() + .ok_or_else(|| FsError::Security("Invalid file name".to_string()))?; + canonical_parent.join(file_name) + } + }; + + if !canonical_resolved.starts_with(&canonical_base) { + return Err(FsError::OutOfScope); + } + + Ok(canonical_resolved) +} + +/// Resolve a path for create/write flows while securely materializing any +/// missing parent directories inside the scoped base directory. +/// +/// Built-in CRUD operations execute directly through a scoped directory handle +/// and do not need this path-returning helper. This remains part of the API +/// for callers such as `volt_db` that must hand a validated path to another +/// subsystem after the parent chain has been created safely. +pub fn safe_resolve_for_create(base: &Path, user_path: &str) -> Result { + let resolved = safe_resolve(base, user_path)?; + ensure_scoped_parent_dirs(base, &resolved)?; + ensure_not_symlink(&resolved)?; + Ok(resolved) +} diff --git a/crates/volt-core/src/fs/scoped.rs b/crates/volt-core/src/fs/scoped.rs new file mode 100644 index 0000000..92b223b --- /dev/null +++ b/crates/volt-core/src/fs/scoped.rs @@ -0,0 +1,229 @@ +use cap_std::fs::OpenOptions as CapOpenOptions; +use std::io::Write; +use std::path::Path; +use std::time::UNIX_EPOCH; + +use crate::security::validate_path; + +use super::helpers::{open_scoped_dir, scoped_path}; +use super::{FileInfo, FsError}; + +/// Read a file's contents as bytes. +pub fn read_file(base: &Path, path: &str) -> Result, FsError> { + validate_path(path).map_err(FsError::Security)?; + let (_, dir) = open_scoped_dir(base)?; + Ok(dir.read(scoped_path(path))?) +} + +/// Read a file's contents as a UTF-8 string. +pub fn read_file_text(base: &Path, path: &str) -> Result { + validate_path(path).map_err(FsError::Security)?; + let (_, dir) = open_scoped_dir(base)?; + Ok(dir.read_to_string(scoped_path(path))?) +} + +/// Write data to a file, creating it if it doesn't exist. +pub fn write_file(base: &Path, path: &str, data: &[u8]) -> Result<(), FsError> { + validate_path(path).map_err(FsError::Security)?; + let (_, dir) = open_scoped_dir(base)?; + if let Some(parent) = Path::new(path).parent() + && !parent.as_os_str().is_empty() + && parent != Path::new(".") + { + dir.create_dir_all(parent)?; + } + + let mut options = CapOpenOptions::new(); + let options = options.write(true).create(true).truncate(true); + let mut file = dir.open_with(path, options)?; + file.write_all(data)?; + Ok(()) +} + +/// List entries in a directory. +pub fn read_dir(base: &Path, path: &str) -> Result, FsError> { + validate_path(path).map_err(FsError::Security)?; + let (_, dir) = open_scoped_dir(base)?; + let mut entries = Vec::new(); + let read_dir = if path.is_empty() || path == "." { + dir.entries()? + } else { + dir.read_dir(path)? + }; + for entry in read_dir { + let entry = entry?; + if let Some(name) = entry.file_name().to_str() { + entries.push(name.to_string()); + } + } + Ok(entries) +} + +/// Get metadata for a path. +pub fn stat(base: &Path, path: &str) -> Result { + validate_path(path).map_err(FsError::Security)?; + let (_, dir) = open_scoped_dir(base)?; + let meta = if path.is_empty() || path == "." { + dir.dir_metadata()? + } else { + dir.metadata(path)? + }; + + let modified_ms = meta + .modified() + .ok() + .map(|time| time.into_std()) + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs_f64() * 1000.0) + .unwrap_or(0.0); + + let created_ms = meta + .created() + .ok() + .map(|time| time.into_std()) + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs_f64() * 1000.0); + + Ok(FileInfo { + size: meta.len(), + is_file: meta.is_file(), + is_dir: meta.is_dir(), + readonly: meta.permissions().readonly(), + modified_ms, + created_ms, + }) +} + +/// Check whether a path exists within the scoped base directory. +pub fn exists(base: &Path, path: &str) -> Result { + validate_path(path).map_err(FsError::Security)?; + let (_, dir) = open_scoped_dir(base)?; + if path.is_empty() || path == "." { + return Ok(true); + } + dir.try_exists(path).map_err(FsError::Io) +} + +/// Create a directory (and parents if needed). +pub fn mkdir(base: &Path, path: &str) -> Result<(), FsError> { + validate_path(path).map_err(FsError::Security)?; + let (_, dir) = open_scoped_dir(base)?; + dir.create_dir_all(scoped_path(path))?; + Ok(()) +} + +/// Remove a file or directory. +/// If the path is a directory, removal is recursive. +pub fn remove(base: &Path, path: &str) -> Result<(), FsError> { + validate_path(path).map_err(FsError::Security)?; + if path.is_empty() || path == "." { + return Err(FsError::Security( + "Refusing to remove the base directory".to_string(), + )); + } + + let (_, dir) = open_scoped_dir(base)?; + let metadata = dir.symlink_metadata(path)?; + if metadata.is_dir() { + Ok(dir.remove_dir_all(path)?) + } else { + Ok(dir.remove_file(path)?) + } +} + +/// Rename (move) a file or directory within the scope. +/// Both `from` and `to` must be within the base scope. +/// Uses `std::fs::rename` which is atomic on same-filesystem. +pub fn rename(base: &Path, from: &str, to: &str) -> Result<(), FsError> { + validate_path(from).map_err(FsError::Security)?; + validate_path(to).map_err(FsError::Security)?; + let (_, dir) = open_scoped_dir(base)?; + + if !dir.try_exists(from)? { + return Err(FsError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("source path does not exist: {from}"), + ))); + } + + if dir.try_exists(to)? { + return Err(FsError::Security(format!( + "FS_ALREADY_EXISTS: destination already exists: {to}" + ))); + } + + if let Some(parent) = Path::new(to).parent() + && !parent.as_os_str().is_empty() + && parent != Path::new(".") + { + dir.create_dir_all(parent)?; + } + dir.rename(from, &dir, to)?; + Ok(()) +} + +/// Rename (move) a file or directory within the scope, replacing the +/// destination if it already exists. +/// +/// This is currently used by internal storage writes. Callers should rely on +/// the scoped-handle confinement guarantees here, but not assume stronger +/// cross-platform atomic replacement semantics than the underlying OS rename +/// operation provides. +pub fn replace_file(base: &Path, from: &str, to: &str) -> Result<(), FsError> { + validate_path(from).map_err(FsError::Security)?; + validate_path(to).map_err(FsError::Security)?; + let (_, dir) = open_scoped_dir(base)?; + + if !dir.try_exists(from)? { + return Err(FsError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("source path does not exist: {from}"), + ))); + } + + if let Some(parent) = Path::new(to).parent() + && !parent.as_os_str().is_empty() + && parent != Path::new(".") + { + dir.create_dir_all(parent)?; + } + dir.rename(from, &dir, to)?; + Ok(()) +} + +/// Copy a file within the scope. +/// Both `from` and `to` must be within the base scope. +/// Only files can be copied; use mkdir + recursive copy for directories. +pub fn copy(base: &Path, from: &str, to: &str) -> Result<(), FsError> { + validate_path(from).map_err(FsError::Security)?; + validate_path(to).map_err(FsError::Security)?; + let (_, dir) = open_scoped_dir(base)?; + + if !dir.try_exists(from)? { + return Err(FsError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("source path does not exist: {from}"), + ))); + } + + if !dir.metadata(from)?.is_file() { + return Err(FsError::Security( + "copy only supports files, not directories".to_string(), + )); + } + + if dir.try_exists(to)? { + return Err(FsError::Security(format!( + "FS_ALREADY_EXISTS: destination already exists: {to}" + ))); + } + + if let Some(parent) = Path::new(to).parent() + && !parent.as_os_str().is_empty() + && parent != Path::new(".") + { + dir.create_dir_all(parent)?; + } + dir.copy(from, &dir, to)?; + Ok(()) +} diff --git a/crates/volt-core/src/fs/tests/mutation_ops.rs b/crates/volt-core/src/fs/tests/mutation_ops.rs index 8e26663..c9e44ab 100644 --- a/crates/volt-core/src/fs/tests/mutation_ops.rs +++ b/crates/volt-core/src/fs/tests/mutation_ops.rs @@ -1,5 +1,6 @@ use super::*; use std::env; +use std::fs; #[test] fn test_write_and_read() { diff --git a/crates/volt-core/src/fs/tests/path_resolution.rs b/crates/volt-core/src/fs/tests/path_resolution.rs index e7b9134..8510b53 100644 --- a/crates/volt-core/src/fs/tests/path_resolution.rs +++ b/crates/volt-core/src/fs/tests/path_resolution.rs @@ -1,5 +1,6 @@ use super::*; use std::env; +use std::fs; #[test] fn test_path_traversal_rejected() { diff --git a/crates/volt-core/src/grant_store.rs b/crates/volt-core/src/grant_store.rs index 0a050af..b56669f 100644 --- a/crates/volt-core/src/grant_store.rs +++ b/crates/volt-core/src/grant_store.rs @@ -11,9 +11,9 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Mutex; -use std::sync::atomic::{AtomicU64, Ordering}; use thiserror::Error; +use uuid::Uuid; #[derive(Error, Debug)] pub enum GrantError { @@ -30,7 +30,6 @@ struct GrantEntry { root_path: PathBuf, } -static GRANT_COUNTER: AtomicU64 = AtomicU64::new(0); static GRANT_STORE: Mutex>> = Mutex::new(None); fn with_store(f: F) -> R @@ -43,12 +42,7 @@ where } fn generate_grant_id() -> String { - let count = GRANT_COUNTER.fetch_add(1, Ordering::Relaxed); - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - format!("grant_{ts:x}_{count:x}") + format!("grant_{}", Uuid::new_v4()) } /// Create a new grant for the given directory path. @@ -59,10 +53,16 @@ pub fn create_grant(path: PathBuf) -> Result { if !path.is_dir() { return Err(GrantError::InvalidPath); } + let canonical_path = path.canonicalize().map_err(|_| GrantError::InvalidPath)?; let id = generate_grant_id(); with_store(|store| { - store.insert(id.clone(), GrantEntry { root_path: path }); + store.insert( + id.clone(), + GrantEntry { + root_path: canonical_path, + }, + ); }); Ok(id) } @@ -113,7 +113,7 @@ mod tests { assert!(id.starts_with("grant_")); let resolved = resolve_grant(&id).unwrap(); - assert_eq!(resolved, dir); + assert_eq!(resolved, dir.canonicalize().unwrap()); } #[test] diff --git a/crates/volt-core/src/ipc.rs b/crates/volt-core/src/ipc.rs index 35fc4be..94566fa 100644 --- a/crates/volt-core/src/ipc.rs +++ b/crates/volt-core/src/ipc.rs @@ -16,7 +16,8 @@ pub use self::protocol::{ pub use self::rate_limit::RateLimiter; pub use self::security::{IPC_MAX_REQUEST_BYTES, IpcError}; pub use self::webview::{ - event_script, ipc_init_script, payload_too_large_response_script, response_script, + IPC_MAX_RESPONSE_BYTES, event_script, ipc_init_script, payload_too_large_response_script, + response_script, }; /// Registry of IPC handlers mapped by method name. diff --git a/crates/volt-core/src/ipc/tests/scripts.rs b/crates/volt-core/src/ipc/tests/scripts.rs index fa2bbf7..0047616 100644 --- a/crates/volt-core/src/ipc/tests/scripts.rs +++ b/crates/volt-core/src/ipc/tests/scripts.rs @@ -4,8 +4,10 @@ use super::*; fn test_ipc_init_script_valid() { let script = ipc_init_script(); assert!(script.contains("window.__volt__")); - assert!(script.contains("window.__volt_response__")); - assert!(script.contains("window.__volt_event__")); + assert!(script.contains("Object.defineProperty(window, '__volt_response__'")); + assert!(script.contains("Object.defineProperty(window, '__volt_event__'")); + assert!(script.contains("writable: false")); + assert!(script.contains("configurable: false")); assert!(script.contains("response.errorCode")); assert!(script.contains("response.errorDetails")); } @@ -56,6 +58,13 @@ fn test_response_script_escapes_js_line_separators() { assert!(script.contains("\\u2029")); } +#[test] +fn test_response_script_escapes_null_bytes() { + let raw = "{\"id\":\"1\",\"result\":\"nul\0byte\"}"; + let script = response_script(raw); + assert!(script.contains("\\0")); +} + #[test] fn test_payload_too_large_response_script_preserves_request_id() { let script = payload_too_large_response_script(r#"{"id":"req-7","method":"demo","args":"x"}"#); diff --git a/crates/volt-core/src/ipc/webview.rs b/crates/volt-core/src/ipc/webview.rs index 6c35987..46fc1c3 100644 --- a/crates/volt-core/src/ipc/webview.rs +++ b/crates/volt-core/src/ipc/webview.rs @@ -2,6 +2,8 @@ use serde_json::Value as JsonValue; use super::{IPC_MAX_REQUEST_BYTES, IpcError, IpcResponse}; +pub const IPC_MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024; + /// Generate the JavaScript IPC initialization script injected into the WebView. /// This creates the `window.__volt__` API and the pending request tracking. pub fn ipc_init_script() -> String { @@ -75,43 +77,51 @@ pub fn ipc_init_script() -> String { }); // Response handler called from Rust via evaluate_script - window.__volt_response__ = function(responseJson) { - try { - var response = JSON.parse(responseJson); - var p = pending.get(response.id); - if (p) { - pending.delete(response.id); - if (response.error) { - var error = new Error(response.error); - if (response.errorCode) { - error.code = response.errorCode; + Object.defineProperty(window, '__volt_response__', { + value: function(responseJson) { + try { + var response = JSON.parse(responseJson); + var p = pending.get(response.id); + if (p) { + pending.delete(response.id); + if (response.error) { + var error = new Error(response.error); + if (response.errorCode) { + error.code = response.errorCode; + } + if (response.errorDetails !== undefined) { + error.details = response.errorDetails; + } + p.reject(error); + } else { + p.resolve(response.result); } - if (response.errorDetails !== undefined) { - error.details = response.errorDetails; - } - p.reject(error); - } else { - p.resolve(response.result); } + } catch (e) { + console.error('[volt] Failed to parse IPC response:', e); } - } catch (e) { - console.error('[volt] Failed to parse IPC response:', e); - } - }; + }, + writable: false, + configurable: false, + }); // Event handler called from Rust via evaluate_script - window.__volt_event__ = function(event, data) { - var set = listeners.get(event); - if (set) { - set.forEach(function(cb) { - try { - cb(data); - } catch (e) { - console.error('[volt] Event handler error:', e); - } - }); - } - }; + Object.defineProperty(window, '__volt_event__', { + value: function(event, data) { + var set = listeners.get(event); + if (set) { + set.forEach(function(cb) { + try { + cb(data); + } catch (e) { + console.error('[volt] Event handler error:', e); + } + }); + } + }, + writable: false, + configurable: false, + }); // Forward CSP violations to native logging so they are visible without DevTools. document.addEventListener('securitypolicyviolation', function(e) { @@ -183,6 +193,7 @@ fn escape_for_single_quoted_js(value: &str) -> String { match ch { '\\' => escaped.push_str("\\\\"), '\'' => escaped.push_str("\\'"), + '\0' => escaped.push_str("\\0"), '\n' => escaped.push_str("\\n"), '\r' => escaped.push_str("\\r"), '\u{2028}' => escaped.push_str("\\u2028"), diff --git a/crates/volt-core/src/security.rs b/crates/volt-core/src/security.rs index ecf20f2..0ccca60 100644 --- a/crates/volt-core/src/security.rs +++ b/crates/volt-core/src/security.rs @@ -1,5 +1,7 @@ //! Content Security Policy generation for WebView responses. +use unicode_normalization::UnicodeNormalization; + const VOLT_EMBEDDED_ORIGINS: &str = "volt://localhost http://volt.localhost https://volt.localhost"; /// Default CSP for production builds - strict, no unsafe-eval. @@ -48,7 +50,11 @@ fn sanitize_dev_server_origin(dev_server_origin: &str) -> Option<(String, String return None; } let host = parsed.host_str()?; - if host.contains(';') || host.contains('\n') || host.contains('\r') { + if host.contains(';') + || host.contains('\n') + || host.contains('\r') + || host.chars().any(|ch| ch.is_ascii_whitespace()) + { return None; } let mut http_origin = format!("{scheme}://{host}"); @@ -63,39 +69,40 @@ fn sanitize_dev_server_origin(dev_server_origin: &str) -> Option<(String, String /// Validate that a path string does not attempt directory traversal. pub fn validate_path(path: &str) -> Result<(), String> { + let normalized = path.nfc().collect::(); + + if normalized.contains('\0') { + return Err("Null bytes are not allowed in paths".to_string()); + } + // Reject absolute paths - if path.starts_with('/') || path.starts_with('\\') { + if normalized.starts_with('/') || normalized.starts_with('\\') { return Err("Absolute paths are not allowed".to_string()); } // Reject Windows drive letters - if path.len() >= 2 && path.as_bytes()[1] == b':' { + if normalized.len() >= 2 && normalized.as_bytes()[1] == b':' { return Err("Absolute paths are not allowed".to_string()); } // Reject path traversal - for component in path.split(['/', '\\']) { + for component in normalized.split(['/', '\\']) { if component == ".." { return Err("Path traversal (..) is not allowed".to_string()); } } // Reject Windows reserved device names - let base_name = path - .split(['/', '\\']) - .next_back() - .unwrap_or(path) - .split('.') - .next() - .unwrap_or(""); - let reserved = [ "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", ]; - if reserved.iter().any(|r| r.eq_ignore_ascii_case(base_name)) { - return Err(format!("Reserved device name '{base_name}' is not allowed")); + for component in normalized.split(['/', '\\']) { + let base_name = component.split('.').next().unwrap_or(""); + if reserved.iter().any(|r| r.eq_ignore_ascii_case(base_name)) { + return Err(format!("Reserved device name '{base_name}' is not allowed")); + } } Ok(()) @@ -210,6 +217,12 @@ mod tests { assert!(!csp.contains("script-src *")); } + #[test] + fn test_development_csp_rejects_whitespace_in_origin() { + let csp = development_csp("http://localhost :5173"); + assert!(!csp.contains("localhost :5173")); + } + #[test] fn test_production_csp_has_only_explicit_localhost_allowances() { let csp = production_csp(); @@ -271,6 +284,17 @@ mod tests { assert!(validate_path("connection.json").is_ok()); } + #[test] + fn test_path_rejects_reserved_names_in_any_component() { + assert!(validate_path("CON/file.txt").is_err()); + assert!(validate_path("safe/PRN/data.txt").is_err()); + } + + #[test] + fn test_path_rejects_null_bytes() { + assert!(validate_path("data\0file.txt").is_err()); + } + #[test] fn test_url_scheme_ftp_blocked() { assert!(validate_url_scheme("ftp://example.com/file.txt").is_err()); diff --git a/crates/volt-core/tests/security_fs_integration.rs b/crates/volt-core/tests/security_fs_integration.rs index c7a7478..46bac14 100644 --- a/crates/volt-core/tests/security_fs_integration.rs +++ b/crates/volt-core/tests/security_fs_integration.rs @@ -3,7 +3,10 @@ use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; -use volt_core::fs::{mkdir, read_dir, read_file_text, remove, safe_resolve, stat, write_file}; +use volt_core::fs::{ + copy, exists, mkdir, read_dir, read_file, read_file_text, remove, rename, replace_file, + safe_resolve, stat, write_file, +}; /// Helper to create a temporary test sandbox with a unique directory per call. fn create_sandbox() -> std::path::PathBuf { @@ -128,6 +131,7 @@ fn safe_resolve_rejects_symlink_escape() { let sandbox = create_sandbox(); let outside = create_sandbox(); let link_path = sandbox.join("escape-link"); + std::fs::write(outside.join("secret.txt"), "sensitive").unwrap(); #[cfg(unix)] { @@ -150,3 +154,60 @@ fn safe_resolve_rejects_symlink_escape() { cleanup(&sandbox); cleanup(&outside); } + +#[test] +fn fs_operations_reject_symlink_escape_targets() { + let sandbox = create_sandbox(); + let outside = create_sandbox(); + let link_path = sandbox.join("escape-link"); + let inside_source = sandbox.join("source.txt"); + let inside_replace = sandbox.join("replace-source.txt"); + std::fs::write(outside.join("secret.txt"), "outside-secret").unwrap(); + std::fs::write(&inside_source, "inside-source").unwrap(); + std::fs::write(&inside_replace, "replace-source").unwrap(); + + #[cfg(unix)] + { + std::os::unix::fs::symlink(&outside, &link_path).unwrap(); + } + + #[cfg(windows)] + { + if std::os::windows::fs::symlink_dir(&outside, &link_path).is_err() { + cleanup(&sandbox); + cleanup(&outside); + return; + } + } + + assert!(read_file(&sandbox, "escape-link/secret.txt").is_err()); + assert!(read_file_text(&sandbox, "escape-link/secret.txt").is_err()); + assert!(read_dir(&sandbox, "escape-link").is_err()); + assert!(stat(&sandbox, "escape-link/secret.txt").is_err()); + assert!(exists(&sandbox, "escape-link/secret.txt").is_err()); + assert!(write_file(&sandbox, "escape-link/secret.txt", b"pwned").is_err()); + assert!(mkdir(&sandbox, "escape-link/newdir").is_err()); + assert!(remove(&sandbox, "escape-link/secret.txt").is_err()); + assert!(rename(&sandbox, "source.txt", "escape-link/renamed.txt").is_err()); + assert!(copy(&sandbox, "source.txt", "escape-link/copied.txt").is_err()); + assert!(replace_file(&sandbox, "replace-source.txt", "escape-link/replaced.txt").is_err()); + assert_eq!( + std::fs::read_to_string(outside.join("secret.txt")).unwrap(), + "outside-secret" + ); + assert_eq!( + std::fs::read_to_string(&inside_source).unwrap(), + "inside-source" + ); + assert_eq!( + std::fs::read_to_string(&inside_replace).unwrap(), + "replace-source" + ); + assert!(!outside.join("copied.txt").exists()); + assert!(!outside.join("renamed.txt").exists()); + assert!(!outside.join("replaced.txt").exists()); + assert!(!outside.join("newdir").exists()); + + cleanup(&sandbox); + cleanup(&outside); +} diff --git a/crates/volt-runner/src/ipc_bridge/dispatch.rs b/crates/volt-runner/src/ipc_bridge/dispatch.rs index 436ab28..cf2c09d 100644 --- a/crates/volt-runner/src/ipc_bridge/dispatch.rs +++ b/crates/volt-runner/src/ipc_bridge/dispatch.rs @@ -68,14 +68,24 @@ pub(super) fn dispatch_ipc_task( request_id: &str, timeout: Duration, ) -> IpcResponse { + if (matches_fast_path(raw) || matches_plugin_route(raw)) + && let Err(error) = runtime_client.check_ipc_rate_limit() + { + return IpcResponse::error_with_code( + request_id.to_string(), + error, + IPC_HANDLER_ERROR_CODE.to_string(), + ); + } + if let Some(response) = try_dispatch_native_fast_path(raw) { - return rate_limit_response(runtime_client, request_id, response); + return response; } if let Some(plugin_manager) = plugin_manager && let Some(response) = try_dispatch_plugin_route(plugin_manager, raw, timeout) { - return rate_limit_response(runtime_client, request_id, response); + return response; } runtime_client @@ -102,6 +112,33 @@ pub(super) fn dispatch_ipc_task( }) } +fn matches_fast_path(raw: &str) -> bool { + let Some(method) = extract_method(raw) else { + return false; + }; + method == "__volt_internal:csp-violation" + || method == crate::modules::volt_bench::DATA_PROFILE_CHANNEL + || method == crate::modules::volt_bench::DATA_QUERY_CHANNEL + || method == crate::modules::volt_bench::WORKFLOW_RUN_CHANNEL +} + +fn matches_plugin_route(raw: &str) -> bool { + extract_method(raw) + .map(|method| method.starts_with("plugin:")) + .unwrap_or(false) +} + +fn extract_method(raw: &str) -> Option { + serde_json::from_str::(raw) + .ok() + .and_then(|value| { + value + .get("method") + .and_then(JsonValue::as_str) + .map(str::to_string) + }) +} + fn try_dispatch_plugin_route( plugin_manager: &PluginManager, raw: &str, @@ -118,18 +155,3 @@ fn try_dispatch_plugin_route( let request: IpcRequest = serde_json::from_value(parsed).ok()?; plugin_manager.handle_ipc_request(&request, timeout) } - -fn rate_limit_response( - runtime_client: &JsRuntimePoolClient, - request_id: &str, - response: IpcResponse, -) -> IpcResponse { - match runtime_client.check_ipc_rate_limit() { - Ok(()) => response, - Err(error) => IpcResponse::error_with_code( - request_id.to_string(), - error, - IPC_HANDLER_ERROR_CODE.to_string(), - ), - } -} diff --git a/crates/volt-runner/src/ipc_bridge/response.rs b/crates/volt-runner/src/ipc_bridge/response.rs index aba507e..bfcc631 100644 --- a/crates/volt-runner/src/ipc_bridge/response.rs +++ b/crates/volt-runner/src/ipc_bridge/response.rs @@ -1,21 +1,12 @@ use volt_core::command::{self, AppCommand}; -use volt_core::ipc::{IPC_HANDLER_ERROR_CODE, IpcResponse, response_script}; +use volt_core::ipc::{ + IPC_HANDLER_ERROR_CODE, IPC_MAX_RESPONSE_BYTES, IpcResponse, response_script, +}; pub(super) fn send_response_to_window(js_window_id: &str, response: IpcResponse) { let request_id = response.id.clone(); - let response_json = match serde_json::to_string(&response) { - Ok(serialized) => serialized, - Err(error) => { - let fallback = IpcResponse::error_with_code( - response.id, - format!("failed to serialize IPC response: {error}"), - IPC_HANDLER_ERROR_CODE.to_string(), - ); - match serde_json::to_string(&fallback) { - Ok(serialized) => serialized, - Err(_) => return, - } - } + let Some(response_json) = response_json_for_window(response) else { + return; }; let script = response_script(&response_json); @@ -31,3 +22,61 @@ pub(super) fn send_response_to_window(js_window_id: &str, response: IpcResponse) ); } } + +fn response_json_for_window(response: IpcResponse) -> Option { + let response_id = response.id.clone(); + let mut response_json = match serde_json::to_string(&response) { + Ok(serialized) => serialized, + Err(error) => { + let fallback = IpcResponse::error_with_code( + response_id.clone(), + format!("failed to serialize IPC response: {error}"), + IPC_HANDLER_ERROR_CODE.to_string(), + ); + match serde_json::to_string(&fallback) { + Ok(serialized) => serialized, + Err(_) => return None, + } + } + }; + if response_json.len() > IPC_MAX_RESPONSE_BYTES { + let fallback = IpcResponse::error_with_details( + response_id, + format!( + "IPC response too large ({} bytes > {} bytes)", + response_json.len(), + IPC_MAX_RESPONSE_BYTES + ), + IPC_HANDLER_ERROR_CODE.to_string(), + serde_json::json!({ + "responseBytes": response_json.len(), + "maxResponseBytes": IPC_MAX_RESPONSE_BYTES + }), + ); + response_json = match serde_json::to_string(&fallback) { + Ok(serialized) => serialized, + Err(_) => return None, + }; + } + Some(response_json) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn oversized_responses_are_replaced_with_error_payloads() { + let oversized = IpcResponse::success( + "req-1".to_string(), + serde_json::json!({ + "payload": "x".repeat(IPC_MAX_RESPONSE_BYTES + 1024) + }), + ); + + let serialized = response_json_for_window(oversized).expect("serialized"); + assert!(serialized.contains("IPC response too large")); + assert!(serialized.contains("maxResponseBytes")); + assert!(serialized.len() < IPC_MAX_RESPONSE_BYTES); + } +} diff --git a/crates/volt-runner/src/plugin_manager/host_api_access.rs b/crates/volt-runner/src/plugin_manager/host_api_access.rs index e800b0f..6af9363 100644 --- a/crates/volt-runner/src/plugin_manager/host_api_access.rs +++ b/crates/volt-runner/src/plugin_manager/host_api_access.rs @@ -165,9 +165,7 @@ impl AccessRequestOptions { title: object .get("title") .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string), + .and_then(sanitize_dialog_request_title), directory: object .get("directory") .and_then(Value::as_bool) @@ -180,6 +178,40 @@ impl AccessRequestOptions { } } +const MAX_ACCESS_TITLE_CHARS: usize = 100; + +fn sanitize_dialog_request_title(title: &str) -> Option { + let sanitized = title + .chars() + .filter(|ch| !is_dialog_control_character(*ch)) + .take(MAX_ACCESS_TITLE_CHARS) + .collect::(); + let trimmed = sanitized.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn is_dialog_control_character(ch: char) -> bool { + ch.is_control() + || matches!( + ch, + '\u{200E}' + | '\u{200F}' + | '\u{202A}' + | '\u{202B}' + | '\u{202C}' + | '\u{202D}' + | '\u{202E}' + | '\u{2066}' + | '\u{2067}' + | '\u{2068}' + | '\u{2069}' + ) +} + fn format_dialog_title(plugin_name: &str, options: &AccessRequestOptions) -> String { let resource = if options.directory { "folder" } else { "file" }; match &options.title { diff --git a/crates/volt-runner/src/plugin_manager/host_api_storage.rs b/crates/volt-runner/src/plugin_manager/host_api_storage.rs index 8bbb0c3..4d9bf0a 100644 --- a/crates/volt-runner/src/plugin_manager/host_api_storage.rs +++ b/crates/volt-runner/src/plugin_manager/host_api_storage.rs @@ -12,6 +12,7 @@ const STORAGE_DIR: &str = "storage"; const STORAGE_INDEX_FILE: &str = "_index.json"; const STORAGE_MAX_KEY_BYTES: usize = 256; const STORAGE_MAX_VALUE_BYTES: usize = 1024 * 1024; +const STORAGE_MAX_TOTAL_BYTES: u64 = 100 * 1024 * 1024; impl PluginManager { pub(super) fn handle_storage_request( @@ -105,6 +106,7 @@ impl PluginStorage { } fn set(&mut self, key: String, value: String) -> Result<(), PluginRuntimeError> { + self.ensure_within_quota(&key, value.len() as u64)?; let hash = hash_key(&key); write_bytes_atomic(&self.root, &value_path(&hash), value.as_bytes()) .map_err(storage_error)?; @@ -133,6 +135,50 @@ impl PluginStorage { fn keys(&self) -> Vec { self.index.entries.keys().cloned().collect() } + + fn ensure_within_quota( + &self, + key: &str, + next_value_bytes: u64, + ) -> Result<(), PluginRuntimeError> { + let current_total = self.total_value_bytes()?; + let replaced_bytes = self.value_bytes_for_key(key)?; + let projected_total = current_total + .saturating_sub(replaced_bytes) + .saturating_add(next_value_bytes); + if projected_total > STORAGE_MAX_TOTAL_BYTES { + return Err(storage_error(format!( + "storage quota exceeded ({} bytes > {} bytes)", + projected_total, STORAGE_MAX_TOTAL_BYTES + ))); + } + Ok(()) + } + + fn total_value_bytes(&self) -> Result { + let mut total = 0_u64; + for hash in self.index.entries.values() { + let path = self.root.join(value_path(hash)); + match std::fs::metadata(&path) { + Ok(metadata) => total = total.saturating_add(metadata.len()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(storage_error(error.to_string())), + } + } + Ok(total) + } + + fn value_bytes_for_key(&self, key: &str) -> Result { + let Some(hash) = self.index.entries.get(key) else { + return Ok(0); + }; + let path = self.root.join(value_path(hash)); + match std::fs::metadata(path) { + Ok(metadata) => Ok(metadata.len()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0), + Err(error) => Err(storage_error(error.to_string())), + } + } } #[derive(Debug, Default, Serialize, Deserialize)] @@ -142,7 +188,16 @@ struct StorageIndex { fn load_index(root: &Path) -> Result { match volt_core::fs::read_file_text(root, STORAGE_INDEX_FILE) { - Ok(contents) => serde_json::from_str(&contents).map_err(|error| error.to_string()), + Ok(contents) => match serde_json::from_str(&contents) { + Ok(index) => Ok(index), + Err(error) => { + tracing::warn!( + storage_root = %root.display(), + "plugin storage index is corrupted; rebuilding from an empty index: {error}" + ); + Ok(StorageIndex::default()) + } + }, Err(volt_core::fs::FsError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => { Ok(StorageIndex::default()) } @@ -173,7 +228,7 @@ fn reconcile_index(root: &Path, index: &mut StorageIndex) -> Result<(), String> let Some(name) = entry.file_name().to_str().map(str::to_string) else { continue; }; - if name.ends_with(".val") && !expected.contains(&name) { + if (name.ends_with(".val") && !expected.contains(&name)) || name.ends_with(".tmp") { volt_core::fs::remove(root, &name).map_err(|error| error.to_string())?; changed = true; } @@ -186,14 +241,10 @@ fn reconcile_index(root: &Path, index: &mut StorageIndex) -> Result<(), String> fn write_bytes_atomic(root: &Path, relative_path: &str, data: &[u8]) -> Result<(), String> { let temp_path = temp_path(relative_path); - let temp_resolved = volt_core::fs::safe_resolve_for_create(root, &temp_path) - .map_err(|error| error.to_string())?; - let final_resolved = volt_core::fs::safe_resolve_for_create(root, relative_path) - .map_err(|error| error.to_string())?; - std::fs::write(&temp_resolved, data).map_err(|error| error.to_string())?; - if let Err(error) = replace_path_atomic(&temp_resolved, &final_resolved) { - let _ = std::fs::remove_file(&temp_resolved); - return Err(error); + volt_core::fs::write_file(root, &temp_path, data).map_err(|error| error.to_string())?; + if let Err(error) = volt_core::fs::replace_file(root, &temp_path, relative_path) { + let _ = volt_core::fs::remove(root, &temp_path); + return Err(error.to_string()); } Ok(()) } @@ -261,46 +312,3 @@ fn storage_error(message: impl Into) -> PluginRuntimeError { message: message.into(), } } - -#[cfg(windows)] -fn replace_path_atomic(from: &Path, to: &Path) -> Result<(), String> { - use std::ffi::OsStr; - use std::os::windows::ffi::OsStrExt; - - const MOVEFILE_REPLACE_EXISTING: u32 = 0x1; - const MOVEFILE_WRITE_THROUGH: u32 = 0x8; - - #[link(name = "Kernel32")] - unsafe extern "system" { - fn MoveFileExW( - lp_existing_file_name: *const u16, - lp_new_file_name: *const u16, - dw_flags: u32, - ) -> i32; - } - - let wide = |path: &Path| -> Vec { - OsStr::new(path.as_os_str()) - .encode_wide() - .chain(std::iter::once(0)) - .collect() - }; - let from_wide = wide(from); - let to_wide = wide(to); - let status = unsafe { - MoveFileExW( - from_wide.as_ptr(), - to_wide.as_ptr(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) - }; - if status == 0 { - return Err(std::io::Error::last_os_error().to_string()); - } - Ok(()) -} - -#[cfg(not(windows))] -fn replace_path_atomic(from: &Path, to: &Path) -> Result<(), String> { - std::fs::rename(from, to).map_err(|error| error.to_string()) -} diff --git a/crates/volt-runner/src/plugin_manager/process/io.rs b/crates/volt-runner/src/plugin_manager/process/io.rs index d9c4ce2..466cd41 100644 --- a/crates/volt-runner/src/plugin_manager/process/io.rs +++ b/crates/volt-runner/src/plugin_manager/process/io.rs @@ -10,6 +10,7 @@ use super::wire::{WireMessage, WireMessageType}; use crate::plugin_manager::{ExitListener, MessageListener, ProcessExitInfo}; const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024; +const MAX_STDERR_CAPTURE_BYTES: usize = 256 * 1024; pub(super) struct ChildPluginProcessInner { pub(super) child: Mutex, @@ -158,8 +159,7 @@ pub(super) fn spawn_stderr_reader( let _ = thread::Builder::new() .name("volt-plugin-host-stderr".to_string()) .spawn(move || { - let mut captured = String::new(); - let _ = stderr.read_to_string(&mut captured); + let captured = read_bounded_stderr(&mut stderr); if let Ok(mut buffer) = stderr_buffer.lock() { *buffer = captured; } @@ -281,10 +281,51 @@ fn read_wire_message(reader: &mut BufReader) -> std::io::Result String { + let mut captured = Vec::with_capacity(4096); + let mut chunk = [0_u8; 8192]; + let mut truncated = false; + + loop { + match stderr.read(&mut chunk) { + Ok(0) => break, + Ok(read) => { + let remaining = MAX_STDERR_CAPTURE_BYTES.saturating_sub(captured.len()); + if remaining == 0 { + truncated = true; + break; + } + let to_copy = read.min(remaining); + captured.extend_from_slice(&chunk[..to_copy]); + if to_copy < read { + truncated = true; + break; + } + } + Err(_) => break, + } + } + + let mut text = String::from_utf8_lossy(&captured).into_owned(); + if truncated { + text.push_str("\n[volt] plugin stderr truncated at 262144 bytes"); + } + text +} #[cfg(test)] mod tests { use super::*; + #[test] + fn bounded_stderr_reader_caps_capture_size() { + let oversized = vec![b'x'; MAX_STDERR_CAPTURE_BYTES + 1024]; + let mut reader = std::io::Cursor::new(oversized); + let captured = read_bounded_stderr(&mut reader); + + assert!(captured.len() >= MAX_STDERR_CAPTURE_BYTES); + assert!(captured.contains("plugin stderr truncated")); + } + #[test] fn drain_waiters_disconnects_pending_receivers() { let waiters = Mutex::new(HashMap::new()); diff --git a/crates/volt-runner/src/plugin_manager/tests/request_access.rs b/crates/volt-runner/src/plugin_manager/tests/request_access.rs index 2c8e6c7..6fdf344 100644 --- a/crates/volt-runner/src/plugin_manager/tests/request_access.rs +++ b/crates/volt-runner/src/plugin_manager/tests/request_access.rs @@ -138,3 +138,28 @@ fn request_access_returns_null_when_user_cancels() { assert_eq!(response.payload, Some(serde_json::Value::Null)); } + +#[test] +fn request_access_sanitizes_dialog_titles_from_plugins() { + let _guard = lock_grant_state(); + let selected_root = TempDir::new("selected-folder-sanitized"); + let selected_path = selected_root.join("picked"); + std::fs::create_dir_all(&selected_path).expect("selected dir"); + let picker = FakeAccessPicker::from_responses(vec![Ok(Some(selected_path))]); + let seen = picker.seen.clone(); + let manager = manager_for_access_tests(picker); + let spoofed_title = format!("Hello\u{202E}\n{}", "x".repeat(120)); + + let _ = plugin_request( + &manager, + "acme.search", + "plugin:request-access", + json!({ "title": spoofed_title, "directory": true }), + ); + + let seen = seen.lock().expect("seen"); + assert_eq!(seen.len(), 1); + assert!(!seen[0].title.contains('\n')); + assert!(!seen[0].title.contains('\u{202E}')); + assert!(seen[0].title.len() <= "Plugin 'Acme Search' wants to access a folder: ".len() + 100); +} diff --git a/crates/volt-runner/src/plugin_manager/tests/request_runtime.rs b/crates/volt-runner/src/plugin_manager/tests/request_runtime.rs index 6654270..f3216ae 100644 --- a/crates/volt-runner/src/plugin_manager/tests/request_runtime.rs +++ b/crates/volt-runner/src/plugin_manager/tests/request_runtime.rs @@ -162,8 +162,6 @@ fn watchdog_kills_after_two_missed_heartbeats() { }, Duration::from_millis(50), ); - thread::sleep(Duration::from_millis(60)); - assert!(wait_for_flag(killed.as_ref(), Duration::from_millis(200))); assert_eq!( manager diff --git a/crates/volt-runner/src/plugin_manager/tests/storage.rs b/crates/volt-runner/src/plugin_manager/tests/storage.rs index 44aeca3..60ecbe3 100644 --- a/crates/volt-runner/src/plugin_manager/tests/storage.rs +++ b/crates/volt-runner/src/plugin_manager/tests/storage.rs @@ -149,10 +149,12 @@ fn storage_reconciles_orphan_value_files_on_first_access() { let storage_root = storage_root(&manager); std::fs::create_dir_all(&storage_root).expect("storage dir"); std::fs::write(storage_root.join("orphan.val"), "orphan").expect("orphan"); + std::fs::write(storage_root.join("stale.tmp"), "temp").expect("temp"); let _ = storage_request(&manager, "plugin:storage:keys", json!({})); assert!(!storage_root.join("orphan.val").exists()); + assert!(!storage_root.join("stale.tmp").exists()); } #[test] @@ -182,3 +184,39 @@ fn storage_rejects_oversized_keys_values_and_traversal() { .contains("path traversal") ); } + +#[test] +fn storage_recovers_from_corrupted_index() { + let manager = manager_for_storage_tests(); + let storage_root = storage_root(&manager); + std::fs::create_dir_all(&storage_root).expect("storage dir"); + std::fs::write(storage_root.join("_index.json"), "{not-json").expect("corrupt index"); + + assert_eq!( + storage_request(&manager, "plugin:storage:keys", json!({})), + json!([]) + ); +} + +#[test] +fn storage_rejects_writes_that_exceed_plugin_quota() { + let manager = manager_for_storage_tests(); + let quota_busting_value = "v".repeat(1024 * 1024); + + for index in 0..100 { + let _ = storage_request( + &manager, + "plugin:storage:set", + json!({ "key": format!("key-{index}"), "value": quota_busting_value }), + ); + } + + assert!( + storage_error( + &manager, + "plugin:storage:set", + json!({ "key": "overflow", "value": "boom" }), + ) + .contains("storage quota exceeded") + ); +} diff --git a/docs/api/fs.md b/docs/api/fs.md index 73fb873..1e50815 100644 --- a/docs/api/fs.md +++ b/docs/api/fs.md @@ -191,6 +191,6 @@ interface FileInfo { Paths are validated at two layers: 1. **TypeScript layer** — Rejects absolute paths (`/`, `\`, drive letters) and `..` traversal -2. **Rust layer** — `safe_resolve()` canonicalizes both the base and resolved paths, then verifies the result is under the base directory. Also blocks Windows reserved device names (`CON`, `NUL`, `COM1`, etc.) and symlink escapes +2. **Rust layer** — `safe_resolve()` canonicalizes both the base and resolved paths, then verifies the result is under the base directory. The actual built-in CRUD operations then execute relative to an opened scoped directory handle, preventing symlink-swapped paths from escaping after validation. Windows reserved device names (`CON`, `NUL`, `COM1`, etc.) and symlink escapes are blocked. -Grant tokens are stored in a global `HashMap` protected by a Mutex. `bindScope` validates the grant exists before creating the scoped handle — invalid or expired grant IDs are rejected. +Grant tokens are stored in a global `HashMap` protected by a Mutex. Grant IDs are opaque random tokens, and the stored grant root is canonicalized when the grant is created. `bindScope` validates the grant exists before creating the scoped handle — invalid or expired grant IDs are rejected. diff --git a/docs/architecture.md b/docs/architecture.md index 2e956c5..adea4e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,7 +55,7 @@ | `webview` | Navigation policy, origin whitelisting, `WebViewConfig` | | `ipc` | `IpcRegistry`, `IpcRequest`/`IpcResponse`, `RateLimiter`, prototype pollution guards | | `security` | CSP generation (prod/dev), path validation, URL scheme validation, reserved device name blocking | -| `fs` | `safe_resolve()` with canonicalization, sandboxed CRUD operations | +| `fs` | `safe_resolve()` with canonicalization plus capability-scoped sandboxed CRUD operations | | `embed` | `AssetBundle` (serialize/deserialize/serve), MIME type detection, `volt://` protocol handler | | `menu` | `MenuItemConfig`, role-based predefined items, `build_menu()` | | `tray` | `TrayConfig` for system tray icons | diff --git a/docs/security.md b/docs/security.md index ba7ee67..4242bee 100644 --- a/docs/security.md +++ b/docs/security.md @@ -63,6 +63,8 @@ All filesystem operations go through `safe_resolve()`, which enforces: 4. **Reserved device names** - Windows device names (`CON`, `PRN`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`) are blocked to prevent device access attacks 5. **Scoped create path checks** - Parent directories for create/write flows are materialized inside the sandbox one component at a time, and symlink escapes are rejected before a new file is created +After validation, Volt performs file operations relative to an opened scoped base-directory handle rather than handing the resolved string path directly back to `std::fs`. This closes the validate-then-open gap for the built-in sandboxed CRUD operations. + The TypeScript `fs` module adds a second validation layer before calling into Rust, providing defense-in-depth. ## IPC Security @@ -85,13 +87,14 @@ A sliding-window rate limiter (default: 1000 requests/second) protects against I IPC uses bounded load-shedding to prevent memory growth under abusive traffic: - Payload size is capped (`256 KiB`); oversized messages are rejected with `IPC_PAYLOAD_TOO_LARGE` +- Response scripts are capped (`16 MiB`); oversized responses are replaced with a structured IPC error before `evaluate_script()` - Per-window in-flight IPC processing is capped (`32` by default in dev bridge); overflow is rejected with `IPC_IN_FLIGHT_LIMIT` - Renderer pending IPC map is bounded (`128` pending requests) to avoid unbounded queue buildup - Bridge workers enforce end-to-end handler timeouts so a wedged synchronous handler cannot stall the IPC queue indefinitely ### Response Escaping -IPC responses are embedded in JavaScript via `evaluate_script()`. All response JSON is escaped (backslashes, single quotes, newlines) to prevent injection through crafted payloads. +IPC responses are embedded in JavaScript via `evaluate_script()`. All response JSON is escaped (backslashes, single quotes, null bytes, and line terminators) to prevent injection through crafted payloads. The injected `window.__volt_response__` and `window.__volt_event__` handlers are installed as non-writable, non-configurable properties. ## Navigation Whitelist diff --git a/packages/volt/src/__tests__/ipc.test.ts b/packages/volt/src/__tests__/ipc.test.ts index ab9ed8b..1c124c4 100644 --- a/packages/volt/src/__tests__/ipc.test.ts +++ b/packages/volt/src/__tests__/ipc.test.ts @@ -45,6 +45,12 @@ describe('ipcMain', () => { ); }); + it('handle rejects reserved internal Volt channels', () => { + expect(() => ipcMain.handle('__volt_internal:csp-violation', () => 'nope')).toThrow( + 'reserved by Volt', + ); + }); + it('removeHandler removes a handler', () => { ipcMain.handle('test-channel', () => 'x'); ipcMain.removeHandler('test-channel'); diff --git a/packages/volt/src/ipc.ts b/packages/volt/src/ipc.ts index d081c80..709f4a1 100644 --- a/packages/volt/src/ipc.ts +++ b/packages/volt/src/ipc.ts @@ -6,7 +6,7 @@ type IpcHandler = (args: unknown) => Promise | unknown; const handlers = new Map(); -const RESERVED_IPC_PREFIX = 'volt:'; +const RESERVED_IPC_PREFIXES = ['volt:', '__volt_internal:']; export type IpcErrorCode = | 'IPC_HANDLER_NOT_FOUND' @@ -217,7 +217,7 @@ function assertNonReservedChannel(channel: string): void { if (typeof channel !== 'string') { throw new Error('IPC handler channel must be a string'); } - if (channel.trim().startsWith(RESERVED_IPC_PREFIX)) { + if (RESERVED_IPC_PREFIXES.some((prefix) => channel.trim().startsWith(prefix))) { throw new Error(`IPC channel is reserved by Volt: ${channel.trim()}`); } }