From b341991ae33b7ed36f084ae33023a15f1be2821d Mon Sep 17 00:00:00 2001 From: deswanth12 Date: Thu, 15 Oct 2026 18:36:15 +0530 Subject: [PATCH] fix(browser): make Chromium sandbox configurable --- crawl4ai/async_configs.py | 8 ++++ crawl4ai/browser_manager.py | 29 +++++++------ deploy/docker/server.py | 1 + tests/unit/test_browser_tls_sandbox_flags.py | 44 ++++++++++++++++++++ 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/crawl4ai/async_configs.py b/crawl4ai/async_configs.py index 15789be1e..246d8999a 100644 --- a/crawl4ai/async_configs.py +++ b/crawl4ai/async_configs.py @@ -1036,6 +1036,11 @@ class BrowserConfig: domains. Default: False. avoid_css (bool): If True, blocks loading of CSS files (css, less, scss, sass) to reduce resource usage and speed up crawling. Default: False. + chromium_sandbox (Optional[bool]): If True, enables Chromium's native sandbox protections and + omits --no-sandbox from launch arguments. Requires host/container + support (e.g. user namespaces or an appropriate seccomp profile). + If None (default), checks CRAWL4AI_CHROMIUM_SANDBOX environment + variable (defaults to False). Default: None. """ def __init__( @@ -1089,6 +1094,7 @@ def __init__( init_scripts: List[str] = None, memory_saving_mode: bool = False, max_pages_before_recycle: int = 0, + chromium_sandbox: Optional[bool] = None, ): self.browser_type = browser_type @@ -1156,6 +1162,7 @@ def __init__( self.init_scripts = init_scripts if init_scripts is not None else [] self.memory_saving_mode = memory_saving_mode self.max_pages_before_recycle = max_pages_before_recycle + self.chromium_sandbox = chromium_sandbox fa_user_agenr_generator = ValidUAGenerator() if self.user_agent_mode == "random": @@ -1253,6 +1260,7 @@ def to_dict(self): "init_scripts": self.init_scripts, "memory_saving_mode": self.memory_saving_mode, "max_pages_before_recycle": self.max_pages_before_recycle, + "chromium_sandbox": self.chromium_sandbox, } diff --git a/crawl4ai/browser_manager.py b/crawl4ai/browser_manager.py index fa671c694..6b6056350 100644 --- a/crawl4ai/browser_manager.py +++ b/crawl4ai/browser_manager.py @@ -41,16 +41,21 @@ ] -def chromium_sandbox_requested() -> bool: - """True when the operator opts into the Chromium sandbox - (CRAWL4AI_CHROMIUM_SANDBOX=true). The default keeps --no-sandbox because - Chromium cannot start sandboxed in a container without user namespaces.""" +def chromium_sandbox_requested(config: Optional[BrowserConfig] = None) -> bool: + """True when the Chromium sandbox is requested either explicitly via + BrowserConfig.chromium_sandbox or via the CRAWL4AI_CHROMIUM_SANDBOX=true + environment variable. The default keeps --no-sandbox because Chromium + cannot start sandboxed in a container without user namespaces.""" + if config is not None: + explicit = getattr(config, "chromium_sandbox", None) + if explicit is not None: + return bool(explicit) return os.environ.get("CRAWL4AI_CHROMIUM_SANDBOX", "false").lower() == "true" -def apply_sandbox_policy(args: List[str]) -> List[str]: +def apply_sandbox_policy(args: List[str], config: Optional[BrowserConfig] = None) -> List[str]: """Drop --no-sandbox, also from extra_args, when the sandbox is requested.""" - if chromium_sandbox_requested(): + if chromium_sandbox_requested(config): return [a for a in args if a != "--no-sandbox"] return args @@ -136,7 +141,7 @@ def build_browser_flags(config: BrowserConfig) -> List[str]: elif config.proxy_config: flags.append(f"--proxy-server={config.proxy_config.server}") # dedupe - return apply_sandbox_policy(list(dict.fromkeys(flags))) + return apply_sandbox_policy(list(dict.fromkeys(flags)), config=config) browser_type: str user_data_dir: str @@ -209,7 +214,7 @@ async def start(self) -> str: if self.browser_config.extra_args: args.extend(self.browser_config.extra_args) - args = apply_sandbox_policy(args) + args = apply_sandbox_policy(args, config=self.browser_config) # ── make sure no old Chromium instance is owning the same port/profile ── @@ -884,7 +889,7 @@ async def _start_impl(self): launch_kwargs = { "headless": self.config.headless, - "args": apply_sandbox_policy(list(dict.fromkeys(cli_args))), # dedupe + "args": apply_sandbox_policy(list(dict.fromkeys(cli_args)), config=self.config), # dedupe "viewport": { "width": self.config.viewport_width, "height": self.config.viewport_height, @@ -893,7 +898,7 @@ async def _start_impl(self): "ignore_https_errors": self.config.ignore_https_errors, "accept_downloads": self.config.accept_downloads, } - if chromium_sandbox_requested(): + if chromium_sandbox_requested(self.config): launch_kwargs["chromium_sandbox"] = True if self.config.proxy_config: @@ -1156,11 +1161,11 @@ def _build_browser_args(self) -> dict: args.extend(self.config.extra_args) # Deduplicate args - args = apply_sandbox_policy(list(dict.fromkeys(args))) + args = apply_sandbox_policy(list(dict.fromkeys(args)), config=self.config) browser_args = {"headless": self.config.headless, "args": args} # Playwright adds --no-sandbox itself unless chromium_sandbox is True. - if chromium_sandbox_requested(): + if chromium_sandbox_requested(self.config): browser_args["chromium_sandbox"] = True # On Windows, passing channel='chromium' (the default) causes Playwright diff --git a/deploy/docker/server.py b/deploy/docker/server.py index 0963da11d..cff1f961a 100644 --- a/deploy/docker/server.py +++ b/deploy/docker/server.py @@ -134,6 +134,7 @@ def get_default_browser_config() -> BrowserConfig: DNS-rebinding / redirect-to-internal protection as /crawl, rather than relying on each handler to remember it.""" bc = BrowserConfig( + chromium_sandbox=CHROMIUM_SANDBOX, extra_args=_browser_extra_args(), **config["crawler"]["browser"].get("kwargs", {}), ) diff --git a/tests/unit/test_browser_tls_sandbox_flags.py b/tests/unit/test_browser_tls_sandbox_flags.py index 6034ffda4..dd3f1cfca 100644 --- a/tests/unit/test_browser_tls_sandbox_flags.py +++ b/tests/unit/test_browser_tls_sandbox_flags.py @@ -76,6 +76,50 @@ def test_sandbox_requested_by_env(monkeypatch): assert "--no-sandbox" not in ManagedBrowser.build_browser_flags(bc) +def test_sandbox_requested_by_config(monkeypatch): + monkeypatch.delenv("CRAWL4AI_CHROMIUM_SANDBOX", raising=False) + bc = BrowserConfig(headless=True, chromium_sandbox=True, extra_args=["--no-sandbox", "--disable-gpu"]) + launch = BrowserManager(browser_config=bc)._build_browser_args() + assert "--no-sandbox" not in launch["args"] + assert "--disable-gpu" in launch["args"] + assert launch["chromium_sandbox"] is True + assert "--no-sandbox" not in ManagedBrowser.build_browser_flags(bc) + + +def test_sandbox_explicit_false_overrides_env(monkeypatch): + monkeypatch.setenv("CRAWL4AI_CHROMIUM_SANDBOX", "true") + bc = BrowserConfig(headless=True, chromium_sandbox=False, extra_args=["--disable-gpu"]) + launch = BrowserManager(browser_config=bc)._build_browser_args() + assert "--no-sandbox" in launch["args"] + assert not launch.get("chromium_sandbox") + assert "--no-sandbox" in ManagedBrowser.build_browser_flags(bc) + + +def test_sandbox_config_serialization_and_clone(): + bc = BrowserConfig(headless=True, chromium_sandbox=True) + dumped = bc.to_dict() + assert dumped["chromium_sandbox"] is True + + cloned = bc.clone() + assert cloned.chromium_sandbox is True + + overridden = bc.clone(chromium_sandbox=False) + assert overridden.chromium_sandbox is False + + +def test_sandbox_untrusted_load_ignored(monkeypatch): + from crawl4ai.async_configs import Provenance + + monkeypatch.delenv("CRAWL4AI_CHROMIUM_SANDBOX", raising=False) + loaded = BrowserConfig.load( + {"type": "BrowserConfig", "params": {"chromium_sandbox": True, "headless": True}}, + provenance=Provenance.UNTRUSTED, + ) + assert loaded.chromium_sandbox is None + # Default without env remains unsandboxed + assert "--no-sandbox" in BrowserManager(browser_config=loaded)._build_browser_args()["args"] + + # ── end to end: self-signed HTTPS origin ───────────────────────────────