From 18dc796df0f2e71680a154e7771067d904a58f6c Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Thu, 14 May 2026 21:36:55 +0000 Subject: [PATCH] fix(cdp): gate file:// navigation behind --allow-file-access GHSA-q55h-vfv9-qcr5. Any process able to reach the CDP port could send Page.navigate {"url":"file:///etc/shadow"} and read arbitrary local files (the process's UID's worth of read access). When obscura runs as root inside Docker or as a systemd service that translates straight into /etc/shadow, /root/.ssh/id_rsa, /proc/1/environ, etc. Validate at the CDP boundary, not in obscura-net, so direct CLI callers ("obscura fetch file://...") keep working. A new BrowserContext.allow_file_access flag defaults to false; Page.navigate returns an error when the requested URL is file:// and the flag is off. CdpContext::new_with_security threads the flag from the server entry point, and "obscura serve --allow-file-access" opts in for local-HTML testing workflows. Tested: - Default obscura serve: CDP Page.navigate {file:///etc/passwd} -> refused with descriptive error. - obscura serve --allow-file-access: same call succeeds. - obscura fetch file://... CLI path: unchanged, still reads the file. - All earlier PoCs (GHSA-6pj9 JS->file SOP, GHSA-4789 sub-resource SSRF, GHSA-4789 CSS template injection) still pass. - cargo test --workspace clean. --- crates/obscura-browser/src/context.rs | 10 ++++++++++ crates/obscura-cdp/src/dispatch.rs | 15 +++++++++++++-- crates/obscura-cdp/src/domains/page.rs | 20 ++++++++++++++++++++ crates/obscura-cdp/src/lib.rs | 5 ++++- crates/obscura-cdp/src/server.rs | 19 +++++++++++++++++-- crates/obscura-cli/src/main.rs | 13 +++++++++++-- 6 files changed, 75 insertions(+), 7 deletions(-) diff --git a/crates/obscura-browser/src/context.rs b/crates/obscura-browser/src/context.rs index b539f0f..2a33596 100644 --- a/crates/obscura-browser/src/context.rs +++ b/crates/obscura-browser/src/context.rs @@ -11,6 +11,14 @@ pub struct BrowserContext { pub robots_cache: Arc, pub obey_robots: bool, pub stealth: bool, + /// When true, CDP-driven navigation to file:// URLs is permitted. + /// Default is false: a remote CDP client cannot point the browser + /// at /etc/shadow even if Obscura is running as a privileged user. + /// Flip on via `obscura serve --allow-file-access` for legitimate + /// local-HTML testing workflows. The CLI's own `obscura fetch + /// file://...` path is unaffected because it does not go through + /// the CDP server. + pub allow_file_access: bool, } impl BrowserContext { @@ -26,6 +34,7 @@ impl BrowserContext { robots_cache: Arc::new(RobotsCache::new()), obey_robots: false, stealth: false, + allow_file_access: false, } } @@ -66,6 +75,7 @@ impl BrowserContext { robots_cache: Arc::new(RobotsCache::new()), obey_robots: false, stealth, + allow_file_access: false, } } diff --git a/crates/obscura-cdp/src/dispatch.rs b/crates/obscura-cdp/src/dispatch.rs index 9a50eb7..f30bc9d 100644 --- a/crates/obscura-cdp/src/dispatch.rs +++ b/crates/obscura-cdp/src/dispatch.rs @@ -57,12 +57,23 @@ impl CdpContext { stealth: bool, user_agent: Option, ) -> Self { - let default_context = Arc::new(BrowserContext::with_full_options( + Self::new_with_security(proxy, stealth, user_agent, false) + } + + pub fn new_with_security( + proxy: Option, + stealth: bool, + user_agent: Option, + allow_file_access: bool, + ) -> Self { + let mut ctx = BrowserContext::with_full_options( "default".to_string(), proxy, stealth, user_agent, - )); + ); + ctx.allow_file_access = allow_file_access; + let default_context = Arc::new(ctx); // Pre-seed with the default-frame execution context ids that // `Runtime.enable` (1) and post-navigation re-emission (2) advertise // via Runtime.executionContextCreated. Anything else has to be diff --git a/crates/obscura-cdp/src/domains/page.rs b/crates/obscura-cdp/src/domains/page.rs index 9c46d0e..684a29e 100644 --- a/crates/obscura-cdp/src/domains/page.rs +++ b/crates/obscura-cdp/src/domains/page.rs @@ -4,6 +4,14 @@ use serde_json::{json, Value}; use crate::dispatch::CdpContext; use crate::types::CdpEvent; +fn url_is_file_scheme(raw: &str) -> bool { + url::Url::parse(raw) + .map(|u| u.scheme().eq_ignore_ascii_case("file")) + .unwrap_or_else(|_| { + raw.trim_start().to_ascii_lowercase().starts_with("file:") + }) +} + pub async fn handle( method: &str, params: &Value, @@ -16,6 +24,18 @@ pub async fn handle( let url = params.get("url").and_then(|v| v.as_str()) .ok_or("url required")?; + // Block CDP-initiated file:// navigation by default. + // Anyone who can reach the CDP port (default localhost, + // but Docker images bind 0.0.0.0) could otherwise read + // any file the obscura process can read. Opt in via + // `obscura serve --allow-file-access` when local-HTML + // testing is the intended workflow. + if url_is_file_scheme(url) && !ctx.default_context.allow_file_access { + return Err( + "Page.navigate to file:// is disabled. Restart with `obscura serve --allow-file-access` to enable.".to_string() + ); + } + let wait_until = params.get("waitUntil") .and_then(|v| { if let Some(s) = v.as_str() { diff --git a/crates/obscura-cdp/src/lib.rs b/crates/obscura-cdp/src/lib.rs index 5f7382b..ec4d90e 100644 --- a/crates/obscura-cdp/src/lib.rs +++ b/crates/obscura-cdp/src/lib.rs @@ -3,4 +3,7 @@ pub mod dispatch; pub mod types; pub mod domains; -pub use server::{start, start_with_full_options, start_with_host, start_with_options}; +pub use server::{ + start, start_with_full_options, start_with_host, start_with_host_and_security, + start_with_options, +}; diff --git a/crates/obscura-cdp/src/server.rs b/crates/obscura-cdp/src/server.rs index 4ac7ec7..b59b001 100644 --- a/crates/obscura-cdp/src/server.rs +++ b/crates/obscura-cdp/src/server.rs @@ -55,6 +55,17 @@ pub async fn start_with_host( proxy: Option, stealth: bool, user_agent: Option, +) -> anyhow::Result<()> { + start_with_host_and_security(port, host, proxy, stealth, user_agent, false).await +} + +pub async fn start_with_host_and_security( + port: u16, + host: &str, + proxy: Option, + stealth: bool, + user_agent: Option, + allow_file_access: bool, ) -> anyhow::Result<()> { let ip: std::net::IpAddr = host .parse() @@ -67,13 +78,16 @@ pub async fn start_with_host( "DevTools endpoint: ws://{}:{}/devtools/browser", host, port ); + if allow_file_access { + info!("file:// navigation enabled (--allow-file-access). Do not expose this port to untrusted networks."); + } let local = tokio::task::LocalSet::new(); local .run_until(async { let (msg_tx, msg_rx) = mpsc::unbounded_channel::(); - let _processor_handle = tokio::task::spawn_local(cdp_processor(msg_rx, proxy, stealth, user_agent)); + let _processor_handle = tokio::task::spawn_local(cdp_processor(msg_rx, proxy, stealth, user_agent, allow_file_access)); loop { match listener.accept().await { @@ -100,8 +114,9 @@ async fn cdp_processor( proxy: Option, stealth: bool, user_agent: Option, + allow_file_access: bool, ) { - let mut ctx = CdpContext::new_with_full_options(proxy, stealth, user_agent); + let mut ctx = CdpContext::new_with_security(proxy, stealth, user_agent, allow_file_access); let (itx, irx) = mpsc::unbounded_channel::(); ctx.intercept_tx = Some(itx); let mut intercept_rx: Option> = Some(irx); diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs index c4f3503..9ce748a 100644 --- a/crates/obscura-cli/src/main.rs +++ b/crates/obscura-cli/src/main.rs @@ -53,6 +53,13 @@ enum Command { #[arg(long, default_value_t = 1)] workers: u16, + + /// Allow CDP clients to navigate to file:// URLs. Off by + /// default so a CDP connection cannot read arbitrary local + /// files. Enable only when serving local HTML for testing + /// and the port is on a trusted network. + #[arg(long)] + allow_file_access: bool, }, Fetch { @@ -192,7 +199,7 @@ async fn main() -> anyhow::Result<()> { let global_proxy = args.proxy.clone(); match args.command { - Some(Command::Serve { port, host, proxy, user_agent, stealth, workers }) => { + Some(Command::Serve { port, host, proxy, user_agent, stealth, workers, allow_file_access }) => { let proxy = merge_proxy(global_proxy.clone(), proxy); print_banner(port); if let Some(ref proxy) = proxy { @@ -214,7 +221,9 @@ async fn main() -> anyhow::Result<()> { tracing::info!("{} worker processes", workers); run_multi_worker_serve(port, workers, proxy, stealth, user_agent).await?; } else { - obscura_cdp::start_with_host(port, &host, proxy, stealth, user_agent).await?; + obscura_cdp::start_with_host_and_security( + port, &host, proxy, stealth, user_agent, allow_file_access, + ).await?; } } Some(Command::Fetch { url, dump, selector, wait, timeout, wait_until, user_agent, stealth, eval, output, quiet }) => {