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.
This commit is contained in:
SGavrl authored and h4ckf0r0day committed 2026-05-14 23:46:35 +02:00
1 parent ac0efd1798
commit 18dc796df0
6 files changed
+75 -7

No files matched your search

+13 -2
View File
@@ -57,12 +57,23 @@ impl CdpContext {
stealth: bool,
user_agent: Option<String>,
) -> 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<String>,
stealth: bool,
user_agent: Option<String>,
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
+20
View File
@@ -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() {
+4 -1
View File
@@ -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,
};
+17 -2
View File
@@ -55,6 +55,17 @@ pub async fn start_with_host(
proxy: Option<String>,
stealth: bool,
user_agent: Option<String>,
) -> 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<String>,
stealth: bool,
user_agent: Option<String>,
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::<ServerMessage>();
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<String>,
stealth: bool,
user_agent: Option<String>,
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::<obscura_js::ops::InterceptedRequest>();
ctx.intercept_tx = Some(itx);
let mut intercept_rx: Option<mpsc::UnboundedReceiver<obscura_js::ops::InterceptedRequest>> = Some(irx);