Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crawl4ai/async_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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,
}


Expand Down
29 changes: 17 additions & 12 deletions crawl4ai/browser_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ──
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions deploy/docker/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", {}),
)
Expand Down
44 changes: 44 additions & 0 deletions tests/unit/test_browser_tls_sandbox_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────


Expand Down