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:
1 parent
ac0efd1798
commit
18dc796df0
6 files changed
+75
-7
No files matched your search
@@ -11,6 +11,14 @@ pub struct BrowserContext {
|
|||||||
pub robots_cache: Arc<RobotsCache>,
|
pub robots_cache: Arc<RobotsCache>,
|
||||||
pub obey_robots: bool,
|
pub obey_robots: bool,
|
||||||
pub stealth: 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 {
|
impl BrowserContext {
|
||||||
@@ -26,6 +34,7 @@ impl BrowserContext {
|
|||||||
robots_cache: Arc::new(RobotsCache::new()),
|
robots_cache: Arc::new(RobotsCache::new()),
|
||||||
obey_robots: false,
|
obey_robots: false,
|
||||||
stealth: false,
|
stealth: false,
|
||||||
|
allow_file_access: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +75,7 @@ impl BrowserContext {
|
|||||||
robots_cache: Arc::new(RobotsCache::new()),
|
robots_cache: Arc::new(RobotsCache::new()),
|
||||||
obey_robots: false,
|
obey_robots: false,
|
||||||
stealth,
|
stealth,
|
||||||
|
allow_file_access: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,12 +57,23 @@ impl CdpContext {
|
|||||||
stealth: bool,
|
stealth: bool,
|
||||||
user_agent: Option<String>,
|
user_agent: Option<String>,
|
||||||
) -> Self {
|
) -> 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(),
|
"default".to_string(),
|
||||||
proxy,
|
proxy,
|
||||||
stealth,
|
stealth,
|
||||||
user_agent,
|
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
|
// Pre-seed with the default-frame execution context ids that
|
||||||
// `Runtime.enable` (1) and post-navigation re-emission (2) advertise
|
// `Runtime.enable` (1) and post-navigation re-emission (2) advertise
|
||||||
// via Runtime.executionContextCreated. Anything else has to be
|
// via Runtime.executionContextCreated. Anything else has to be
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ use serde_json::{json, Value};
|
|||||||
use crate::dispatch::CdpContext;
|
use crate::dispatch::CdpContext;
|
||||||
use crate::types::CdpEvent;
|
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(
|
pub async fn handle(
|
||||||
method: &str,
|
method: &str,
|
||||||
params: &Value,
|
params: &Value,
|
||||||
@@ -16,6 +24,18 @@ pub async fn handle(
|
|||||||
let url = params.get("url").and_then(|v| v.as_str())
|
let url = params.get("url").and_then(|v| v.as_str())
|
||||||
.ok_or("url required")?;
|
.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")
|
let wait_until = params.get("waitUntil")
|
||||||
.and_then(|v| {
|
.and_then(|v| {
|
||||||
if let Some(s) = v.as_str() {
|
if let Some(s) = v.as_str() {
|
||||||
|
|||||||
@@ -3,4 +3,7 @@ pub mod dispatch;
|
|||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod domains;
|
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,
|
||||||
|
};
|
||||||
@@ -55,6 +55,17 @@ pub async fn start_with_host(
|
|||||||
proxy: Option<String>,
|
proxy: Option<String>,
|
||||||
stealth: bool,
|
stealth: bool,
|
||||||
user_agent: Option<String>,
|
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<()> {
|
) -> anyhow::Result<()> {
|
||||||
let ip: std::net::IpAddr = host
|
let ip: std::net::IpAddr = host
|
||||||
.parse()
|
.parse()
|
||||||
@@ -67,13 +78,16 @@ pub async fn start_with_host(
|
|||||||
"DevTools endpoint: ws://{}:{}/devtools/browser",
|
"DevTools endpoint: ws://{}:{}/devtools/browser",
|
||||||
host, port
|
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();
|
let local = tokio::task::LocalSet::new();
|
||||||
local
|
local
|
||||||
.run_until(async {
|
.run_until(async {
|
||||||
let (msg_tx, msg_rx) = mpsc::unbounded_channel::<ServerMessage>();
|
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 {
|
loop {
|
||||||
match listener.accept().await {
|
match listener.accept().await {
|
||||||
@@ -100,8 +114,9 @@ async fn cdp_processor(
|
|||||||
proxy: Option<String>,
|
proxy: Option<String>,
|
||||||
stealth: bool,
|
stealth: bool,
|
||||||
user_agent: Option<String>,
|
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>();
|
let (itx, irx) = mpsc::unbounded_channel::<obscura_js::ops::InterceptedRequest>();
|
||||||
ctx.intercept_tx = Some(itx);
|
ctx.intercept_tx = Some(itx);
|
||||||
let mut intercept_rx: Option<mpsc::UnboundedReceiver<obscura_js::ops::InterceptedRequest>> = Some(irx);
|
let mut intercept_rx: Option<mpsc::UnboundedReceiver<obscura_js::ops::InterceptedRequest>> = Some(irx);
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ enum Command {
|
|||||||
|
|
||||||
#[arg(long, default_value_t = 1)]
|
#[arg(long, default_value_t = 1)]
|
||||||
workers: u16,
|
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 {
|
Fetch {
|
||||||
@@ -192,7 +199,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let global_proxy = args.proxy.clone();
|
let global_proxy = args.proxy.clone();
|
||||||
|
|
||||||
match args.command {
|
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);
|
let proxy = merge_proxy(global_proxy.clone(), proxy);
|
||||||
print_banner(port);
|
print_banner(port);
|
||||||
if let Some(ref proxy) = proxy {
|
if let Some(ref proxy) = proxy {
|
||||||
@@ -214,7 +221,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::info!("{} worker processes", workers);
|
tracing::info!("{} worker processes", workers);
|
||||||
run_multi_worker_serve(port, workers, proxy, stealth, user_agent).await?;
|
run_multi_worker_serve(port, workers, proxy, stealth, user_agent).await?;
|
||||||
} else {
|
} 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 }) => {
|
Some(Command::Fetch { url, dump, selector, wait, timeout, wait_until, user_agent, stealth, eval, output, quiet }) => {
|
||||||
|
|||||||
Reference in new issue
Block a user