237 lines
8.5 KiB
Python
237 lines
8.5 KiB
Python
"""
|
|
R2 trust-boundary behavioral tests (library-level provenance machinery).
|
|
|
|
Untrusted request bodies may only construct a strict subset of types and may
|
|
only set scalar, non-power fields, which are then clamped. Trusted (SDK /
|
|
in-process) construction is unchanged.
|
|
|
|
Docker-layer wiring tests (handlers passing UNTRUSTED, /config/dump
|
|
validate-only, declarative hooks) live alongside as the docker pieces land.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from crawl4ai.async_configs import (
|
|
BrowserConfig,
|
|
CrawlerRunConfig,
|
|
Provenance,
|
|
UntrustedConfigError,
|
|
)
|
|
|
|
T = Provenance.TRUSTED
|
|
U = Provenance.UNTRUSTED
|
|
|
|
pytestmark = pytest.mark.posture
|
|
|
|
|
|
class TestTrustedUnchanged:
|
|
"""SDK/in-process callers (default TRUSTED) keep full power."""
|
|
|
|
def test_trusted_keeps_power_fields(self):
|
|
c = CrawlerRunConfig.load(
|
|
{"js_code": "x=1", "page_timeout": 999999, "word_count_threshold": 5},
|
|
provenance=T,
|
|
)
|
|
assert c.js_code == "x=1"
|
|
assert c.page_timeout == 999999
|
|
|
|
def test_trusted_is_the_default(self):
|
|
# No provenance argument => behaves exactly as before.
|
|
c = CrawlerRunConfig.load({"js_code": "y=2"})
|
|
assert c.js_code == "y=2"
|
|
|
|
def test_trusted_roundtrip(self):
|
|
c = CrawlerRunConfig(js_code="z=3", screenshot=True, page_timeout=120000)
|
|
c2 = CrawlerRunConfig.load(c.dump())
|
|
assert c2.js_code == "z=3" and c2.page_timeout == 120000
|
|
|
|
|
|
class TestUntrustedForbiddenFields:
|
|
@pytest.mark.parametrize(
|
|
"field,value",
|
|
[
|
|
("js_code", "alert(1)"),
|
|
("js_code_before_wait", "x"),
|
|
("deep_crawl_strategy", {"type": "BFSDeepCrawlStrategy", "params": {}}),
|
|
("proxy_config", {"server": "http://evil"}),
|
|
("base_url", "http://evil"),
|
|
("simulate_user", True),
|
|
("magic", True),
|
|
("process_in_browser", True),
|
|
("session_id", "shared"),
|
|
],
|
|
)
|
|
def test_crawler_power_field_rejected(self, field, value):
|
|
with pytest.raises(UntrustedConfigError):
|
|
CrawlerRunConfig.load({field: value}, provenance=U)
|
|
|
|
@pytest.mark.parametrize(
|
|
"field,value",
|
|
[
|
|
("extra_args", ["--proxy-server=http://evil"]),
|
|
("proxy", "http://evil"),
|
|
("user_data_dir", "/etc"),
|
|
("cdp_url", "ws://evil"),
|
|
("cookies", [{"name": "s", "value": "x"}]),
|
|
("headers", {"X": "y"}),
|
|
("init_scripts", ["evil()"]),
|
|
],
|
|
)
|
|
def test_browser_power_field_rejected(self, field, value):
|
|
with pytest.raises(UntrustedConfigError):
|
|
BrowserConfig.load({field: value}, provenance=U)
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
["--no-zygote", "--utility-cmd-prefix=sh -c id>/tmp/pwn"],
|
|
["--renderer-cmd-prefix=/bin/sh"],
|
|
["--gpu-launcher=touch /tmp/x"],
|
|
["--browser-subprocess-path=/bin/sh"],
|
|
],
|
|
)
|
|
def test_extra_args_chromium_cmd_injection_rejected(self, payload):
|
|
"""Y4tacker RCE: Chromium launch-arg command injection via extra_args.
|
|
extra_args is a forbidden field for untrusted bodies, so ANY value
|
|
(including the --*-cmd-prefix / --no-zygote exec primitives) is rejected
|
|
outright rather than scrubbed by an always-incomplete denylist."""
|
|
with pytest.raises(UntrustedConfigError):
|
|
BrowserConfig.load({"extra_args": payload}, provenance=U)
|
|
|
|
|
|
class TestUntrustedTypeGate:
|
|
def test_llm_strategy_rejected(self):
|
|
body = {
|
|
"type": "CrawlerRunConfig",
|
|
"params": {
|
|
"extraction_strategy": {
|
|
"type": "LLMExtractionStrategy",
|
|
"params": {},
|
|
}
|
|
},
|
|
}
|
|
with pytest.raises(UntrustedConfigError):
|
|
CrawlerRunConfig.load(body, provenance=U)
|
|
|
|
def test_env_exfil_poc_rejected(self):
|
|
"""The verified os.getenv-via-'env:' credential-exfil PoC must 400."""
|
|
poc = {
|
|
"type": "CrawlerRunConfig",
|
|
"params": {
|
|
"extraction_strategy": {
|
|
"type": "LLMExtractionStrategy",
|
|
"params": {
|
|
"llm_config": {
|
|
"type": "LLMConfig",
|
|
"params": {"api_token": "env:SECRET_KEY"},
|
|
}
|
|
},
|
|
}
|
|
},
|
|
}
|
|
with pytest.raises(UntrustedConfigError):
|
|
CrawlerRunConfig.load(poc, provenance=U)
|
|
|
|
def test_allowed_non_llm_strategy_accepted(self):
|
|
body = {
|
|
"type": "CrawlerRunConfig",
|
|
"params": {
|
|
"extraction_strategy": {
|
|
"type": "JsonCssExtractionStrategy",
|
|
"params": {"schema": {"name": "x", "baseSelector": "div", "fields": []}},
|
|
}
|
|
},
|
|
}
|
|
c = CrawlerRunConfig.load(body, provenance=U)
|
|
assert c.extraction_strategy is not None
|
|
|
|
|
|
class TestUntrustedClampAndDrop:
|
|
def test_timeout_zero_becomes_cap(self):
|
|
c = CrawlerRunConfig.load({"page_timeout": 0}, provenance=U)
|
|
assert c.page_timeout == 60_000 # 0 ("no timeout") clamped, never unbounded
|
|
|
|
def test_timeout_clamped(self):
|
|
c = CrawlerRunConfig.load({"wait_for_timeout": 500000}, provenance=U)
|
|
assert c.wait_for_timeout == 60_000
|
|
|
|
def test_unknown_field_dropped_not_raised(self):
|
|
c = CrawlerRunConfig.load(
|
|
{"css_selector": ".x", "totally_unknown_field": 1}, provenance=U
|
|
)
|
|
assert c.css_selector == ".x"
|
|
|
|
def test_viewport_clamped(self):
|
|
b = BrowserConfig.load({"viewport_width": 99999, "headless": True}, provenance=U)
|
|
assert b.viewport_width == 4000
|
|
assert b.headless is True
|
|
|
|
def test_safe_scalar_kept(self):
|
|
c = CrawlerRunConfig.load(
|
|
{"word_count_threshold": 7, "screenshot": True, "wait_until": "load"},
|
|
provenance=U,
|
|
)
|
|
assert c.word_count_threshold == 7 and c.screenshot is True
|
|
|
|
|
|
class TestLlmConfigGuard:
|
|
def test_untrusted_env_token_rejected(self):
|
|
from crawl4ai.async_configs import LLMConfig
|
|
with pytest.raises(UntrustedConfigError):
|
|
LLMConfig(api_token="env:SECRET_KEY", provenance=U)
|
|
|
|
def test_untrusted_never_reads_env(self, monkeypatch):
|
|
from crawl4ai.async_configs import LLMConfig
|
|
monkeypatch.setenv("OPENAI_API_KEY", "leak-me")
|
|
cfg = LLMConfig(provider="openai/gpt-4o-mini", provenance=U)
|
|
assert cfg.api_token != "leak-me"
|
|
|
|
|
|
class TestDockerHandlersEnforceUntrusted:
|
|
"""The Docker endpoints map UntrustedConfigError -> HTTP 400 (not 500)."""
|
|
|
|
def _auth(self, server_module):
|
|
from auth import create_access_token
|
|
return {"Authorization": f"Bearer {create_access_token({'sub': 'u@x.com'})}"}
|
|
|
|
def test_crawl_rejects_js_code(self, stock_client, server_module):
|
|
r = stock_client.post(
|
|
"/crawl",
|
|
json={"urls": ["https://example.com"], "crawler_config": {"js_code": "fetch('http://169.254.169.254')"}},
|
|
headers=self._auth(server_module),
|
|
)
|
|
assert r.status_code == 400, r.status_code
|
|
|
|
def test_crawl_rejects_proxy_config(self, stock_client, server_module):
|
|
r = stock_client.post(
|
|
"/crawl",
|
|
json={"urls": ["https://example.com"],
|
|
"browser_config": {"proxy_config": {"server": "http://evil"}}},
|
|
headers=self._auth(server_module),
|
|
)
|
|
assert r.status_code == 400, r.status_code
|
|
|
|
def test_config_dump_rejects_power_field(self, stock_client, server_module):
|
|
r = stock_client.post(
|
|
"/config/dump",
|
|
json={"type": "BrowserConfig", "params": {"extra_args": ["--proxy-server=x"]}},
|
|
headers=self._auth(server_module),
|
|
)
|
|
assert r.status_code == 400, r.status_code
|
|
|
|
def test_config_dump_validates_safe_config(self, stock_client, server_module):
|
|
r = stock_client.post(
|
|
"/config/dump",
|
|
json={"type": "CrawlerRunConfig", "params": {"word_count_threshold": 5, "screenshot": True}},
|
|
headers=self._auth(server_module),
|
|
)
|
|
assert r.status_code == 200, r.status_code
|
|
|
|
def test_config_dump_drops_unknown_keeps_safe(self, stock_client, server_module):
|
|
r = stock_client.post(
|
|
"/config/dump",
|
|
json={"type": "CrawlerRunConfig", "params": {"css_selector": ".x", "bogus_field": 1}},
|
|
headers=self._auth(server_module),
|
|
)
|
|
assert r.status_code == 200, r.status_code
|