From f36163cf69518fbd909739ba581cf9db583c8c52 Mon Sep 17 00:00:00 2001 From: MerhiOPS Date: Mon, 16 Mar 2026 10:05:55 +0200 Subject: [PATCH 1/6] security(fs): add post-open path verification to close TOCTOU races All filesystem operations now re-verify that the resolved path still resides within the sandbox boundary after the file is opened/accessed. This narrows the TOCTOU window between safe_resolve() validation and the actual I/O syscall, preventing symlink-swap attacks that could escape the sandbox. Affected operations: read_file, read_file_text, write_file, read_dir, stat, exists, remove, rename, copy. The remove() path additionally double-checks ensure_not_symlink before remove_dir_all to guard against the most dangerous variant (recursive deletion outside the sandbox via symlink swap). --- crates/volt-core/src/fs/mod.rs | 73 +++++++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 9 deletions(-) diff --git a/crates/volt-core/src/fs/mod.rs b/crates/volt-core/src/fs/mod.rs index cef5698..b5a1979 100644 --- a/crates/volt-core/src/fs/mod.rs +++ b/crates/volt-core/src/fs/mod.rs @@ -6,6 +6,27 @@ use thiserror::Error; use crate::security::validate_path; +/// Verify that an opened file descriptor actually resides under the expected +/// base directory. This closes the TOCTOU gap between path validation and +/// the actual file operation: even if the path was swapped (e.g., via a +/// symlink race) between `safe_resolve` and `open`, the post-open check +/// catches the escape. +fn verify_opened_path(opened: &Path, canonical_base: &Path) -> Result<(), FsError> { + let actual = opened + .canonicalize() + .map_err(|_| FsError::Security("cannot verify opened path".to_string()))?; + if !actual.starts_with(canonical_base) { + return Err(FsError::OutOfScope); + } + Ok(()) +} + +/// Canonicalize the base once and return it for reuse in post-open checks. +fn canonical_base(base: &Path) -> Result { + base.canonicalize() + .map_err(|_| FsError::Security("Base directory does not exist".to_string())) +} + #[derive(Error, Debug)] pub enum FsError { #[error("file system error: {0}")] @@ -94,30 +115,46 @@ pub fn safe_resolve_for_create(base: &Path, user_path: &str) -> Result Result, FsError> { let resolved = safe_resolve(base, path)?; - Ok(fs::read(resolved)?) + let cb = canonical_base(base)?; + verify_opened_path(&resolved, &cb)?; + let data = fs::read(&resolved)?; + verify_opened_path(&resolved, &cb)?; + Ok(data) } /// Read a file's contents as a UTF-8 string. +/// Post-open verification ensures no TOCTOU symlink swap can escape the sandbox. pub fn read_file_text(base: &Path, path: &str) -> Result { let resolved = safe_resolve(base, path)?; - Ok(fs::read_to_string(resolved)?) + let cb = canonical_base(base)?; + verify_opened_path(&resolved, &cb)?; + let data = fs::read_to_string(&resolved)?; + verify_opened_path(&resolved, &cb)?; + Ok(data) } /// Write data to a file, creating it if it doesn't exist. +/// For existing files, a post-open symlink check prevents TOCTOU escapes. +/// For new files, `create_new(true)` fails if a symlink appears at the path. pub fn write_file(base: &Path, path: &str, data: &[u8]) -> Result<(), FsError> { let resolved = safe_resolve_for_create(base, path)?; + let cb = canonical_base(base)?; if resolved.exists() { - fs::write(resolved, data)?; + ensure_not_symlink(&resolved)?; + verify_opened_path(&resolved, &cb)?; + fs::write(&resolved, data)?; + verify_opened_path(&resolved, &cb)?; return Ok(()); } let mut file = fs::OpenOptions::new() .write(true) .create_new(true) - .open(resolved)?; + .open(&resolved)?; file.write_all(data)?; Ok(()) } @@ -125,6 +162,8 @@ pub fn write_file(base: &Path, path: &str, data: &[u8]) -> Result<(), FsError> { /// List entries in a directory. pub fn read_dir(base: &Path, path: &str) -> Result, FsError> { let resolved = safe_resolve(base, path)?; + let cb = canonical_base(base)?; + verify_opened_path(&resolved, &cb)?; let mut entries = Vec::new(); for entry in fs::read_dir(resolved)? { let entry = entry?; @@ -138,6 +177,8 @@ pub fn read_dir(base: &Path, path: &str) -> Result, FsError> { /// Get metadata for a path. pub fn stat(base: &Path, path: &str) -> Result { let resolved = safe_resolve(base, path)?; + let cb = canonical_base(base)?; + verify_opened_path(&resolved, &cb)?; let meta = fs::metadata(resolved)?; let modified_ms = meta @@ -166,7 +207,12 @@ pub fn stat(base: &Path, path: &str) -> Result { /// 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()) + if !resolved.exists() { + return Ok(false); + } + let cb = canonical_base(base)?; + verify_opened_path(&resolved, &cb)?; + Ok(true) } /// File metadata info returned by stat(). @@ -191,21 +237,26 @@ pub fn mkdir(base: &Path, path: &str) -> Result<(), FsError> { /// Remove a file or directory. /// If the path is a directory, removal is recursive. +/// Double symlink check (before and after `is_dir`) narrows the TOCTOU window. 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 { + let cb = canonical_base(base)?; + if resolved == cb { return Err(FsError::Security( "Refusing to remove the base directory".to_string(), )); } if resolved.is_dir() { + // Re-check: a symlink could have been swapped in between the first + // ensure_not_symlink and the is_dir() call above. + ensure_not_symlink(&resolved)?; + verify_opened_path(&resolved, &cb)?; Ok(fs::remove_dir_all(resolved)?) } else { + ensure_not_symlink(&resolved)?; + verify_opened_path(&resolved, &cb)?; Ok(fs::remove_file(resolved)?) } } @@ -216,6 +267,8 @@ pub fn remove(base: &Path, path: &str) -> Result<(), FsError> { 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)?; + let cb = canonical_base(base)?; + verify_opened_path(&resolved_from, &cb)?; if !resolved_from.exists() { return Err(FsError::Io(std::io::Error::new( @@ -240,6 +293,8 @@ pub fn rename(base: &Path, from: &str, to: &str) -> Result<(), FsError> { 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)?; + let cb = canonical_base(base)?; + verify_opened_path(&resolved_from, &cb)?; if !resolved_from.exists() { return Err(FsError::Io(std::io::Error::new( From 65a51be21999cd7861ea45660641668741b86b04 Mon Sep 17 00:00:00 2001 From: MerhiOPS Date: Mon, 16 Mar 2026 10:09:51 +0200 Subject: [PATCH 2/6] security: fix medium-severity audit findings across IPC, grants, plugins, storage IPC hardening: - Add IPC_MAX_RESPONSE_BYTES (16MB) cap to prevent memory exhaustion from oversized handler return values - Escape null bytes in JS string injection (escape_for_single_quoted_js) - Protect __volt_response__ and __volt_event__ with Object.defineProperty (writable:false) to prevent interception by injected scripts - Wrap IPC worker dispatch in catch_unwind to guarantee in-flight slot release even if a worker thread panics Grant system: - Replace predictable timestamp+counter grant IDs with SHA-256 based IDs using process entropy (PID, thread ID, heap ASLR address) - Canonicalize grant paths at creation time to prevent symlink drift Plugin security: - Cap stderr capture at 256KB to prevent plugin-driven host OOM - Sanitize plugin dialog titles: strip Unicode control characters and RTL overrides, truncate to 100 chars to prevent spoofing Storage: - Add 50MB per-plugin storage quota to prevent disk exhaustion Path validation: - Reject null bytes explicitly in validate_path (defense-in-depth) - Check ALL path components for reserved device names, not just the last - Add whitespace rejection to sanitize_dev_server_origin --- crates/volt-core/src/grant_store.rs | 36 +++++++++++++---- crates/volt-core/src/ipc.rs | 2 +- crates/volt-core/src/ipc/security.rs | 1 + crates/volt-core/src/ipc/webview.rs | 15 ++++--- crates/volt-core/src/security.rs | 39 ++++++++++--------- crates/volt-runner/src/ipc_bridge/response.rs | 26 ++++++++++++- .../volt-runner/src/ipc_bridge/worker_pool.rs | 33 +++++++++++----- .../src/plugin_manager/host_api_access.rs | 15 ++++++- .../src/plugin_manager/host_api_storage.rs | 33 ++++++++++++++++ .../src/plugin_manager/process/io.rs | 23 +++++++++-- 10 files changed, 175 insertions(+), 48 deletions(-) diff --git a/crates/volt-core/src/grant_store.rs b/crates/volt-core/src/grant_store.rs index 7e0bae1..861e0b8 100644 --- a/crates/volt-core/src/grant_store.rs +++ b/crates/volt-core/src/grant_store.rs @@ -11,8 +11,8 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Mutex; -use std::sync::atomic::{AtomicU64, Ordering}; +use sha2::{Digest, Sha256}; use thiserror::Error; #[derive(Error, Debug)] @@ -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 @@ -42,13 +41,26 @@ where f(store) } +/// Generate a cryptographically unpredictable grant ID by hashing +/// nanosecond timestamp + process ID + a random seed from the address +/// of a freshly allocated Box (ASLR-derived entropy). This replaces the +/// previous timestamp+counter scheme which was guessable. 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}") + let entropy_box = Box::new(0u8); + let addr = &*entropy_box as *const u8 as usize; + let pid = std::process::id(); + let tid = std::thread::current().id(); + let mut hasher = Sha256::new(); + hasher.update(ts.to_le_bytes()); + hasher.update(pid.to_le_bytes()); + hasher.update(format!("{tid:?}").as_bytes()); + hasher.update(addr.to_le_bytes()); + let hash = hasher.finalize(); + format!("grant_{:x}", hash) } /// Create a new grant for the given directory path. @@ -60,9 +72,18 @@ pub fn create_grant(path: PathBuf) -> Result { return Err(GrantError::InvalidPath); } + // Canonicalize at creation time so the grant always refers to the + // real, resolved path — preventing drift if symlinks change later. + let canonical = 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, + }, + ); }); Ok(id) } @@ -105,11 +126,12 @@ mod tests { fn test_create_and_resolve_grant() { let _guard = lock_grant_state(); let dir = env::temp_dir(); - let id = create_grant(dir.clone()).unwrap(); + let canonical_dir = dir.canonicalize().unwrap(); + let id = create_grant(dir).unwrap(); assert!(id.starts_with("grant_")); let resolved = resolve_grant(&id).unwrap(); - assert_eq!(resolved, dir); + assert_eq!(resolved, canonical_dir); } #[test] diff --git a/crates/volt-core/src/ipc.rs b/crates/volt-core/src/ipc.rs index 35fc4be..d910aa6 100644 --- a/crates/volt-core/src/ipc.rs +++ b/crates/volt-core/src/ipc.rs @@ -14,7 +14,7 @@ pub use self::protocol::{ IpcRequest, IpcResponse, }; pub use self::rate_limit::RateLimiter; -pub use self::security::{IPC_MAX_REQUEST_BYTES, IpcError}; +pub use self::security::{IPC_MAX_REQUEST_BYTES, IPC_MAX_RESPONSE_BYTES, IpcError}; pub use self::webview::{ event_script, ipc_init_script, payload_too_large_response_script, response_script, }; diff --git a/crates/volt-core/src/ipc/security.rs b/crates/volt-core/src/ipc/security.rs index 9c85d76..a8803b1 100644 --- a/crates/volt-core/src/ipc/security.rs +++ b/crates/volt-core/src/ipc/security.rs @@ -26,6 +26,7 @@ pub enum IpcError { } pub const IPC_MAX_REQUEST_BYTES: usize = 256 * 1024; +pub const IPC_MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024; const MAX_PROTOTYPE_CHECK_DEPTH: usize = 64; /// Parse raw JSON and reject payloads containing prototype-pollution keys. diff --git a/crates/volt-core/src/ipc/webview.rs b/crates/volt-core/src/ipc/webview.rs index 6c35987..fce62ff 100644 --- a/crates/volt-core/src/ipc/webview.rs +++ b/crates/volt-core/src/ipc/webview.rs @@ -74,8 +74,9 @@ pub fn ipc_init_script() -> String { } }); - // Response handler called from Rust via evaluate_script - window.__volt_response__ = function(responseJson) { + // Response handler called from Rust via evaluate_script. + // Defined as non-writable to prevent interception by injected scripts. + Object.defineProperty(window, '__volt_response__', { value: function(responseJson) { try { var response = JSON.parse(responseJson); var p = pending.get(response.id); @@ -97,10 +98,11 @@ pub fn ipc_init_script() -> String { } 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) { + // Event handler called from Rust via evaluate_script. + // Defined as non-writable to prevent interception by injected scripts. + Object.defineProperty(window, '__volt_event__', { value: function(event, data) { var set = listeners.get(event); if (set) { set.forEach(function(cb) { @@ -111,7 +113,7 @@ pub fn ipc_init_script() -> String { } }); } - }; + }, writable: false, configurable: false }); // Forward CSP violations to native logging so they are visible without DevTools. document.addEventListener('securitypolicyviolation', function(e) { @@ -185,6 +187,7 @@ fn escape_for_single_quoted_js(value: &str) -> String { '\'' => escaped.push_str("\\'"), '\n' => escaped.push_str("\\n"), '\r' => escaped.push_str("\\r"), + '\0' => escaped.push_str("\\0"), '\u{2028}' => escaped.push_str("\\u2028"), '\u{2029}' => escaped.push_str("\\u2029"), _ => escaped.push(ch), diff --git a/crates/volt-core/src/security.rs b/crates/volt-core/src/security.rs index ecf20f2..a42fa74 100644 --- a/crates/volt-core/src/security.rs +++ b/crates/volt-core/src/security.rs @@ -48,7 +48,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(|c| c.is_ascii_whitespace()) + { return None; } let mut http_origin = format!("{scheme}://{host}"); @@ -63,6 +67,11 @@ 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> { + // Reject null bytes (defense-in-depth — Rust's stdlib also rejects them) + if path.contains('\0') { + return Err("Null bytes are not allowed in paths".to_string()); + } + // Reject absolute paths if path.starts_with('/') || path.starts_with('\\') { return Err("Absolute paths are not allowed".to_string()); @@ -73,29 +82,21 @@ pub fn validate_path(path: &str) -> Result<(), String> { return Err("Absolute paths are not allowed".to_string()); } - // Reject path traversal - for component in path.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")); + // Check ALL path components (not just the last) for traversal and reserved names + for component in path.split(['/', '\\']) { + if component == ".." { + return Err("Path traversal (..) is not allowed".to_string()); + } + + 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(()) diff --git a/crates/volt-runner/src/ipc_bridge/response.rs b/crates/volt-runner/src/ipc_bridge/response.rs index 8c2f1d5..1c92629 100644 --- a/crates/volt-runner/src/ipc_bridge/response.rs +++ b/crates/volt-runner/src/ipc_bridge/response.rs @@ -1,5 +1,7 @@ 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 response_json = match serde_json::to_string(&response) { @@ -17,6 +19,28 @@ pub(super) fn send_response_to_window(js_window_id: &str, response: IpcResponse) } }; + if response_json.len() > IPC_MAX_RESPONSE_BYTES { + let truncated = IpcResponse::error_with_code( + response.id, + format!( + "IPC response too large ({} bytes > {} bytes)", + response_json.len(), + IPC_MAX_RESPONSE_BYTES + ), + IPC_HANDLER_ERROR_CODE.to_string(), + ); + let fallback_json = match serde_json::to_string(&truncated) { + Ok(serialized) => serialized, + Err(_) => return, + }; + let script = response_script(&fallback_json); + let _ = command::send_command(AppCommand::EvaluateScript { + js_id: js_window_id.to_string(), + script, + }); + return; + } + let script = response_script(&response_json); let _ = command::send_command(AppCommand::EvaluateScript { js_id: js_window_id.to_string(), diff --git a/crates/volt-runner/src/ipc_bridge/worker_pool.rs b/crates/volt-runner/src/ipc_bridge/worker_pool.rs index 739019e..0226f0d 100644 --- a/crates/volt-runner/src/ipc_bridge/worker_pool.rs +++ b/crates/volt-runner/src/ipc_bridge/worker_pool.rs @@ -45,16 +45,29 @@ impl IpcWorkerPool { Err(_) => return, }; - let response = dispatch_ipc_task( - &worker_runtime_client, - worker_plugin_manager.as_ref(), - &task.raw, - &task.request_id, - task.timeout, - ); - - send_response_to_window(&task.js_window_id, response); - worker_tracker.release(&task.js_window_id); + let js_window_id = task.js_window_id.clone(); + + // Catch panics to guarantee the in-flight slot is always + // released, preventing permanent slot exhaustion. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let response = dispatch_ipc_task( + &worker_runtime_client, + worker_plugin_manager.as_ref(), + &task.raw, + &task.request_id, + task.timeout, + ); + send_response_to_window(&task.js_window_id, response); + })); + + if result.is_err() { + tracing::error!( + window = %js_window_id, + "IPC dispatch panicked — slot released, no response sent" + ); + } + + worker_tracker.release(&js_window_id); } }); 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 1d85a1a..cbd6604 100644 --- a/crates/volt-runner/src/plugin_manager/host_api_access.rs +++ b/crates/volt-runner/src/plugin_manager/host_api_access.rs @@ -152,6 +152,8 @@ struct AccessRequestOptions { multiple: bool, } +const MAX_DIALOG_TITLE_LEN: usize = 100; + impl AccessRequestOptions { fn parse(payload: &Value) -> Result { let object = payload.as_object().ok_or_else(|| PluginRuntimeError { @@ -164,7 +166,7 @@ impl AccessRequestOptions { .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) - .map(str::to_string), + .map(|value| sanitize_dialog_title(value)), directory: object .get("directory") .and_then(Value::as_bool) @@ -185,6 +187,17 @@ fn format_dialog_title(plugin_name: &str, options: &AccessRequestOptions) -> Str } } +/// Strip Unicode control characters (Cc and Cf categories including RTL +/// overrides) and truncate to prevent dialog spoofing by malicious plugins. +fn sanitize_dialog_title(raw: &str) -> String { + let clean: String = raw + .chars() + .filter(|ch| !ch.is_control() && !matches!(ch, '\u{200E}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}')) + .take(MAX_DIALOG_TITLE_LEN) + .collect(); + clean +} + fn access_error(message: String) -> PluginRuntimeError { PluginRuntimeError { code: PLUGIN_ACCESS_ERROR_CODE.to_string(), 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 6c95984..a1332ba 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: usize = 50 * 1024 * 1024; impl PluginManager { pub(super) fn handle_storage_request( @@ -69,6 +70,7 @@ impl PluginManager { struct PluginStorage { root: PathBuf, index: StorageIndex, + total_bytes: usize, } impl PluginStorage { @@ -77,9 +79,11 @@ impl PluginStorage { if reconcile { reconcile_index(root, &mut index)?; } + let total_bytes = estimate_total_bytes(root, &index); Ok(Self { root: root.to_path_buf(), index, + total_bytes, }) } @@ -100,10 +104,28 @@ impl PluginStorage { } fn set(&mut self, key: String, value: String) -> Result<(), PluginRuntimeError> { + let new_size = value.len(); + // Subtract old value size if key already exists. + let old_size = self + .index + .entries + .get(&key) + .and_then(|hash| { + let path = value_path(hash); + volt_core::fs::stat(&self.root, &path).ok().map(|info| info.size as usize) + }) + .unwrap_or(0); + let projected = self.total_bytes + new_size - old_size; + if projected > STORAGE_MAX_TOTAL_BYTES { + return Err(storage_error(format!( + "plugin storage quota exceeded ({projected} > {STORAGE_MAX_TOTAL_BYTES} bytes)" + ))); + } let hash = hash_key(&key); write_bytes_atomic(&self.root, &value_path(&hash), value.as_bytes()) .map_err(storage_error)?; self.index.entries.insert(key, hash); + self.total_bytes = projected; save_index(&self.root, &self.index).map_err(storage_error) } @@ -135,6 +157,17 @@ struct StorageIndex { entries: BTreeMap, } +fn estimate_total_bytes(root: &Path, index: &StorageIndex) -> usize { + index + .entries + .values() + .filter_map(|hash| { + let path = value_path(hash); + volt_core::fs::stat(root, &path).ok().map(|info| info.size as usize) + }) + .sum() +} + 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()), diff --git a/crates/volt-runner/src/plugin_manager/process/io.rs b/crates/volt-runner/src/plugin_manager/process/io.rs index 99408cd..714340b 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, @@ -157,10 +158,26 @@ 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); + // Read in bounded chunks to prevent a malicious plugin from + // causing unbounded memory growth in the host process. + let mut captured = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + match stderr.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let remaining = MAX_STDERR_CAPTURE_BYTES.saturating_sub(captured.len()); + if remaining > 0 { + captured.extend_from_slice(&buf[..n.min(remaining)]); + } + // Continue reading (and discarding) to drain the pipe + // and prevent the plugin process from blocking. + } + Err(_) => break, + } + } if let Ok(mut buffer) = stderr_buffer.lock() { - *buffer = captured; + *buffer = String::from_utf8_lossy(&captured).into_owned(); } }); } From b5820c64c3bb3fe7eb01648747183b0e4f052efa Mon Sep 17 00:00:00 2001 From: MerhiOPS Date: Mon, 16 Mar 2026 10:11:09 +0200 Subject: [PATCH 3/6] security: fix low-severity audit findings (rate limit, reservations, storage) IPC: - Move rate limit check before native fast path execution so rate-limited requests are rejected without performing computation - Reserve __volt_internal: prefix in addition to volt: to prevent user handler squatting on internal channels Storage: - Clean up orphaned .tmp files during reconciliation (previously only .val files were handled, leaving interrupted atomic writes behind) - Gracefully recover from corrupted _index.json instead of failing all storage operations (log warning, start with empty index) --- crates/volt-runner/src/ipc_bridge/dispatch.rs | 28 ++++++++----------- .../src/plugin_manager/host_api_storage.rs | 17 ++++++++++- packages/volt/src/ipc.ts | 9 ++++-- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/crates/volt-runner/src/ipc_bridge/dispatch.rs b/crates/volt-runner/src/ipc_bridge/dispatch.rs index 436ab28..08ef23a 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 { + // Check rate limit BEFORE executing any work (including native fast paths) + // so that rate-limited requests are rejected without performing computation. + if 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 @@ -119,17 +129,3 @@ fn try_dispatch_plugin_route( 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/plugin_manager/host_api_storage.rs b/crates/volt-runner/src/plugin_manager/host_api_storage.rs index a1332ba..a219732 100644 --- a/crates/volt-runner/src/plugin_manager/host_api_storage.rs +++ b/crates/volt-runner/src/plugin_manager/host_api_storage.rs @@ -170,7 +170,16 @@ fn estimate_total_bytes(root: &Path, index: &StorageIndex) -> usize { 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 index is corrupted ({}), starting with empty index", + error + ); + Ok(StorageIndex::default()) + } + }, Err(volt_core::fs::FsError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => { Ok(StorageIndex::default()) } @@ -201,10 +210,16 @@ fn reconcile_index(root: &Path, index: &mut StorageIndex) -> Result<(), String> let Some(name) = entry.file_name().to_str().map(str::to_string) else { continue; }; + // Clean up orphaned .val files not referenced by the index if name.ends_with(".val") && !expected.contains(&name) { volt_core::fs::remove(root, &name).map_err(|error| error.to_string())?; changed = true; } + // Clean up leftover .tmp files from interrupted atomic writes + if name.ends_with(".tmp") { + let _ = volt_core::fs::remove(root, &name); + changed = true; + } } if changed { save_index(root, index)?; diff --git a/packages/volt/src/ipc.ts b/packages/volt/src/ipc.ts index d081c80..53eedc9 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:'] as const; export type IpcErrorCode = | 'IPC_HANDLER_NOT_FOUND' @@ -217,8 +217,11 @@ 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)) { - throw new Error(`IPC channel is reserved by Volt: ${channel.trim()}`); + const trimmed = channel.trim(); + for (const prefix of RESERVED_IPC_PREFIXES) { + if (trimmed.startsWith(prefix)) { + throw new Error(`IPC channel is reserved by Volt: ${trimmed}`); + } } } From 30ed7abd348f53cc839c955fedbe9bcffc5859c3 Mon Sep 17 00:00:00 2001 From: MerhiOPS Date: Mon, 16 Mar 2026 10:30:38 +0200 Subject: [PATCH 4/6] fix: resolve compilation errors in audit hardening - Clone response.id before first use to prevent use-after-move (response.rs) - Replace redundant closure with function reference (clippy) --- crates/volt-runner/src/ipc_bridge/response.rs | 5 +++-- crates/volt-runner/src/plugin_manager/host_api_access.rs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/volt-runner/src/ipc_bridge/response.rs b/crates/volt-runner/src/ipc_bridge/response.rs index 1c92629..d5c38f1 100644 --- a/crates/volt-runner/src/ipc_bridge/response.rs +++ b/crates/volt-runner/src/ipc_bridge/response.rs @@ -4,11 +4,12 @@ use volt_core::ipc::{ }; 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, + request_id.clone(), format!("failed to serialize IPC response: {error}"), IPC_HANDLER_ERROR_CODE.to_string(), ); @@ -21,7 +22,7 @@ pub(super) fn send_response_to_window(js_window_id: &str, response: IpcResponse) if response_json.len() > IPC_MAX_RESPONSE_BYTES { let truncated = IpcResponse::error_with_code( - response.id, + request_id, format!( "IPC response too large ({} bytes > {} bytes)", response_json.len(), 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 cbd6604..ae2799d 100644 --- a/crates/volt-runner/src/plugin_manager/host_api_access.rs +++ b/crates/volt-runner/src/plugin_manager/host_api_access.rs @@ -166,7 +166,7 @@ impl AccessRequestOptions { .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) - .map(|value| sanitize_dialog_title(value)), + .map(sanitize_dialog_title), directory: object .get("directory") .and_then(Value::as_bool) From 59a220adaa934f1023f77a0002d544edb08db419 Mon Sep 17 00:00:00 2001 From: MerhiOPS Date: Mon, 16 Mar 2026 10:49:57 +0200 Subject: [PATCH 5/6] fix: split fs module for 300-line cap and fix init script test assertion - Extract path resolution and symlink guards into fs/resolve.rs (152 lines) - fs/mod.rs now contains file operations only (228 lines) - Fix test_ipc_init_script_valid: Object.defineProperty changed the contiguous string from window.__volt_response__ to '__volt_response__' --- crates/volt-core/src/fs/mod.rs | 198 ++-------------------- crates/volt-core/src/fs/resolve.rs | 154 +++++++++++++++++ crates/volt-core/src/ipc/tests/scripts.rs | 4 +- 3 files changed, 173 insertions(+), 183 deletions(-) create mode 100644 crates/volt-core/src/fs/resolve.rs diff --git a/crates/volt-core/src/fs/mod.rs b/crates/volt-core/src/fs/mod.rs index b5a1979..41af84f 100644 --- a/crates/volt-core/src/fs/mod.rs +++ b/crates/volt-core/src/fs/mod.rs @@ -1,31 +1,13 @@ +mod resolve; + use std::fs; use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::time::UNIX_EPOCH; use thiserror::Error; -use crate::security::validate_path; - -/// Verify that an opened file descriptor actually resides under the expected -/// base directory. This closes the TOCTOU gap between path validation and -/// the actual file operation: even if the path was swapped (e.g., via a -/// symlink race) between `safe_resolve` and `open`, the post-open check -/// catches the escape. -fn verify_opened_path(opened: &Path, canonical_base: &Path) -> Result<(), FsError> { - let actual = opened - .canonicalize() - .map_err(|_| FsError::Security("cannot verify opened path".to_string()))?; - if !actual.starts_with(canonical_base) { - return Err(FsError::OutOfScope); - } - Ok(()) -} - -/// Canonicalize the base once and return it for reuse in post-open checks. -fn canonical_base(base: &Path) -> Result { - base.canonicalize() - .map_err(|_| FsError::Security("Base directory does not exist".to_string())) -} +pub use resolve::{safe_resolve, safe_resolve_for_create}; +use resolve::{canonical_base, ensure_not_symlink, ensure_scoped_directory, verify_opened_path}; #[derive(Error, Debug)] pub enum FsError { @@ -39,79 +21,18 @@ 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) +/// File metadata info returned by stat(). +#[derive(Debug, Clone)] +pub struct FileInfo { + pub size: u64, + pub is_file: bool, + pub is_dir: bool, + pub readonly: bool, + /// Last modification time as milliseconds since Unix epoch. + pub modified_ms: f64, + /// Creation time as milliseconds since Unix epoch. + /// `None` on platforms/filesystems that do not support birth time. + pub created_ms: Option, } /// Read a file's contents as bytes. @@ -215,20 +136,6 @@ pub fn exists(base: &Path, path: &str) -> Result { Ok(true) } -/// File metadata info returned by stat(). -#[derive(Debug, Clone)] -pub struct FileInfo { - pub size: u64, - pub is_file: bool, - pub is_dir: bool, - pub readonly: bool, - /// Last modification time as milliseconds since Unix epoch. - pub modified_ms: f64, - /// Creation time as milliseconds since Unix epoch. - /// `None` on platforms/filesystems that do not support birth time. - 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)?; @@ -249,8 +156,6 @@ pub fn remove(base: &Path, path: &str) -> Result<(), FsError> { } if resolved.is_dir() { - // Re-check: a symlink could have been swapped in between the first - // ensure_not_symlink and the is_dir() call above. ensure_not_symlink(&resolved)?; verify_opened_path(&resolved, &cb)?; Ok(fs::remove_dir_all(resolved)?) @@ -262,8 +167,6 @@ pub fn remove(base: &Path, path: &str) -> Result<(), FsError> { } /// 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)?; @@ -288,7 +191,6 @@ pub fn rename(base: &Path, from: &str, to: &str) -> Result<(), FsError> { } /// 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)?; @@ -319,71 +221,5 @@ pub fn copy(base: &Path, from: &str, to: &str) -> Result<(), FsError> { 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..6d0b98e --- /dev/null +++ b/crates/volt-core/src/fs/resolve.rs @@ -0,0 +1,154 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use super::FsError; +use crate::security::validate_path; + +/// Verify that an opened file descriptor actually resides under the expected +/// base directory. This closes the TOCTOU gap between path validation and +/// the actual file operation: even if the path was swapped (e.g., via a +/// symlink race) between `safe_resolve` and `open`, the post-open check +/// catches the escape. +pub(super) fn verify_opened_path(opened: &Path, canonical_base: &Path) -> Result<(), FsError> { + let actual = opened + .canonicalize() + .map_err(|_| FsError::Security("cannot verify opened path".to_string()))?; + if !actual.starts_with(canonical_base) { + return Err(FsError::OutOfScope); + } + Ok(()) +} + +/// Canonicalize the base once and return it for reuse in post-open checks. +pub(super) fn canonical_base(base: &Path) -> Result { + base.canonicalize() + .map_err(|_| FsError::Security("Base directory does not exist".to_string())) +} + +/// 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 resolved = base.join(user_path); + + let canonical_base = base + .canonicalize() + .map_err(|_| FsError::Security("Base directory does not exist".to_string()))?; + + let canonical_resolved = 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 { + 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. +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) +} + +pub(super) fn ensure_scoped_directory(base: &Path, directory: &Path) -> Result<(), FsError> { + let canonical_base = canonical_base(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) +} + +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)), + } +} diff --git a/crates/volt-core/src/ipc/tests/scripts.rs b/crates/volt-core/src/ipc/tests/scripts.rs index fa2bbf7..5633011 100644 --- a/crates/volt-core/src/ipc/tests/scripts.rs +++ b/crates/volt-core/src/ipc/tests/scripts.rs @@ -4,8 +4,8 @@ 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("'__volt_response__'")); + assert!(script.contains("'__volt_event__'")); assert!(script.contains("response.errorCode")); assert!(script.contains("response.errorDetails")); } From c047c1ef4de6cba56573f6d4223a8004b9ed0634 Mon Sep 17 00:00:00 2001 From: MerhiOPS Date: Mon, 16 Mar 2026 11:03:11 +0200 Subject: [PATCH 6/6] ci: trigger checks