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
14 changes: 13 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>` (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 `<link rel="icon">` URL (prefers higher resolution) and falls back to `/favicon.ico`; posts `{type:'faviconFound', url}` to `epocaFavicon` on DOMContentLoaded and SPA navigations
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/epoca-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
194 changes: 190 additions & 4 deletions crates/epoca-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>,
}

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)]
Expand Down Expand Up @@ -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"))]
{
Expand Down Expand Up @@ -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")
);
}
}
44 changes: 40 additions & 4 deletions crates/epoca-core/src/tabs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2929,9 +2929,10 @@ pub struct WebViewTab {
}

impl WebViewTab {
pub fn new(url: String, context_id: Option<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
// None = isolated (private), Some = shared named context (persistent store)
let isolated = context_id.is_none();
pub fn new(url: String, _context_id: Option<String>, store_uuid: Option<[u8; 16]>, window: &mut Window, cx: &mut Context<Self>) -> 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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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<bool> = 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::<u32>().ok())
.map(|major| major >= 14)
.unwrap_or(false)
})
}
Loading