Skip to content

Commit aa81e8f

Browse files
committed
security: non-breaking hardening patch (0.8.8)
Backward-compatible fixes for the Docker server - features keep working, only the unsafe behavior is closed. (The secure-by-default redesign is the later major.) - SSRF: replace the explicit blocklist with the one rule (reject any resolved IP where not ip.is_global) evaluated on embedded IPv4 transition forms too, closing the gaps - IPv6 unspecified ::, NAT64 64:ff9b::/96, 6to4 2002::/16, v4-mapped. Error messages are now opaque (no resolved-IP leak). - output_path arbitrary write: harden validate_output_path with realpath containment (defeats a symlinked path component) and write via O_NOFOLLOW (write_output_file). output_path stays supported. - LLM base_url key exfil: ignore a request-supplied base_url in /md, /llm, /llm/job; the endpoint is always server-derived. Field still accepted (no 4xx) for compatibility. - env:SECRET_KEY exfil gadget: LLMConfig refuses env: resolution of protected names (SECRET/PASSWORD/PRIVATE substrings, CRAWL4AI*/AWS_SECRET* prefixes, SECRET_KEY/REDIS_PASSWORD/TOKEN). Normal provider keys (OPENAI_API_KEY, ...) unaffected. - CRLF log injection: CRLFSafeFilter strips CR/LF/control from log records. - Webhook header injection: sanitize_webhook_headers (name pattern, no control chars, deny hop-by-hop/sensitive) at send time + a WebhookConfig validator for early 422. Bump 0.8.7 -> 0.8.8 (__version__ + Dockerfile C4AI_VER). 30 new behavioral tests; existing 111 security tests + 112 library config tests still pass. NOT included (breaking -> deferred to the major): auth-by-default, trust boundary, declarative hooks, output_path removal, base_url/provider removal, loopback bind, redis password, TLS-verify-on, CORS, bounded queue. The exec-hook RCE and unauth-by-default criticals have no non-breaking fix and are closed only in the major (hooks are already off by default).
1 parent 72fd78e commit aa81e8f

9 files changed

Lines changed: 330 additions & 38 deletions

File tree

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
FROM python:3.12-slim-bookworm AS build
22

33
# C4ai version
4-
ARG C4AI_VER=0.8.7
4+
ARG C4AI_VER=0.8.8
55
ENV C4AI_VERSION=$C4AI_VER
66
LABEL c4ai.version=$C4AI_VER
77

crawl4ai/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# crawl4ai/__version__.py
22

33
# This is the version that will be used for stable releases
4-
__version__ = "0.8.7"
4+
__version__ = "0.8.8"
55

66
# For nightly builds, this gets set during build process
77
__nightly_version__ = None

crawl4ai/async_configs.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2022,6 +2022,30 @@ def clone(self, **kwargs):
20222022
config_dict.update(kwargs)
20232023
return CrawlerRunConfig.from_kwargs(config_dict)
20242024

2025+
2026+
# Environment variable names that `LLMConfig(api_token="env:NAME")` may NOT
2027+
# resolve. This blocks the credential-exfil gadget where an untrusted config
2028+
# body sets api_token="env:SECRET_KEY" and pairs it with a base_url to leak a
2029+
# server secret. Normal provider keys (OPENAI_API_KEY, ANTHROPIC_API_KEY,
2030+
# HF_TOKEN, ...) are unaffected.
2031+
_FORBIDDEN_ENV_SUBSTRINGS = ("SECRET", "PASSWORD", "PRIVATE", "PASSWD")
2032+
_FORBIDDEN_ENV_PREFIXES = ("CRAWL4AI", "AWS_SECRET")
2033+
_FORBIDDEN_ENV_EXACT = {"SECRET_KEY", "REDIS_PASSWORD", "TOKEN"}
2034+
2035+
2036+
def _is_forbidden_env_name(name: str) -> bool:
2037+
if not name:
2038+
return True
2039+
u = name.upper()
2040+
if u in _FORBIDDEN_ENV_EXACT:
2041+
return True
2042+
if any(s in u for s in _FORBIDDEN_ENV_SUBSTRINGS):
2043+
return True
2044+
if any(u.startswith(p) for p in _FORBIDDEN_ENV_PREFIXES):
2045+
return True
2046+
return False
2047+
2048+
20252049
class LLMConfig:
20262050
def __init__(
20272051
self,
@@ -2044,7 +2068,17 @@ def __init__(
20442068
if api_token and not api_token.startswith("env:"):
20452069
self.api_token = api_token
20462070
elif api_token and api_token.startswith("env:"):
2047-
self.api_token = os.getenv(api_token[4:])
2071+
# `env:NAME` resolves an environment variable. Refuse to read names
2072+
# that are clearly server secrets, so an untrusted config body cannot
2073+
# exfiltrate them via `env:SECRET_KEY` and a paired base_url. Normal
2074+
# provider keys (…_API_KEY / …_TOKEN) are unaffected.
2075+
_env_name = api_token[4:]
2076+
if _is_forbidden_env_name(_env_name):
2077+
raise ValueError(
2078+
f"LLMConfig.api_token may not reference the protected "
2079+
f"environment variable {_env_name!r}"
2080+
)
2081+
self.api_token = os.getenv(_env_name)
20482082
else:
20492083
# Check if given provider starts with any of key in PROVIDER_MODELS_PREFIXES
20502084
# If not, check if it is in PROVIDER_MODELS

deploy/docker/api.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ async def handle_llm_qa(
129129
prompt_with_variables=prompt,
130130
api_token=get_llm_api_key(config, resolved_provider),
131131
temperature=temperature or get_llm_temperature(config, resolved_provider),
132-
base_url=base_url or get_llm_base_url(config, resolved_provider),
132+
base_url=get_llm_base_url(config, resolved_provider), # ignore request base_url (key-exfil vector)
133133
base_delay=config["llm"].get("backoff_base_delay", 2),
134134
max_attempts=config["llm"].get("backoff_max_attempts", 3),
135135
exponential_factor=config["llm"].get("backoff_exponential_factor", 2)
@@ -188,7 +188,7 @@ async def process_llm_extraction(
188188
provider=provider or config["llm"]["provider"],
189189
api_token=api_key,
190190
temperature=temperature or get_llm_temperature(config, provider),
191-
base_url=base_url or get_llm_base_url(config, provider)
191+
base_url=get_llm_base_url(config, provider) # ignore request base_url (key-exfil vector)
192192
),
193193
instruction=instruction,
194194
schema=json.loads(schema) if schema else None,
@@ -299,7 +299,7 @@ async def handle_markdown_request(
299299
provider=provider or config["llm"]["provider"],
300300
api_token=get_llm_api_key(config, provider), # Returns None to let litellm handle it
301301
temperature=temperature or get_llm_temperature(config, provider),
302-
base_url=base_url or get_llm_base_url(config, provider)
302+
base_url=get_llm_base_url(config, provider) # ignore request base_url (key-exfil vector)
303303
),
304304
instruction=query or "Extract main content"
305305
)

deploy/docker/schemas.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ class WebhookConfig(BaseModel):
121121
webhook_data_in_payload: bool = False
122122
webhook_headers: Optional[Dict[str, str]] = None
123123

124+
@field_validator("webhook_headers")
125+
@classmethod
126+
def _validate_headers(cls, v):
127+
# Reject unsafe outbound headers early (422). Mirrors
128+
# webhook.sanitize_webhook_headers; kept inline to avoid an import cycle.
129+
if not v:
130+
return v
131+
from webhook import sanitize_webhook_headers
132+
return sanitize_webhook_headers(v)
133+
124134

125135
class WebhookPayload(BaseModel):
126136
"""Payload sent to webhook endpoints."""

deploy/docker/server.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636

3737
from utils import (
3838
FilterType, load_config, setup_logging, verify_email_domain,
39-
validate_output_path, validate_webhook_url, validate_url_destination,
39+
validate_output_path, write_output_file, validate_webhook_url, validate_url_destination,
4040
)
4141
import os
4242
import sys
@@ -426,9 +426,7 @@ async def generate_screenshot(
426426
screenshot_data = results[0].screenshot
427427
if body.output_path:
428428
abs_path = validate_output_path(body.output_path)
429-
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
430-
with open(abs_path, "wb") as f:
431-
f.write(base64.b64decode(screenshot_data))
429+
write_output_file(abs_path, base64.b64decode(screenshot_data))
432430
return {"success": True, "path": abs_path}
433431
return {"success": True, "screenshot": screenshot_data}
434432
except Exception as e:
@@ -464,9 +462,7 @@ async def generate_pdf(
464462
pdf_data = results[0].pdf
465463
if body.output_path:
466464
abs_path = validate_output_path(body.output_path)
467-
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
468-
with open(abs_path, "wb") as f:
469-
f.write(pdf_data)
465+
write_output_file(abs_path, pdf_data)
470466
return {"success": True, "path": abs_path}
471467
return {"success": True, "pdf": base64.b64encode(pdf_data).decode()}
472468
except Exception as e:
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""
2+
Behavioral tests for the 0.8.8 non-breaking security patch.
3+
4+
Each fix here is backward-compatible: features keep working, only the unsafe
5+
behavior is closed. (The full secure-by-default redesign is the later major.)
6+
7+
Covers:
8+
- SSRF blocklist gaps closed (NAT64 / 6to4 / :: / v4-mapped, not-is_global)
9+
+ opaque error (no resolved IP leak)
10+
- output_path symlink/TOCTOU hardening (realpath containment + O_NOFOLLOW)
11+
with the feature kept
12+
- request-supplied LLM base_url ignored (key-exfil vector)
13+
- env:SECRET_KEY exfil gadget blocked in LLMConfig (provider keys still work)
14+
- CRLF-safe logging
15+
- webhook header sanitization
16+
"""
17+
18+
import os
19+
import socket
20+
import sys
21+
22+
import pytest
23+
24+
DOCKER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
25+
if DOCKER_DIR not in sys.path:
26+
sys.path.insert(0, DOCKER_DIR)
27+
28+
29+
def _patch_dns(monkeypatch, ip):
30+
def fake(host, port=None, *a, **k):
31+
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port or 0))]
32+
monkeypatch.setattr(socket, "getaddrinfo", fake)
33+
34+
35+
class TestSsrfGapsClosed:
36+
@pytest.mark.parametrize("ip", [
37+
"169.254.169.254", # metadata
38+
"127.0.0.1", "10.0.0.5", "192.168.1.1", "100.64.0.1",
39+
"::1", "::", # v6 loopback + unspecified (was a gap)
40+
"::ffff:169.254.169.254", # v4-mapped metadata
41+
"64:ff9b::a9fe:a9fe", # NAT64 -> 169.254.169.254 (was a gap)
42+
"2002:a9fe:a9fe::1", # 6to4 embedding 169.254.169.254 (was a gap)
43+
])
44+
def test_internal_blocked(self, monkeypatch, ip):
45+
import utils
46+
_patch_dns(monkeypatch, ip)
47+
with pytest.raises(ValueError):
48+
utils.validate_webhook_url("http://target.example/cb")
49+
50+
@pytest.mark.parametrize("ip", ["8.8.8.8", "1.1.1.1"])
51+
def test_public_allowed(self, monkeypatch, ip):
52+
import utils
53+
_patch_dns(monkeypatch, ip)
54+
utils.validate_webhook_url("http://target.example/cb") # no raise
55+
56+
def test_error_is_opaque(self, monkeypatch):
57+
import utils
58+
_patch_dns(monkeypatch, "169.254.169.254")
59+
with pytest.raises(ValueError) as e:
60+
utils.validate_webhook_url("http://target.example/")
61+
assert "169.254" not in str(e.value) # no resolved-IP leak
62+
63+
64+
class TestOutputPathHardening:
65+
def test_symlink_escape_rejected(self, monkeypatch, tmp_path):
66+
import utils
67+
allowed = tmp_path / "outputs"
68+
allowed.mkdir()
69+
monkeypatch.setattr(utils, "ALLOWED_OUTPUT_DIR", str(allowed))
70+
# Plant a symlinked subdir that points outside the allowed dir.
71+
outside = tmp_path / "outside"
72+
outside.mkdir()
73+
(allowed / "evil").symlink_to(outside)
74+
from fastapi import HTTPException
75+
with pytest.raises(HTTPException):
76+
utils.validate_output_path("evil/pwned.png") # realpath escapes
77+
78+
def test_normal_path_ok(self, monkeypatch, tmp_path):
79+
import utils
80+
allowed = tmp_path / "outputs"
81+
allowed.mkdir()
82+
monkeypatch.setattr(utils, "ALLOWED_OUTPUT_DIR", str(allowed))
83+
p = utils.validate_output_path("sub/shot.png")
84+
assert p.startswith(str(allowed))
85+
86+
def test_write_refuses_symlink_final_component(self, monkeypatch, tmp_path):
87+
import utils
88+
allowed = tmp_path / "outputs"
89+
allowed.mkdir()
90+
target = allowed / "shot.png"
91+
# Pre-plant a symlink at the final path pointing at a secret.
92+
secret = tmp_path / "secret"
93+
secret.write_text("SECRET")
94+
target.symlink_to(secret)
95+
with pytest.raises(OSError):
96+
utils.write_output_file(str(target), b"data") # O_NOFOLLOW refuses
97+
98+
99+
class TestLlmBaseUrlIgnored:
100+
def test_request_base_url_not_honored_in_source(self):
101+
# base_url from the request must never be passed to the LLM call.
102+
with open(os.path.join(DOCKER_DIR, "api.py")) as f:
103+
src = f.read()
104+
assert "base_url or get_llm_base_url" not in src
105+
assert "base_url=get_llm_base_url(config" in src
106+
107+
108+
class TestEnvSecretGuard:
109+
def test_env_secret_key_blocked(self):
110+
from crawl4ai.async_configs import LLMConfig
111+
with pytest.raises(ValueError):
112+
LLMConfig(api_token="env:SECRET_KEY")
113+
114+
@pytest.mark.parametrize("name", ["REDIS_PASSWORD", "CRAWL4AI_API_TOKEN", "MY_PRIVATE_KEY"])
115+
def test_other_secrets_blocked(self, name):
116+
from crawl4ai.async_configs import LLMConfig
117+
with pytest.raises(ValueError):
118+
LLMConfig(api_token=f"env:{name}")
119+
120+
def test_provider_key_still_works(self, monkeypatch):
121+
from crawl4ai.async_configs import LLMConfig
122+
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-123")
123+
cfg = LLMConfig(provider="openai/gpt-4o-mini", api_token="env:OPENAI_API_KEY")
124+
assert cfg.api_token == "sk-test-123" # normal provider key unaffected
125+
126+
127+
class TestCRLFSafeLogging:
128+
def test_crlf_stripped(self):
129+
import logging
130+
from utils import CRLFSafeFilter
131+
rec = logging.LogRecord("t", logging.INFO, __file__, 1,
132+
"url=http://x/\r\nINJECTED login ok", None, None)
133+
CRLFSafeFilter().filter(rec)
134+
msg = rec.getMessage()
135+
assert "\r" not in msg and "\n" not in msg and "INJECTED" in msg
136+
137+
138+
class TestWebhookHeaderSanitization:
139+
def test_crlf_value_rejected(self):
140+
from webhook import sanitize_webhook_headers
141+
with pytest.raises(ValueError):
142+
sanitize_webhook_headers({"X-Foo": "bar\r\nInjected: 1"})
143+
144+
@pytest.mark.parametrize("bad", ["Host", "Content-Length", "Authorization", "Cookie"])
145+
def test_hop_by_hop_denied(self, bad):
146+
from webhook import sanitize_webhook_headers
147+
with pytest.raises(ValueError):
148+
sanitize_webhook_headers({bad: "x"})
149+
150+
def test_good_header_passes(self):
151+
from webhook import sanitize_webhook_headers
152+
assert sanitize_webhook_headers({"X-Trace-Id": "abc"}) == {"X-Trace-Id": "abc"}
153+
154+
def test_schema_rejects_early(self):
155+
from schemas import WebhookConfig
156+
import pydantic
157+
with pytest.raises(pydantic.ValidationError):
158+
WebhookConfig(webhook_url="https://example.com/cb", webhook_headers={"Host": "evil"})

0 commit comments

Comments
 (0)