Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion crates/sage-apps/src/sandbox/probes/isolation.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,57 @@
use std::time::Duration;

use tauri::{AppHandle, State};

use super::super::runtime::{start_test_app, stop_test_apps, unique_run_id};
use super::poll::poll_isolation;
use crate::{
AppsHostState, BUILTIN_STORAGE_ISOLATION_INCOGNITO_ID, BUILTIN_STORAGE_ISOLATION_PERSISTENT_ID,
get_sage_webview,
};

/// Writes probe data into the host (main Sage) webview's `localStorage` and
/// `indexedDB` before the isolation probes run. Without this, the probe apps
/// read fixed keys that nothing ever wrote, so the test passed vacuously even
/// if storage isolation were completely broken. With the host seeded, a sandbox
/// app that can observe these values proves an isolation failure.
async fn seed_host_isolation_probe(app: &AppHandle, run_id: &str) -> Result<(), String> {
let webview = get_sage_webview(app)?;

let value = serde_json::to_string(run_id)
.map_err(|err| format!("failed to encode isolation probe value: {err}"))?;

let script = format!(
r#"(function () {{
var value = {value};
try {{ localStorage.setItem('sage_probe_local_storage', value); }} catch (e) {{}}
try {{
var open = indexedDB.open('sage_probe_db', 1);
open.onupgradeneeded = function () {{
try {{
var db = open.result;
if (!db.objectStoreNames.contains('probe_store')) {{
db.createObjectStore('probe_store');
}}
}} catch (e) {{}}
}};
open.onsuccess = function () {{
try {{
var db = open.result;
if (!db.objectStoreNames.contains('probe_store')) {{ db.close(); return; }}
var tx = db.transaction('probe_store', 'readwrite');
tx.objectStore('probe_store').put(value, 'sage_probe_key');
tx.oncomplete = function () {{ db.close(); }};
}} catch (e) {{}}
}};
}} catch (e) {{}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Host seed errors swallowed silently

Medium Severity

The injected host seed script wraps localStorage and IndexedDB writes in empty catch blocks, and Rust treats a successful webview.eval as success. If those writes fail, seed_host_isolation_probe still returns Ok, so isolation probes may see no host data and the test passes even when storage isolation is broken.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 445eeed. Configure here.

}})();"#
);

webview
.eval(script)
.map_err(|err| format!("failed to seed host isolation probe data: {err}"))
}

pub(in crate::sandbox) async fn run_isolation_test(
app: &AppHandle,
apps_state: &State<'_, AppsHostState>,
Expand All @@ -18,6 +64,11 @@ pub(in crate::sandbox) async fn run_isolation_test(

stop_test_apps(app, apps_state, &app_ids).await;

// Seed the host storage first, then give the async IndexedDB write a brief
// moment to commit before launching the sandbox probes.
seed_host_isolation_probe(app, &run_id).await?;
tokio::time::sleep(Duration::from_millis(250)).await;

start_test_app(
app,
apps_state,
Expand Down Expand Up @@ -76,6 +127,9 @@ pub(in crate::sandbox) async fn run_isolation_test(

Ok((
true,
Some("Both sandbox probe modes were unable to observe Sage probe data.".into()),
Some(
"Both sandbox probe modes were unable to observe the seeded Sage host probe data."
.into(),
),
))
}
36 changes: 35 additions & 1 deletion crates/sage-apps/src/sandbox/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,36 @@ use super::probes::{run_isolation_test, run_network_test, run_persistence_test};
use super::state_view::{build_effective_state, build_state_view};
use super::types::{
SandboxCapability, SandboxCapabilityStatus, SandboxRunState, SandboxState,
build_running_sandbox_state, mark_cap,
build_initial_sandbox_state, build_running_sandbox_state, mark_cap,
};
use crate::{AppsHostState, emit_sandbox_state_changed, unix_timestamp_ms};

/// Resets sandbox run state if the runner exits abnormally (panic / early
/// return) before it finalizes. Without this, a crash mid-run would leave
/// `running == true` and a stale (possibly `Passed`) baseline in place, keeping
/// the app launch gate open even though no run is actually in flight.
struct SandboxRunCleanup {
app: AppHandle,
completed: bool,
}

impl Drop for SandboxRunCleanup {
fn drop(&mut self) {
if self.completed {
return;
}

let app = self.app.clone();
tauri::async_runtime::spawn(async move {
let apps_state = app.state::<AppsHostState>();
*apps_state.sandbox.current_run.lock().await = None;
*apps_state.sandbox.baseline.lock().await = build_initial_sandbox_state();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup clears run before baseline

High Severity

When SandboxRunCleanup runs after an abnormal runner exit, it sets current_run to None before resetting baseline. build_effective_state then returns the unchanged pre-rerun baseline (often all Passed) with no live run, so check_gates can allow user app launches even though the crashed rerun had failing or incomplete capability results.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 445eeed. Configure here.

*apps_state.sandbox.running.lock().await = false;
emit_sandbox_state_changed(&app, &apps_state).await;
});
}
}

pub async fn ensure_initial_sandbox_run(app: AppHandle) -> Result<(), String> {
let apps_state = app.state::<AppsHostState>();

Expand Down Expand Up @@ -79,6 +105,11 @@ async fn update_current_run_state(
}

pub async fn sandbox_runner(app: AppHandle) {
let mut cleanup = SandboxRunCleanup {
app: app.clone(),
completed: false,
};

let apps_state = app.state::<AppsHostState>();

let mut current_state = {
Expand Down Expand Up @@ -225,6 +256,9 @@ pub async fn sandbox_runner(app: AppHandle) {
*apps_state.sandbox.running.lock().await = false;

emit_sandbox_state_changed(&app, &apps_state).await;

// Reached the finalize path cleanly; suppress the abnormal-exit reset.
cleanup.completed = true;
}

fn sandbox_state_is_all_pending(state: &SandboxState) -> bool {
Expand Down
17 changes: 10 additions & 7 deletions crates/sage-apps/src/sandbox/state_view.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
use tauri::State;

use super::types::{
SandboxCapabilityResult, SandboxCapabilityStatus, SandboxRunState, SandboxState,
SandboxStateView,
SandboxCapabilityResult, SandboxRunState, SandboxState, SandboxStateView,
};
use crate::AppsHostState;

fn effective_cap(
baseline: &SandboxCapabilityResult,
_baseline: &SandboxCapabilityResult,
current: &SandboxCapabilityResult,
) -> SandboxCapabilityResult {
match current.status {
SandboxCapabilityStatus::Passed | SandboxCapabilityStatus::Failed => current.clone(),
SandboxCapabilityStatus::Pending | SandboxCapabilityStatus::Running => baseline.clone(),
}
// While a run is in progress we must reflect the *live* status of each
// capability, never the previous baseline. Falling back to a stale
// (previously `Passed`) baseline for a `Pending`/`Running` capability let a
// user-triggered rerun keep the launch gate open using results that no
// longer apply — and a runner that crashed mid-rerun could leave apps
// launchable indefinitely. Surfacing the live `Running`/`Pending` status
// makes the gate block until the rerun actually re-establishes each result.
current.clone()
}

pub fn build_effective_state(
Expand Down
Loading