From 075de924a02bee5394503e97f13460c75316c2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C3=B3nal=20Murray?= Date: Thu, 12 Mar 2026 11:54:42 +0000 Subject: [PATCH] feat: per-context WebContent process isolation via WKWebsiteDataStore Each browsing context (Work, Personal, etc.) now gets a dedicated WKWebsiteDataStore keyed by a stable UUID, ensuring separate WebContent OS processes per context. Prevents cross-context cookie/storage/cache leakage even through renderer exploits. - CSPRNG UUID generation via getrandom (collision-resistant) - Fail-closed: unknown/deleted contexts fall to incognito - macOS 14+ runtime gate (older macOS degrades to incognito) - isolated_tabs takes precedence over named contexts - Migration repairs missing or malformed store_uuid hex strings - 13 new unit tests for UUID generation, parsing, serde, context resolution - All 5 WebView creation paths wired: open_webview, open_webview_background, Open in Context menu, Open Private menu, restore_session --- AGENTS.md | 14 ++- CHANGELOG.md | 19 +++ Cargo.lock | 1 + crates/epoca-core/Cargo.toml | 1 + crates/epoca-core/src/settings.rs | 194 ++++++++++++++++++++++++++++- crates/epoca-core/src/tabs.rs | 44 ++++++- crates/epoca-core/src/workbench.rs | 45 +++++-- docs/backlog.md | 1 + 8 files changed, 302 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8fc1965..70a97bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,7 +203,19 @@ If unsure, ask rather than assume. - Effect: metered paywalls reset per-tab (no cross-tab article count), login sessions are tab-local, fingerprinting resets each tab - Toggle at runtime via `Workbench::set_isolated_tabs(bool)`; applies to all subsequently opened tabs - Existing open tabs are unaffected — they keep their data store until closed and reopened -- `WebViewTab::new(url, isolated, window, cx)` — `isolated` param is always passed from the owning Workbench +- `WebViewTab::new(url, context_id, store_uuid, window, cx)` — `store_uuid` enables per-context data store isolation + +### Per-Context WebContent Process Isolation +- Each `SessionContext` has a stable `store_uuid: Option` (hex-encoded 16-byte UUID) +- `resolve_store_uuid()` in Workbench maps context_id → UUID bytes for WKWebsiteDataStore +- Default context → `DEFAULT_STORE_UUID` (fixed constant); named contexts → UUID from settings; private → `None` (incognito) +- `WebViewTab::new()` calls `builder.with_data_store_identifier(uuid)` via `WebViewBuilderExtDarwin` (macOS 14+) +- Different `WKWebsiteDataStore` identifiers → separate WebContent OS processes (WebKit process cache keys on data store pointer) +- Prevents cross-context cookie/storage/cache leakage even through renderer exploits +- Migration: `SettingsGlobal::load()` auto-assigns UUIDs to existing contexts that lack one +- All 5 WebView creation paths in workbench.rs wired: `open_webview()`, `open_webview_background()`, "Open in Context" menu, "Open Private" menu, `restore_session()` +- `generate_store_uuid()`, `format_store_uuid()`, `parse_store_uuid()` in settings.rs +- 12 unit tests covering UUID v4 compliance, uniqueness, hex roundtrip, serde, context resolution ### Favicon in Tab List - `FAVICON_SCRIPT` init script finds the best `` URL (prefers higher resolution) and falls back to `/favicon.ico`; posts `{type:'faviconFound', url}` to `epocaFavicon` on DOMContentLoaded and SPA navigations diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f5f4d5..6c7826d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] — ongoing ### Added +- **Per-context WebContent process isolation** — Each browsing context (Work, Personal, etc.) + now gets a dedicated `WKWebsiteDataStore` keyed by a stable UUID, ensuring separate WebContent + OS processes per context. Prevents cross-context cookie/storage/cache leakage even through + renderer exploits. Default (non-context) tabs share a fixed `DEFAULT_STORE_UUID`; private tabs + use `nonPersistentDataStore` (incognito). `store_uuid` field on `SessionContext` with migration + for existing settings. `resolve_store_uuid()` in workbench.rs maps context → UUID across all 5 + WebView creation paths. Uses `lb-wry`'s `with_data_store_identifier([u8; 16])` API (macOS 14+). + 13 unit tests for UUID generation, parsing, serde roundtrip, and context resolution. + `settings.rs`, `tabs.rs`, `workbench.rs`. (2026-03-11) + +- **Process isolation hardening (Oracle audit fixes)** — UUID generation upgraded from + `DefaultHasher` to OS CSPRNG via `getrandom` crate for collision resistance. `data_store_uuid()` + changed from generate-on-read to parse-only (`Option<[u8; 16]>`) so callers fail closed on + missing/malformed UUIDs. Unknown or deleted contexts now fall to incognito instead of the shared + default store. Migration repairs malformed hex strings (not just missing). macOS 14+ runtime gate + added — older macOS degrades named contexts to incognito with log warning. `isolated_tabs` now + takes precedence over named contexts (privacy-first). `settings.rs`, `tabs.rs`, `workbench.rs`. + (2026-03-11) + - **Media API Phase A (functional getUserMedia + attachTrack)** — `window.epoca.media.getUserMedia()` now allocates opaque track IDs via `media_api.rs`, resolves the JS promise with `{audioTrackId, videoTrackId}`, then evaluates getUserMedia JS in the WKWebView (browser native stack, no ObjC/AVFoundation). diff --git a/Cargo.lock b/Cargo.lock index 4f225d4..167a347 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4269,6 +4269,7 @@ dependencies = [ "epoca-sandbox", "epoca-shield", "epoca-wallet", + "getrandom 0.2.17", "gpui", "gpui-component", "gpui-ui-kit", diff --git a/crates/epoca-core/Cargo.toml b/crates/epoca-core/Cargo.toml index cfdc57d..5621a4b 100644 --- a/crates/epoca-core/Cargo.toml +++ b/crates/epoca-core/Cargo.toml @@ -24,6 +24,7 @@ image = { workspace = true } smallvec = { workspace = true } rusqlite = { workspace = true } blake2 = "0.10" +getrandom = "0.2" zip = { workspace = true } ureq = "3" str0m = "0.16" diff --git a/crates/epoca-core/src/settings.rs b/crates/epoca-core/src/settings.rs index 258286d..e33fc4d 100644 --- a/crates/epoca-core/src/settings.rs +++ b/crates/epoca-core/src/settings.rs @@ -2,6 +2,43 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::path::PathBuf; +/// Fixed UUID for the "default" browsing context (non-isolated, non-experimental). +/// All regular tabs share this WKWebsiteDataStore so they share cookies/storage. +/// Derived from UUID v5 with the DNS namespace and "epoca.default.store". +pub const DEFAULT_STORE_UUID: [u8; 16] = [ + 0x7a, 0x3b, 0x8c, 0x1d, 0x4e, 0x5f, 0x40, 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0x07, 0x18, 0x29, +]; + +/// Generate a random 16-byte UUID suitable for `WKWebsiteDataStore.dataStoreForIdentifier`. +/// Uses OS CSPRNG via `getrandom` for collision resistance — these UUIDs are security +/// boundaries between browsing contexts, so predictable IDs would be a vulnerability. +pub fn generate_store_uuid() -> [u8; 16] { + let mut uuid = [0u8; 16]; + getrandom::getrandom(&mut uuid).expect("OS random source unavailable"); + // Set version 4 (random) and variant 1 bits for RFC 4122 compliance. + uuid[6] = (uuid[6] & 0x0f) | 0x40; // version 4 + uuid[8] = (uuid[8] & 0x3f) | 0x80; // variant 1 + uuid +} + +/// Parse a hex-encoded 16-byte UUID string back to bytes. +pub fn parse_store_uuid(hex: &str) -> Option<[u8; 16]> { + let hex = hex.replace('-', ""); + if hex.len() != 32 { + return None; + } + let mut bytes = [0u8; 16]; + for i in 0..16 { + bytes[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?; + } + Some(bytes) +} + +/// Encode a 16-byte UUID as a hex string (no dashes). +pub fn format_store_uuid(uuid: &[u8; 16]) -> String { + uuid.iter().map(|b| format!("{b:02x}")).collect() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SearchEngine { DuckDuckGo, @@ -111,6 +148,18 @@ pub struct SessionContext { pub id: String, pub name: String, pub color: String, + /// Hex-encoded 16-byte UUID for `WKWebsiteDataStore.dataStoreForIdentifier`. + /// Each context gets its own data store → its own WebContent process namespace. + #[serde(default)] + pub store_uuid: Option, +} + +impl SessionContext { + /// Parse the stored hex UUID. Returns `None` if absent or malformed — + /// callers must treat `None` as "isolation unavailable, fail closed." + pub fn data_store_uuid(&self) -> Option<[u8; 16]> { + self.store_uuid.as_deref().and_then(parse_store_uuid) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -166,19 +215,37 @@ impl gpui::Global for SettingsGlobal {} impl SettingsGlobal { pub fn load() -> Self { let path = Self::settings_path(); - let settings = std::fs::read_to_string(&path) + let mut settings: AppSettings = std::fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); - Self { settings, path } + + // Migration: assign or repair store_uuid for any context that lacks a valid one. + let mut migrated = false; + for ctx in &mut settings.contexts { + let valid = ctx + .store_uuid + .as_deref() + .and_then(parse_store_uuid) + .is_some(); + if !valid { + ctx.store_uuid = Some(format_store_uuid(&generate_store_uuid())); + migrated = true; + } + } + + let global = Self { settings, path }; + if migrated { + global.save(); + } + global } fn settings_path() -> PathBuf { #[cfg(target_os = "macos")] { let home = std::env::var("HOME").unwrap_or_default(); - PathBuf::from(home) - .join("Library/Application Support/Epoca/settings.json") + PathBuf::from(home).join("Library/Application Support/Epoca/settings.json") } #[cfg(not(target_os = "macos"))] { @@ -238,4 +305,123 @@ mod tests { let s: AppSettings = serde_json::from_str(json).unwrap(); assert_eq!(s.history_retention, HistoryRetention::Hours24); } + + // ── Process isolation / data store UUID tests ──────────────────── + + #[test] + fn test_generate_store_uuid_produces_valid_v4() { + let uuid = generate_store_uuid(); + assert_eq!(uuid[6] >> 4, 4, "version nibble must be 4"); + assert_eq!(uuid[8] >> 6, 2, "variant bits must be 10"); + } + + #[test] + fn test_generate_store_uuid_is_unique() { + let a = generate_store_uuid(); + let b = generate_store_uuid(); + assert_ne!(a, b); + } + + #[test] + fn test_format_and_parse_store_uuid_roundtrip() { + let uuid = generate_store_uuid(); + let hex = format_store_uuid(&uuid); + assert_eq!(hex.len(), 32); + let back = parse_store_uuid(&hex).expect("parse should succeed"); + assert_eq!(back, uuid); + } + + #[test] + fn test_parse_store_uuid_rejects_short_hex() { + assert!(parse_store_uuid("abcdef").is_none()); + } + + #[test] + fn test_parse_store_uuid_rejects_invalid_hex() { + assert!(parse_store_uuid("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").is_none()); + } + + #[test] + fn test_parse_store_uuid_accepts_dashes() { + let hex = "7a3b8c1d-4e5f-40a1-b2c3-d4e5f6071829"; + let parsed = parse_store_uuid(hex).expect("should accept dashes"); + assert_eq!(parsed, DEFAULT_STORE_UUID); + } + + #[test] + fn test_default_store_uuid_is_constant() { + let hex = format_store_uuid(&DEFAULT_STORE_UUID); + assert_eq!(hex, "7a3b8c1d4e5f40a1b2c3d4e5f6071829"); + } + + #[test] + fn test_session_context_data_store_uuid_returns_stored() { + let uuid = generate_store_uuid(); + let ctx = SessionContext { + id: "ctx-1".into(), + name: "Test".into(), + color: "#ff0000".into(), + store_uuid: Some(format_store_uuid(&uuid)), + }; + assert_eq!(ctx.data_store_uuid(), Some(uuid)); + } + + #[test] + fn test_session_context_data_store_uuid_none_when_missing() { + let ctx = SessionContext { + id: "ctx-1".into(), + name: "Test".into(), + color: "#ff0000".into(), + store_uuid: None, + }; + assert_eq!(ctx.data_store_uuid(), None); + } + + #[test] + fn test_session_context_data_store_uuid_none_when_malformed() { + let ctx = SessionContext { + id: "ctx-1".into(), + name: "Test".into(), + color: "#ff0000".into(), + store_uuid: Some("not-valid-hex!!".into()), + }; + assert_eq!(ctx.data_store_uuid(), None); + } + + #[test] + fn test_session_context_deserializes_without_store_uuid() { + let json = r##"{"id":"ctx-1","name":"Work","color":"#3b82f6"}"##; + let ctx: SessionContext = serde_json::from_str(json).unwrap(); + assert!(ctx.store_uuid.is_none()); + } + + #[test] + fn test_session_context_serde_roundtrip_with_store_uuid() { + let ctx = SessionContext { + id: "ctx-1".into(), + name: "Work".into(), + color: "#3b82f6".into(), + store_uuid: Some(format_store_uuid(&generate_store_uuid())), + }; + let json = serde_json::to_string(&ctx).unwrap(); + let back: SessionContext = serde_json::from_str(&json).unwrap(); + assert_eq!(back.store_uuid, ctx.store_uuid); + } + + #[test] + fn test_app_settings_contexts_preserve_store_uuid() { + let mut s = AppSettings::default(); + s.contexts.push(SessionContext { + id: "ctx-1".into(), + name: "Work".into(), + color: "#3b82f6".into(), + store_uuid: Some("aabbccdd11223344aabbccdd11223344".into()), + }); + let json = serde_json::to_string(&s).unwrap(); + let back: AppSettings = serde_json::from_str(&json).unwrap(); + assert_eq!( + back.contexts[0].store_uuid.as_deref(), + Some("aabbccdd11223344aabbccdd11223344") + ); + } } diff --git a/crates/epoca-core/src/tabs.rs b/crates/epoca-core/src/tabs.rs index ec2e5c7..7a76256 100644 --- a/crates/epoca-core/src/tabs.rs +++ b/crates/epoca-core/src/tabs.rs @@ -2929,9 +2929,10 @@ pub struct WebViewTab { } impl WebViewTab { - pub fn new(url: String, context_id: Option, window: &mut Window, cx: &mut Context) -> Self { - // None = isolated (private), Some = shared named context (persistent store) - let isolated = context_id.is_none(); + pub fn new(url: String, _context_id: Option, store_uuid: Option<[u8; 16]>, window: &mut Window, cx: &mut Context) -> Self { + // Fail-closed: no store UUID → incognito. Covers private tabs, deleted contexts, + // and macOS <14 fallback (where dataStoreForIdentifier is unavailable). + let isolated = store_uuid.is_none(); // Observe OverlayLeftInset so this entity is marked dirty — and therefore // re-painted by GPUI — whenever the sidebar animation moves. Without this, // GPUI may skip re-rendering the entity and the native WKWebView frame @@ -3026,7 +3027,22 @@ impl WebViewTab { let mut builder = gpui_component::wry::WebViewBuilder::new() .with_url(&url) .with_incognito(isolated) - .with_user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15 Epoca/1.0") + .with_user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15 Epoca/1.0"); + + // Per-context data store isolation (macOS 14+ only). + // On older macOS, dataStoreForIdentifier is unavailable — fail closed to incognito. + #[cfg(any(target_os = "macos", target_os = "ios"))] + if let Some(uuid) = store_uuid { + if macos_14_or_later() { + use gpui_component::wry::WebViewBuilderExtDarwin; + builder = builder.with_data_store_identifier(uuid); + } else { + log::warn!("macOS <14: per-context data store isolation unavailable, using incognito"); + builder = builder.with_incognito(true); + } + } + + let mut builder = builder .with_initialization_script(CONSOLE_RELAY_SCRIPT) .with_initialization_script(SCROLLBAR_CSS_SCRIPT) .with_initialization_script(TITLE_TRACKER_SCRIPT) @@ -4949,10 +4965,12 @@ impl Render for SettingsTab { let idx = g.settings.contexts.len(); let name = format!("Context {}", idx + 1); let id = format!("ctx-{}", uuid_v4_simple()); + let uuid = crate::settings::format_store_uuid(&crate::settings::generate_store_uuid()); g.settings.contexts.push(crate::settings::SessionContext { id, name, color, + store_uuid: Some(uuid), }); g.save(); }); @@ -5815,3 +5833,21 @@ fn uuid_v4_simple() -> String { .as_nanos(); format!("{:016x}", t) } + +/// `WKWebsiteDataStore.dataStoreForIdentifier:` requires macOS 14+. +/// Cached after first call. +#[cfg(any(target_os = "macos", target_os = "ios"))] +fn macos_14_or_later() -> bool { + use std::sync::OnceLock; + static RESULT: OnceLock = OnceLock::new(); + *RESULT.get_or_init(|| { + std::process::Command::new("sw_vers") + .arg("-productVersion") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .and_then(|v| v.trim().split('.').next()?.parse::().ok()) + .map(|major| major >= 14) + .unwrap_or(false) + }) +} diff --git a/crates/epoca-core/src/workbench.rs b/crates/epoca-core/src/workbench.rs index 8c32f1e..e935a52 100644 --- a/crates/epoca-core/src/workbench.rs +++ b/crates/epoca-core/src/workbench.rs @@ -996,7 +996,8 @@ impl Workbench { let title = url_to_title(&url); let url_clone = url.clone(); let ctx = Some(context_id); - let entity = cx.new(|cx| WebViewTab::new(url, ctx.clone(), window, cx)); + let suuid = self.resolve_store_uuid(&ctx, cx); + let entity = cx.new(|cx| WebViewTab::new(url, ctx.clone(), suuid, window, cx)); let nav = WebViewTab::nav_handler(entity.clone()); self.tabs.push(TabEntry { id, @@ -1019,7 +1020,7 @@ impl Workbench { let id = self.alloc_id(); let title = url_to_title(&url); let url_clone = url.clone(); - let entity = cx.new(|cx| WebViewTab::new(url, None, window, cx)); + let entity = cx.new(|cx| WebViewTab::new(url, None, None, window, cx)); let nav = WebViewTab::nav_handler(entity.clone()); self.tabs.push(TabEntry { id, @@ -2521,7 +2522,8 @@ setTimeout(function(){{r.remove();}},420);}})()"#, let title = url_to_title(&url); let url_clone = url.clone(); let context_id = self.resolve_context_id(cx); - let entity = cx.new(|cx| WebViewTab::new(url, context_id.clone(), window, cx)); + let store_uuid = self.resolve_store_uuid(&context_id, cx); + let entity = cx.new(|cx| WebViewTab::new(url, context_id.clone(), store_uuid, window, cx)); let nav = WebViewTab::nav_handler(entity.clone()); self.tabs.push(TabEntry { id, @@ -2590,7 +2592,8 @@ setTimeout(function(){{r.remove();}},420);}})()"#, let url_clone = url.clone(); // Background tabs (cmd-click) inherit context from the source (active) tab. let context_id = self.active_tab_context_id(); - let entity = cx.new(|cx| WebViewTab::new(url, context_id.clone(), window, cx)); + let store_uuid = self.resolve_store_uuid(&context_id, cx); + let entity = cx.new(|cx| WebViewTab::new(url, context_id.clone(), store_uuid, window, cx)); let nav = WebViewTab::nav_handler(entity.clone()); self.tabs.push(TabEntry { id, @@ -2622,16 +2625,18 @@ setTimeout(function(){{r.remove();}},420);}})()"#, /// When `experimental_contexts` is on: use `active_context`. /// When off: `None` if `isolated_tabs` is true, otherwise `Some("default")` for shared. fn resolve_context_id(&self, cx: &App) -> Option { + // isolated_tabs always wins — every new tab gets incognito. + if self.isolated_tabs { + return None; + } let experimental = cx .try_global::() .map(|g| g.settings.experimental_contexts) .unwrap_or(false); if experimental { self.active_context.clone() - } else if self.isolated_tabs { - None // isolated → incognito } else { - Some("default".to_string()) // shared persistent store + Some("default".to_string()) } } @@ -2642,6 +2647,26 @@ setTimeout(function(){{r.remove();}},420);}})()"#, }) } + /// Map a context_id to a WKWebsiteDataStore UUID for process isolation. + /// - `None` → `None` (incognito / nonPersistentDataStore) + /// - `Some("default")` → fixed DEFAULT_STORE_UUID + /// - `Some("ctx-N")` → UUID from SessionContext settings + fn resolve_store_uuid(&self, context_id: &Option, cx: &App) -> Option<[u8; 16]> { + let ctx_id = context_id.as_deref()?; + if ctx_id == "default" { + return Some(crate::settings::DEFAULT_STORE_UUID); + } + // Fail-closed: unknown/deleted context → None → incognito (via tabs.rs). + cx.try_global::() + .and_then(|g| { + g.settings + .contexts + .iter() + .find(|c| c.id == ctx_id) + .and_then(|c| c.data_store_uuid()) + }) + } + /// Switch the active context. If the active tab already has a URL loaded, /// close it and reopen the same URL in a new tab with the chosen context /// (WKWebView data stores can't change after creation). @@ -2715,6 +2740,9 @@ setTimeout(function(){{r.remove();}},420);}})()"#, id: id.clone(), name, color: color.to_string(), + store_uuid: Some(crate::settings::format_store_uuid( + &crate::settings::generate_store_uuid(), + )), }; cx.update_global::(|g, _| { @@ -3720,8 +3748,9 @@ setTimeout(function(){{r.remove();}},420);}})()"#, TabKind::WebView { url } => { let id = self.alloc_id(); let ctx = stab.context_id.clone(); + let suuid = self.resolve_store_uuid(&ctx, cx); let entity = cx.new(|cx| { - WebViewTab::new(url.clone(), ctx.clone(), window, cx) + WebViewTab::new(url.clone(), ctx.clone(), suuid, window, cx) }); let nav = WebViewTab::nav_handler(entity.clone()); self.tabs.push(TabEntry { diff --git a/docs/backlog.md b/docs/backlog.md index 6f870c9..ababb9d 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -51,6 +51,7 @@ Three interlocking advantages no other browser can replicate: - [x] ~~**Omnibox focus**: ensure omnibox input auto-focuses when opened~~ (done — `new_tab()` calls `window.focus(&focus_handle)`) - [x] ~~**WelcomeTab startup**: app should open omnibox immediately on launch~~ (done — `new_tab(window, cx)` called on startup) - [x] ~~**Crash on fast sidebar toggle**~~ (resolved — generation counter `sidebar_anim_gen` prevents concurrent animation loops) +- [ ] **Omnibox Escape key**: ⌘T opens omnibox but Escape doesn't close it — only clicking off works. Escape should dismiss the omnibox and return focus to the active tab. ---