Skip to content

Commit cdf2ead

Browse files
committed
security: patch proxy SSRF in Docker server (0.8.9)
0.8.8's SSRF check validated the crawl target URL but not the proxy address, so an unauthenticated /crawl, /crawl/stream, or /crawl/job could route the browser through a proxy pointing at an internal IP and reach internal services / cloud metadata. Reported by Geo (geo-chen). Fix (backward compatible): validate every proxy destination with the same not-is_global check used for crawl URLs, before the browser is built - browser_config.proxy, browser_config.proxy_config.server, crawler_config.proxy_config.server - and strip proxy/DNS-redirecting flags (--proxy-server / --proxy-pac-url / --proxy-bypass-list / --host-resolver-rules) from extra_args. A legitimate public proxy still works; configure proxies via proxy_config (validated), not raw extra_args flags. _enforce_proxy_safety is called in both crawl handlers (and covers /crawl/job transitively); HTTPException passthrough added so the 400 is not masked as a 500. Bump 0.8.8 -> 0.8.9 (__version__ + Dockerfile). 20 new tests; full security suite 161 pass. Changelog, release blog, README, SECURITY-CREDITS updated. This vector was already fixed in the upcoming secure-by-default release; 0.8.9 brings it forward because it is an unauthenticated SSRF.
1 parent 930dc0a commit cdf2ead

9 files changed

Lines changed: 266 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to Crawl4AI will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.8.9] - 2026-06-04
9+
10+
0.8.9 is a follow-up, backward-compatible security patch for the self-hosted Docker API server, closing an SSRF path that 0.8.8 did not cover. Upgrade in place; no configuration changes required.
11+
12+
### Security
13+
14+
A security advisory accompanies this release.
15+
16+
- **SSRF via proxy settings (CWE-918)**: the SSRF destination check was applied only to the crawl target URL, not to the proxy address. An unauthenticated `/crawl`, `/crawl/stream`, or `/crawl/job` request could set `browser_config.proxy_config.server` (or the deprecated `browser_config.proxy`, or `crawler_config.proxy_config`, or a `--proxy-server` / `--host-resolver-rules` flag in `extra_args`) to an internal address and route the browser through it, reaching internal services and cloud-metadata endpoints. All proxy destinations are now validated with the same global-routability check before the browser is built, and proxy/DNS-redirecting flags are stripped from `extra_args`. A legitimate public proxy still works. Credit: Geo ([geo-chen](https://github.com/geo-chen)).
17+
18+
Backward compatible. Note: raw `--proxy-server` / `--host-resolver-rules` / `--proxy-bypass-list` / `--proxy-pac-url` flags passed via `extra_args` are now ignored; configure proxies through `proxy_config` (which is validated).
19+
820
## [0.8.8] - 2026-06-04
921

1022
0.8.8 is a focused, backward-compatible security patch for the self-hosted Docker API server. Upgrade in place; no configuration changes are required. If you run the Docker server, upgrade. If it is exposed to a network, also set `CRAWL4AI_API_TOKEN`.

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.8
4+
ARG C4AI_VER=0.8.9
55
ENV C4AI_VERSION=$C4AI_VER
66
LABEL c4ai.version=$C4AI_VER
77

README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ Limited slots._
3737

3838
Crawl4AI turns the web into clean, LLM ready Markdown for RAG, agents, and data pipelines. Fast, controllable, battle tested by a 50k+ star community.
3939

40-
[✨ Check out latest update v0.8.8](#-recent-updates)
40+
[✨ Check out latest update v0.8.9](#-recent-updates)
4141

42-
**New in v0.8.8**: Backward-compatible security patch for the self-hosted Docker API server (SSRF filter gaps, output_path file write, LLM credential exfiltration, log and webhook header injection). If you run the Docker server, upgrade. A larger secure-by-default release with breaking changes is coming in ~1-2 weeks. [Release notes →](https://github.com/unclecode/crawl4ai/blob/main/docs/blog/release-v0.8.8.md)
42+
**New in v0.8.9**: Follow-up security patch for the self-hosted Docker API server, closing an SSRF via proxy settings that 0.8.8 did not cover. Backward compatible. If you run the Docker server, upgrade. A larger secure-by-default release with breaking changes is coming in ~1-2 weeks. [Release notes →](https://github.com/unclecode/crawl4ai/blob/main/docs/blog/release-v0.8.9.md)
4343

4444
✨ Recent v0.8.6: Security hotfix that replaced `litellm` with `unclecode-litellm` due to a PyPI supply chain compromise.
4545

@@ -565,9 +565,22 @@ async def test_news_crawl():
565565
## ✨ Recent Updates
566566

567567
<details open>
568+
<summary><strong>Version 0.8.9 Release Highlights - Proxy SSRF Patch</strong></summary>
569+
570+
A follow-up, backward-compatible security patch for the self-hosted Docker API server: closes an SSRF via proxy settings (`proxy_config.server`, the deprecated `proxy`, `crawler_config.proxy_config`, and proxy/DNS flags in `extra_args`) that 0.8.8 did not cover. Proxy destinations are now validated like crawl URLs. Upgrade in place, no config changes. A larger secure-by-default release with breaking changes is coming in ~1-2 weeks; a migration guide will accompany the pre-announcement.
571+
572+
```bash
573+
pip install -U crawl4ai
574+
```
575+
576+
[Full v0.8.9 Release Notes →](https://github.com/unclecode/crawl4ai/blob/main/docs/blog/release-v0.8.9.md)
577+
578+
</details>
579+
580+
<details>
568581
<summary><strong>Version 0.8.8 Release Highlights - Docker Server Security Patch</strong></summary>
569582

570-
A focused, backward-compatible security patch for the self-hosted Docker API server: closes SSRF filter gaps (IPv6 transition forms), hardens screenshot/PDF `output_path` against a symlink write, stops LLM credential exfiltration via a request `base_url`, and adds CRLF-safe logging and webhook header validation. Upgrade in place, no config changes. A larger secure-by-default release with breaking changes is coming in ~1-2 weeks; a migration guide will accompany the pre-announcement.
583+
A focused, backward-compatible security patch for the self-hosted Docker API server: closes SSRF filter gaps (IPv6 transition forms), hardens screenshot/PDF `output_path` against a symlink write, stops LLM credential exfiltration via a request `base_url`, and adds CRLF-safe logging and webhook header validation. Upgrade in place, no config changes.
571584

572585
```bash
573586
pip install -U crawl4ai

SECURITY-CREDITS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ We thank the following security researchers for their responsible disclosure:
1212
| Velayutham Selvaraj | [LinkedIn](https://www.linkedin.com/in/velayuthamselvaraj) | SSRF via missing host validation in validate_url_scheme (independent report) | 2026-05-06 |
1313
| IcySun & Yashon | icysun@qq.com, liyaoyin@qq.com | SSRF, file write via output_path, missing auth by default, hook sandbox bypass via asyncio (independent report) | 2026-05-15 |
1414
| Geo ([geo-chen](https://github.com/geo-chen)) | cve@sageby.com | LLM API key exfiltration via unvalidated base_url (0.8.8) | 2026-06-02 |
15+
| Geo ([geo-chen](https://github.com/geo-chen)) | cve@sageby.com | SSRF via proxy_config.server bypassing the SSRF check (0.8.9) | 2026-06-04 |

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.8"
4+
__version__ = "0.8.9"
55

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

deploy/docker/api.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,41 @@
4747
get_llm_base_url,
4848
get_redis_task_ttl,
4949
validate_url_destination,
50+
validate_proxy_destination,
51+
scrub_browser_extra_args,
5052
)
5153
from webhook import WebhookDeliveryService
5254

5355
import psutil, time
5456

5557
logger = logging.getLogger(__name__)
5658

59+
60+
def _enforce_proxy_safety(browser_config, crawler_config=None):
61+
"""Block SSRF via a caller-supplied proxy / proxy-redirecting browser flags.
62+
63+
The crawl-target URL is validated elsewhere; this covers the other paths
64+
that reach Chromium's egress: BrowserConfig.proxy, BrowserConfig.proxy_config
65+
.server, CrawlerRunConfig.proxy_config.server, and proxy/DNS flags smuggled
66+
via extra_args. Raises HTTPException(400) on a non-global proxy host."""
67+
from fastapi import HTTPException
68+
try:
69+
if browser_config is not None:
70+
if getattr(browser_config, "proxy", None):
71+
validate_proxy_destination(browser_config.proxy)
72+
pc = getattr(browser_config, "proxy_config", None)
73+
if pc is not None and getattr(pc, "server", None):
74+
validate_proxy_destination(pc.server)
75+
if getattr(browser_config, "extra_args", None):
76+
browser_config.extra_args = scrub_browser_extra_args(browser_config.extra_args)
77+
if crawler_config is not None:
78+
cpc = getattr(crawler_config, "proxy_config", None)
79+
if cpc is not None and getattr(cpc, "server", None):
80+
validate_proxy_destination(cpc.server)
81+
except ValueError:
82+
# opaque: do not echo the resolved internal address
83+
raise HTTPException(status_code=400, detail="Proxy destination blocked (SSRF protection)")
84+
5785
# --- Helper to get memory ---
5886
def _get_memory_mb():
5987
try:
@@ -573,6 +601,7 @@ async def handle_crawl_request(
573601
validate_url_destination(url)
574602
browser_config = BrowserConfig.load(browser_config)
575603
crawler_config = CrawlerRunConfig.load(crawler_config)
604+
_enforce_proxy_safety(browser_config, crawler_config)
576605

577606
dispatcher = MemoryAdaptiveDispatcher(
578607
memory_threshold_percent=config["crawler"]["memory_threshold_percent"],
@@ -717,6 +746,9 @@ async def handle_crawl_request(
717746

718747
return response
719748

749+
except HTTPException:
750+
raise # client errors (e.g. blocked proxy 400) must not become 500
751+
720752
except Exception as e:
721753
logger.error(f"Crawl error: {str(e)}", exc_info=True)
722754

@@ -761,6 +793,7 @@ async def handle_stream_crawl_request(
761793
# browser_config.verbose = True # Set to False or remove for production stress testing
762794
browser_config.verbose = False
763795
crawler_config = CrawlerRunConfig.load(crawler_config)
796+
_enforce_proxy_safety(browser_config, crawler_config)
764797
crawler_config.scraping_strategy = LXMLWebScrapingStrategy()
765798
crawler_config.stream = True
766799

@@ -814,6 +847,11 @@ async def handle_stream_crawl_request(
814847

815848
return crawler, results_gen, hooks_info
816849

850+
except HTTPException:
851+
if crawler:
852+
await release_crawler(crawler)
853+
raise # client errors (e.g. blocked proxy 400) must not become 500
854+
817855
except Exception as e:
818856
# Release crawler on setup error (for successful streams,
819857
# release happens in stream_results finally block)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""
2+
Behavioral tests for the 0.8.9 non-breaking security patch.
3+
4+
Closes the proxy-injection SSRF class in the Docker server: an unauthenticated
5+
/crawl could set a proxy (or proxy-redirecting Chromium flag) pointing at an
6+
internal IP and route the browser through it, reaching internal services and
7+
cloud metadata. The crawl-target URL was validated; the proxy address was not.
8+
9+
All fixes are backward compatible: a legitimate public proxy still works; only
10+
non-global proxy hosts are rejected and dangerous --proxy/--host-resolver flags
11+
are stripped from extra_args.
12+
"""
13+
14+
import os
15+
import sys
16+
17+
import pytest
18+
19+
DOCKER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
20+
if DOCKER_DIR not in sys.path:
21+
sys.path.insert(0, DOCKER_DIR)
22+
23+
24+
class TestValidateProxyDestination:
25+
# IP literals so getaddrinfo is numeric (no network needed).
26+
@pytest.mark.parametrize("server", [
27+
"http://169.254.169.254:8080", # cloud metadata
28+
"http://127.0.0.1:8888",
29+
"http://10.0.0.5:3128",
30+
"http://192.168.1.10:3128",
31+
"169.254.169.254:8080", # bare host:port (no scheme)
32+
"socks5://10.1.2.3:1080",
33+
"http://[::1]:8080", # ipv6 loopback
34+
"http://[::ffff:169.254.169.254]:80", # v4-mapped metadata
35+
])
36+
def test_internal_proxy_rejected(self, server):
37+
import utils
38+
with pytest.raises(ValueError):
39+
utils.validate_proxy_destination(server)
40+
41+
@pytest.mark.parametrize("server", [
42+
"http://8.8.8.8:3128",
43+
"https://1.1.1.1:443",
44+
"8.8.8.8:3128",
45+
])
46+
def test_public_proxy_allowed(self, server):
47+
import utils
48+
utils.validate_proxy_destination(server) # no raise
49+
50+
def test_empty_is_noop(self):
51+
import utils
52+
utils.validate_proxy_destination("") # no raise
53+
utils.validate_proxy_destination(None) # no raise
54+
55+
56+
class TestScrubExtraArgs:
57+
def test_strips_dangerous_flags(self):
58+
import utils
59+
args = [
60+
"--headless",
61+
"--proxy-server=http://10.0.0.1:3128",
62+
"--host-resolver-rules=MAP * 169.254.169.254",
63+
"--proxy-bypass-list=*",
64+
"--proxy-pac-url=http://evil/p.pac",
65+
"--disable-gpu",
66+
]
67+
out = utils.scrub_browser_extra_args(args)
68+
assert out == ["--headless", "--disable-gpu"]
69+
70+
def test_keeps_benign(self):
71+
import utils
72+
args = ["--headless", "--no-sandbox", "--disable-dev-shm-usage"]
73+
assert utils.scrub_browser_extra_args(args) == args
74+
75+
def test_empty(self):
76+
import utils
77+
assert utils.scrub_browser_extra_args([]) == []
78+
assert utils.scrub_browser_extra_args(None) is None
79+
80+
81+
class TestEnforceProxySafety:
82+
def test_browser_proxy_config_internal_400(self):
83+
import api
84+
from fastapi import HTTPException
85+
from crawl4ai import BrowserConfig, ProxyConfig
86+
b = BrowserConfig(proxy_config=ProxyConfig(server="http://169.254.169.254:8080"))
87+
with pytest.raises(HTTPException) as e:
88+
api._enforce_proxy_safety(b, None)
89+
assert e.value.status_code == 400
90+
assert "169.254" not in str(e.value.detail) # opaque
91+
92+
def test_deprecated_proxy_field_internal_400(self):
93+
import api
94+
from fastapi import HTTPException
95+
from crawl4ai import BrowserConfig
96+
b = BrowserConfig(proxy="http://10.0.0.9:3128")
97+
with pytest.raises(HTTPException):
98+
api._enforce_proxy_safety(b, None)
99+
100+
def test_crawler_proxy_config_internal_400(self):
101+
import api
102+
from fastapi import HTTPException
103+
from crawl4ai import BrowserConfig, CrawlerRunConfig, ProxyConfig
104+
c = CrawlerRunConfig(proxy_config=ProxyConfig(server="http://192.168.0.2:3128"))
105+
with pytest.raises(HTTPException):
106+
api._enforce_proxy_safety(BrowserConfig(), c)
107+
108+
def test_extra_args_proxy_scrubbed(self):
109+
import api
110+
from crawl4ai import BrowserConfig
111+
b = BrowserConfig(extra_args=["--proxy-server=http://10.0.0.1", "--headless"])
112+
api._enforce_proxy_safety(b, None) # no raise (scrub, not block)
113+
assert b.extra_args == ["--headless"]
114+
115+
def test_public_proxy_passes(self):
116+
import api
117+
from crawl4ai import BrowserConfig, ProxyConfig
118+
b = BrowserConfig(proxy_config=ProxyConfig(server="http://8.8.8.8:3128"))
119+
api._enforce_proxy_safety(b, None) # no raise

deploy/docker/utils.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,47 @@ def validate_webhook_url(url: str) -> None:
450450
raise ValueError("URL resolves to a blocked address")
451451

452452

453+
# Chromium launch flags that can redirect/route egress or remap DNS, enabling
454+
# the same SSRF as a proxy field. Stripped from caller-supplied extra_args.
455+
_DANGEROUS_BROWSER_ARGS = (
456+
"--proxy-server", "--proxy-pac-url", "--proxy-bypass-list",
457+
"--host-resolver-rules",
458+
)
459+
460+
461+
def validate_proxy_destination(server: str) -> None:
462+
"""Reject a proxy server whose host is non-global (SSRF via proxy_config).
463+
464+
The crawl-target SSRF check is applied to the URL being fetched, but a proxy
465+
address is also attacker-controllable and is handed to Chromium's
466+
--proxy-server. Validate it with the same not-is_global rule. Honors
467+
CRAWL4AI_ALLOW_INTERNAL_URLS. Raises ValueError (opaque) on a blocked host."""
468+
if ALLOW_INTERNAL_URLS or not server:
469+
return
470+
s = str(server)
471+
# proxy strings may be "scheme://host:port" or bare "host:port"
472+
parsed = urlparse(s if "://" in s else "//" + s)
473+
host = parsed.hostname
474+
if not host:
475+
raise ValueError("Invalid proxy server")
476+
# Reuse the destination check (resolve + not-is_global on transition forms).
477+
validate_webhook_url(f"http://{host}")
478+
479+
480+
def scrub_browser_extra_args(extra_args):
481+
"""Drop egress/DNS-redirecting Chromium flags from caller-supplied args.
482+
483+
The supported way to set a proxy is proxy_config (validated). Raw
484+
--proxy-*/--host-resolver-rules flags bypass that validation and re-enable
485+
the SSRF, so they are removed. Returns the filtered list."""
486+
if not extra_args:
487+
return extra_args
488+
return [
489+
a for a in extra_args
490+
if not any(str(a).startswith(bad) for bad in _DANGEROUS_BROWSER_ARGS)
491+
]
492+
493+
453494
def verify_email_domain(email: str) -> bool:
454495
try:
455496
domain = email.split('@')[1]

docs/blog/release-v0.8.9.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Crawl4AI v0.8.9: Proxy SSRF Patch
2+
3+
*June 2026 - 2 min read*
4+
5+
---
6+
7+
I'm releasing Crawl4AI v0.8.9, a follow-up security patch for the self-hosted Docker API server. It closes a server-side request forgery path that v0.8.8 did not cover. It is backward compatible: upgrade in place, no configuration changes required.
8+
9+
If you run the Docker server, please upgrade. If it is exposed to a network, also set `CRAWL4AI_API_TOKEN`. A security advisory accompanies this release.
10+
11+
## What it fixes
12+
13+
The SSRF destination check validated the crawl target URL, but not the proxy address. An unauthenticated `/crawl`, `/crawl/stream`, or `/crawl/job` request could point a proxy at an internal IP and route the browser through it, reaching internal services and cloud-metadata endpoints, even with a perfectly valid crawl URL.
14+
15+
v0.8.9 validates every proxy destination with the same global-routability check before the browser is built:
16+
17+
- `browser_config.proxy_config.server`
18+
- `browser_config.proxy` (deprecated field)
19+
- `crawler_config.proxy_config.server`
20+
- proxy / DNS-redirecting flags in `extra_args` (`--proxy-server`, `--host-resolver-rules`, `--proxy-bypass-list`, `--proxy-pac-url`) are stripped
21+
22+
A legitimate public proxy still works. The only behavior change: set proxies through `proxy_config` (which is validated) rather than raw `extra_args` flags.
23+
24+
## Upgrade
25+
26+
```bash
27+
pip install -U crawl4ai
28+
docker pull unclecode/crawl4ai:0.8.9
29+
```
30+
31+
## Still coming: a secure-by-default Docker server (~1-2 weeks)
32+
33+
The next release remains a larger, secure-by-default update for the Docker API server, with intentional breaking changes (authentication on by default, stricter request validation, safer deployment defaults). A full migration guide will accompany the pre-announcement on Discord and X. This proxy fix is already part of that release; 0.8.9 simply brings it forward because it is an unauthenticated SSRF.
34+
35+
Thanks to Geo for the responsible disclosure.
36+
37+
Live long and import crawl4ai

0 commit comments

Comments
 (0)