feat(cli): --allow-private-network for localhost development (#33) (#203)

Picks up @Timtech4u's PR #33, which has been waiting since April. The
  design (CLI flag + threading through BrowserContext + http client) is
  theirs and is correct; this is a clean rewrite on top of current main
  because repeated rebase rounds had been reverting unrelated work that
  landed in the meantime.

  Issue #4 blocked all loopback / RFC1918 / link-local fetches to prevent
  SSRF. The only escape was OBSCURA_ALLOW_PRIVATE_NETWORK=1, which is
  process-wide and clunky in pipelines. Multiple users (@Timtech4u in
  the original PR, then @duartemvix and @necrogami in the comment
  thread) reported they cannot use obscura for development against
  http://localhost:N without it.

  Adds --allow-private-network as a global CLI flag, works on fetch,
  serve, and scrape. Threads through BrowserContext::with_storage_and_network
  and the http client's validate_url. CdpContext gets a new_full
  builder and obscura_cdp::start_with_full_serve_options entry point so
  'obscura serve --allow-private-network' lets Puppeteer/Playwright
  navigate to localhost too. Default remains blocked.

  Backward compat: the env var still works (OR'd with the flag inside
  validate_url). All existing constructors and entry points unchanged.

  Verified end-to-end against a local http.server on 127.0.0.1: fetch
  and CDP Page.navigate both blocked by default, both succeed with the
  flag. Public URLs unaffected. Integration suite 28/28, full workspace
  cargo test all green.
This commit is contained in:
SGavrl authored and GitHub committed 2026-05-29 17:53:23 +00:00
1 parent 1a1253118f
commit e7bb0c676d
7 files changed
+140 -40

No files matched your search

+26 -18
View File
@@ -74,7 +74,7 @@ impl CdpContext {
user_agent: Option<String>,
storage_dir: Option<std::path::PathBuf>,
) -> Self {
Self::_new_inner(proxy, stealth, user_agent, storage_dir, false)
Self::_new_inner(proxy, stealth, user_agent, storage_dir, false, false)
}
pub fn new_with_security(
@@ -83,7 +83,22 @@ impl CdpContext {
user_agent: Option<String>,
allow_file_access: bool,
) -> Self {
Self::_new_inner(proxy, stealth, user_agent, None, allow_file_access)
Self::_new_inner(proxy, stealth, user_agent, None, allow_file_access, false)
}
/// Kitchen-sink constructor that also threads `allow_private_network`
/// (issue #33). Older `new_with_*` builders stay as-is.
pub fn new_full(
proxy: Option<String>,
stealth: bool,
user_agent: Option<String>,
storage_dir: Option<std::path::PathBuf>,
allow_file_access: bool,
allow_private_network: bool,
) -> Self {
Self::_new_inner(
proxy, stealth, user_agent, storage_dir, allow_file_access, allow_private_network,
)
}
fn _new_inner(
@@ -92,23 +107,16 @@ impl CdpContext {
user_agent: Option<String>,
storage_dir: Option<std::path::PathBuf>,
allow_file_access: bool,
allow_private_network: bool,
) -> Self {
let mut ctx = if let Some(ref dir) = storage_dir {
BrowserContext::with_storage_full(
"default".to_string(),
proxy,
stealth,
user_agent,
Some(dir.clone()),
)
} else {
BrowserContext::with_full_options(
"default".to_string(),
proxy,
stealth,
user_agent,
)
};
let mut ctx = BrowserContext::with_storage_and_network(
"default".to_string(),
proxy,
stealth,
user_agent,
storage_dir,
allow_private_network,
);
ctx.allow_file_access = allow_file_access;
let default_context = Arc::new(ctx);
// Pre-seed with the default-frame execution context ids that
+2 -2
View File
@@ -6,6 +6,6 @@ pub mod cookie_params;
pub(crate) mod util;
pub use server::{
start, start_with_full_options, start_with_host, start_with_host_and_security,
start_with_options,
start, start_with_full_options, start_with_full_serve_options, start_with_host,
start_with_host_and_security, start_with_options,
};
+33 -7
View File
@@ -80,7 +80,10 @@ pub async fn start_with_host_and_security(
allow_file_access: bool,
storage_dir: Option<std::path::PathBuf>,
) -> anyhow::Result<()> {
start_with_host_security_and_storage(port, host, proxy, stealth, user_agent, allow_file_access, storage_dir).await
start_with_full_serve_options(
port, host, proxy, stealth, user_agent, allow_file_access, storage_dir, false,
)
.await
}
pub async fn start_with_host_security_and_storage(
@@ -91,6 +94,25 @@ pub async fn start_with_host_security_and_storage(
user_agent: Option<String>,
allow_file_access: bool,
storage_dir: Option<std::path::PathBuf>,
) -> anyhow::Result<()> {
start_with_full_serve_options(
port, host, proxy, stealth, user_agent, allow_file_access, storage_dir, false,
)
.await
}
/// Full serve entry point that also accepts `allow_private_network` (issue
/// #33). Older entry points default it to `false` so existing callers and
/// public API consumers are unaffected.
pub async fn start_with_full_serve_options(
port: u16,
host: &str,
proxy: Option<String>,
stealth: bool,
user_agent: Option<String>,
allow_file_access: bool,
storage_dir: Option<std::path::PathBuf>,
allow_private_network: bool,
) -> anyhow::Result<()> {
let ip: std::net::IpAddr = host
.parse()
@@ -162,6 +184,7 @@ pub async fn start_with_host_security_and_storage(
let _processor_handle = tokio::task::spawn_local(cdp_processor(
msg_rx, proxy, stealth, user_agent, allow_file_access, storage_dir,
allow_private_network,
));
while let Some(stream) = ws_rx.recv().await {
@@ -300,13 +323,16 @@ async fn cdp_processor(
user_agent: Option<String>,
allow_file_access: bool,
storage_dir: Option<std::path::PathBuf>,
allow_private_network: bool,
) {
let mut ctx = if storage_dir.is_some() {
// Use storage-aware context creation with security settings
CdpContext::new_with_storage(proxy, stealth, user_agent, storage_dir)
} else {
CdpContext::new_with_security(proxy, stealth, user_agent, allow_file_access)
};
let mut ctx = CdpContext::new_full(
proxy,
stealth,
user_agent,
storage_dir,
allow_file_access,
allow_private_network,
);
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);