diff --git a/CHANGELOG.md b/CHANGELOG.md index c09b7d2e1..6121b1cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,66 @@ All notable changes to Crawl4AI will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.3] - 2026-08-31 + +0.9.3 is a security release. It closes five coordinated-disclosure advisories in the PDF processing path and the Docker Playground UI, and ships the 33 bug fixes that accumulated on `develop` since 0.9.2, most of them in the Docker server. There are no new features and no breaking changes. Users who accept untrusted URLs on the Docker server, or who open PDFs from sources they do not control, should upgrade. + +### Security + +The PDF path was the common thread. `PDFContentScrapingStrategy` is selectable from an untrusted Docker API request body, and it fetches with `requests` outside the browser, so none of the Chromium-side egress or resource controls applied to it. + +- **Arbitrary file write via PDF image-write fields (CWE-22, high)**: `PDFContentScrapingStrategy` had no field allowlist, so an untrusted request body could set `save_images_locally` and `image_save_dir` and make the server write extracted images to a path of the caller's choosing. Those fields are now filtered at the trust boundary and `extract_images` is forced off for untrusted bodies. Credit: Zhixi "Jace" Sun. (GHSA-xpp7-j28w-2gvx) +- **SSRF via PDF download redirects (CWE-918, high)**: the PDF download followed redirects without consulting any destination policy, so a public URL that redirected to an internal address reached it. Redirects are now resolved by hand with a per-hop destination check, bounded at five hops, and the peer IP of the response actually read back is validated to close DNS rebinding. The Docker server installs its egress policy into this path at boot. Credit: Nguyen Tran Thanh Lam ([c240030](https://github.com/c240030)). (GHSA-q5rj-45vw-vp2g) +- **Denial of service via unbounded PDF size and page count (CWE-400, medium)**: a remote PDF was streamed to disk and parsed with no cap on bytes or pages. Downloads now stop at `max_pdf_bytes` (100 MiB default), enforced on the running total rather than the caller-supplied `content-length`, and parsing stops at `max_pdf_pages` (2000 default). Untrusted bodies cannot raise their own caps. The Docker config now ships a non-zero `limits.wall_clock_s` of 300 seconds. Credit: Nguyen Tran Thanh Lam ([c240030](https://github.com/c240030)). (GHSA-v2rm-hvrj-2x9q) +- **XSS via unescaped PDF text in `cleaned_html` (CWE-79, medium)**: paragraph text taken verbatim from a PDF was written into `cleaned_html` without escaping, so markup embedded in a PDF survived into the result and executed when rendered. Paragraph text is now escaped like every other sink in that function. Credit: Nguyen Tran Thanh Lam ([c240030](https://github.com/c240030)). (GHSA-7g3g-vhm6-79f3) +- **DOM-based XSS in the Docker Playground leading to API token theft (CWE-79, high)**: the result viewer reset syntax highlighting with `element.innerHTML = element.textContent`, which re-parsed attacker-controlled crawled content as live HTML in the operator's session. The round trip is removed; highlight.js renders safely from `textContent`. Credit: [e1codes](https://github.com/e1codes). (GHSA-m446-hp3q-qfxp) + +All reporters are credited in `SECURITY-CREDITS.md`. GitHub Security Advisories accompany this release. + +### Fixed + +This release also carries the bug fixes that accumulated on `develop` since 0.9.2. + +**Docker server** + +- PDF scraping is supported by default, and requests selecting `PDFContentScrapingStrategy` are routed to `PDFCrawlerStrategy` so the pairing works without extra configuration. (#2094, #2150) +- The egress proxy chains through an upstream `HTTP_PROXY` / `HTTPS_PROXY` instead of ignoring it. (#2142) +- Junk proxy environment values fall through to the next candidate, and non-http proxy schemes are refused. (#2094) +- Compose v5 compatibility, clearer warnings on legacy fields, and better playground error handling. (#2094) +- `CRAWL4AI_API_TOKEN` is forwarded through compose, and `.llm.env` is optional rather than required. (#2094) +- The disabled-hooks 403 distinguishes the removed `hooks.code` field from other rejections. (#2094) +- `output_path` is declared a deprecated no-op rather than silently ignored. (#2094) +- `GET /monitor` redirects to the dashboard UI. (#2157, issue #2091) +- Failed crawl results are preserved instead of dropped, for both batch and single-URL requests. (#2094, #2134, issue #2133) +- An unavailable IPv6 loopback no longer breaks startup. (#2081, issue #2078) +- `mcp` is capped below 2 so the v1 low-level API used by `mcp_bridge` keeps working. (#2148, thanks @weike-zhang) +- Commented environment variable lines in compose are aligned with the environment list. (#2156) + +**Crawler and core** + +- `ManagedBrowser` no longer leaks a Playwright driver process when the browser fails to launch inside `__aenter__`. (#2160) +- `PDFCrawlerStrategy` placeholder responses are no longer vetoed as anti-bot blocks, which previously failed every PDF crawl and burned the retry budget. (#2138, issue #2135) +- Cookies are carried across manual PDF redirect hops, so gated and CDN-signed PDFs download correctly. (#2159) +- Unconditional `setTimeout` waits are removed from the overlay and consent removal scripts, which could hang a crawl under a restrictive CSP. (#2139) +- `remove_overlay_elements` no longer removes `` when the body carries a global popup class. (#2163, thanks @Nalhin) +- The body-visibility timeout is configurable and validated, and the timeout warning is emitted even with `verbose=False`. (#2117, #2131, #2145, issues #2116, #2129, #2144) + +**Documentation** + +- Self-hosting and migration guides updated for 0.9.x. (#2093) +- The `PDFCrawlerStrategy` plus `PDFContentScrapingStrategy` pairing requirement is documented. + +### Tests + +- `tests/unit/test_pdf_download_limits.py`: 22 tests covering per-hop destination validation, DNS rebinding, redirect bounds, byte and page caps, and untrusted-body clamping. +- `tests/unit/test_pdf_html_escaping.py`: escaping of PDF paragraph text in `cleaned_html`. +- `deploy/docker/tests/test_security_pdf_image_write.py`: rejection of image-write fields from untrusted bodies. +- Docker endpoint coverage for crawl failures and for the per-URL `crawler_configs` PDF guard. + +### Breaking Changes + +None. + ## [0.9.0] - 2026-06-18 0.9.0 is a major, secure-by-default release of the Crawl4AI Docker API server. The out-of-the-box deployment is now hardened with defense in depth: authentication is on by default, the server binds loopback unless you give it a token, and the network request body is treated as an untrusted trust boundary. This release contains breaking changes for the self-hosted HTTP server only. The core pip library (SDK / in-process use) is unchanged. diff --git a/Dockerfile b/Dockerfile index 3b39e307d..7ef9c2fb7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM python:3.12-slim-bookworm AS build # C4ai version -ARG C4AI_VER=0.9.2 +ARG C4AI_VER=0.9.3 ENV C4AI_VERSION=$C4AI_VER LABEL c4ai.version=$C4AI_VER diff --git a/README.md b/README.md index 838553762..310ac0240 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,11 @@ Limited slots._ Crawl4AI turns the web into clean, LLM ready Markdown for RAG, agents, and data pipelines. Fast, controllable, battle tested by a 50k+ star community. -[✨ Check out latest update v0.9.2](#-recent-updates) +[✨ Check out latest update v0.9.3](#-recent-updates) -✨ **New in v0.9.2**: Maintenance patch release. Fixes a `MemoryAdaptiveDispatcher` task/page leak when a streaming crawl is closed, Docker Playground "Advanced Config" and Monitor WebSocket auth, Playwright headless-shell packaging, and GPU (`ENABLE_GPU=true`) Docker builds. [Release notes →](https://github.com/unclecode/crawl4ai/blob/main/docs/blog/release-v0.9.2.md) +✨ **New in v0.9.3**: Security release. Closes five coordinated-disclosure advisories: arbitrary file write, SSRF, and denial of service in the PDF processing path, plus two XSS issues in the Docker Playground. Also ships 33 bug fixes across the Docker server, crawler, and PDF handling. No new features, no breaking changes. [Release notes →](https://github.com/unclecode/crawl4ai/blob/main/docs/blog/release-v0.9.3.md) + +✨ Recent v0.9.2: Maintenance patch release. Fixes a `MemoryAdaptiveDispatcher` task/page leak when a streaming crawl is closed, Docker Playground "Advanced Config" and Monitor WebSocket auth, Playwright headless-shell packaging, and GPU (`ENABLE_GPU=true`) Docker builds. [Release notes →](https://github.com/unclecode/crawl4ai/blob/main/docs/blog/release-v0.9.2.md) ✨ Recent v0.9.0: Major secure-by-default release of the Docker API server. Auth is on by default, the server binds loopback unless given a token, and the request body is now an untrusted trust boundary. [Release notes →](https://github.com/unclecode/crawl4ai/blob/main/docs/blog/release-v0.9.0.md) @@ -565,6 +567,23 @@ async def test_news_crawl(): ## ✨ Recent Updates
+Version 0.9.3 Release Highlights - Security Release + +A security release closing five coordinated-disclosure advisories. Four are in the PDF processing path: an arbitrary file write through `PDFContentScrapingStrategy` image-write fields, an SSRF where the PDF download followed redirects into internal addresses, a denial of service from unbounded PDF size and page count, and an XSS from unescaped PDF text in `cleaned_html`. The fifth is a DOM-based XSS in the Docker Playground that could expose the operator's API token. + +It also carries 33 bug fixes that accumulated since 0.9.2: PDF scraping now works out of the box on the Docker server, the egress proxy chains through an upstream proxy, failed crawl results are reported instead of dropped, a Playwright driver leak on failed browser launch is fixed, and PDF crawls are no longer wrongly flagged as anti-bot blocks. + +No new features, no breaking changes. Two defaults changed: PDF downloads now cap at 100 MiB and 2000 pages, and the Docker `limits.wall_clock_s` is now 300 seconds instead of 0. + +```bash +pip install -U crawl4ai +``` + +[Full v0.9.3 Release Notes →](https://github.com/unclecode/crawl4ai/blob/main/docs/blog/release-v0.9.3.md) + +
+ +
Version 0.9.2 Release Highlights - Maintenance Bug Fixes A maintenance patch release with bug fixes across the dispatcher, Docker, and GPU builds. `MemoryAdaptiveDispatcher` no longer leaks crawl tasks and browser pages when a streaming crawl is closed. Docker fixes cover the Playground "Advanced Config" 400, the Monitor WebSocket 500 under JWT auth, and Playwright headless-shell packaging. `ENABLE_GPU=true` Docker builds no longer fail on the CUDA toolkit. diff --git a/SECURITY-CREDITS.md b/SECURITY-CREDITS.md index e2ae2bc66..a1c12441c 100644 --- a/SECURITY-CREDITS.md +++ b/SECURITY-CREDITS.md @@ -18,3 +18,6 @@ We thank the following security researchers for their responsible disclosure: | UDU_RisePho | GitHub: [hoanggxyuuki](https://github.com/hoanggxyuuki) | Chromium launch-flag RCE class via extra_args (0.9.0) | 2026-06-18 | | Y4tacker | GitHub: [Y4tacker](https://github.com/Y4tacker) | Hook system exec() sandbox escape (MRO chain RCE), Chromium launch-arg injection (--utility-cmd-prefix RCE), HTTP crawler path traversal arbitrary file write | 2026-07-09 | | Rafael | GitHub: [rafaelfiguereod-stack](https://github.com/rafaelfiguereod-stack) | Reported SSRF, LLM key exfiltration, and auth gaps (already fixed / not exploitable in current code) | 2026-07-09 | +| Zhixi "Jace" Sun | Independent security researcher | Arbitrary file write via unconfined PDFContentScrapingStrategy image-write fields in untrusted config bodies (0.9.3) | 2026-08-24 | +| Nguyen Tran Thanh Lam | GitHub: [c240030](https://github.com/c240030) | SSRF via PDF download redirects, DoS via unbounded PDF size and page count, XSS via unescaped PDF text in cleaned_html (0.9.3) | 2026-07-27 | +| e1codes | GitHub: [e1codes](https://github.com/e1codes) | DOM-based XSS in the Docker Playground leading to operator API-token theft (0.9.3) | 2026-07-24 | diff --git a/crawl4ai/__version__.py b/crawl4ai/__version__.py index 505ca152c..f8f1eb5d0 100644 --- a/crawl4ai/__version__.py +++ b/crawl4ai/__version__.py @@ -1,7 +1,7 @@ # crawl4ai/__version__.py # This is the version that will be used for stable releases -__version__ = "0.9.2" +__version__ = "0.9.3" # For nightly builds, this gets set during build process __nightly_version__ = None diff --git a/crawl4ai/async_configs.py b/crawl4ai/async_configs.py index 27320fd4b..383079fef 100644 --- a/crawl4ai/async_configs.py +++ b/crawl4ai/async_configs.py @@ -214,6 +214,29 @@ class UntrustedConfigError(ValueError): "override_navigator", "magic", "process_in_browser", "shared_data", "session_id", }, + # PDFContentScrapingStrategy has no scalar allowlist (it is kept for its + # value fields), so its filesystem-write knobs must be blocked explicitly: + # image_save_dir is an unconfined write destination and save_images_locally + # turns the write on. extract_images stays allowed - it only returns the + # image bytes base64-inline in the response, no disk write. + "PDFContentScrapingStrategy": { + "image_save_dir", "save_images_locally", + }, +} + +# Field names that must NEVER be set from an untrusted body on ANY allowed +# type, checked regardless of per-type allowlist. This is the fail-closed +# backstop: the per-type maps above only cover BrowserConfig/CrawlerRunConfig, +# so a strategy that (now or in future) exposes a filesystem-write, code, or +# routing constructor arg is caught here even with no explicit allowlist. +# Presence => 400 (loud), matching js_code/extra_args behavior. +UNTRUSTED_GLOBAL_FORBIDDEN_FIELDS = { + # filesystem write / read sinks + "image_save_dir", "save_images_locally", "downloads_path", "user_data_dir", + "output_path", "save_path", "file_path", "local_path", "storage_state", + # code / command execution + "js_code", "js_code_before_wait", "c4a_script", "init_scripts", + "code", "command", "hook", "hooks", } # Scalar knobs an untrusted body MAY set, per class. A field not listed here is @@ -244,6 +267,7 @@ class UntrustedConfigError(ValueError): "fetch_ssl_certificate", # timing / waiting "wait_until", "page_timeout", "wait_for", "wait_for_timeout", + "body_visibility_timeout", "wait_for_images", "delay_before_return_html", "mean_delay", "max_range", # scrolling / rendering "ignore_body_visibility", "scan_full_page", "scroll_delay", @@ -272,6 +296,8 @@ class UntrustedConfigError(ValueError): _MAX_TIMEOUT_MS = 60_000 _MAX_SCROLL_STEPS = 1000 _MAX_VIEWPORT = 4000 +_MAX_PDF_BYTES = 100 * 1024 * 1024 +_MAX_PDF_PAGES = 2000 def _filter_untrusted_fields(type_name: str, params: dict) -> dict: @@ -280,7 +306,7 @@ def _filter_untrusted_fields(type_name: str, params: dict) -> dict: allowlist = UNTRUSTED_FIELD_ALLOWLIST.get(type_name) # None => keep all non-forbidden out = {} for key, value in params.items(): - if key in forbidden: + if key in UNTRUSTED_GLOBAL_FORBIDDEN_FIELDS or key in forbidden: raise UntrustedConfigError( f"field '{key}' is not permitted on {type_name} from an untrusted request" ) @@ -299,7 +325,7 @@ def _cap_timeout(v): return min(int(v), _MAX_TIMEOUT_MS) if type_name == "CrawlerRunConfig": - for f in ("page_timeout", "wait_for_timeout"): + for f in ("page_timeout", "wait_for_timeout", "body_visibility_timeout"): if f in params: params[f] = _cap_timeout(params[f]) if isinstance(params.get("max_scroll_steps"), int): @@ -308,6 +334,17 @@ def _cap_timeout(v): for f in ("viewport_width", "viewport_height"): if isinstance(params.get(f), int): params[f] = max(1, min(params[f], _MAX_VIEWPORT)) + elif type_name == "PDFContentScrapingStrategy": + # This class has no field allowlist, so without clamping a body could + # simply raise its own caps back to unbounded and re-open the DoS. + for f, cap in (("max_pdf_bytes", _MAX_PDF_BYTES), ("max_pdf_pages", _MAX_PDF_PAGES)): + if f in params: + v = params[f] + # <=0 or non-int would read as "no limit"; pin to the cap. + params[f] = cap if not isinstance(v, int) or v <= 0 else min(v, cap) + # Rasterizing every page is the most expensive thing this strategy can + # do, and nothing about untrusted crawling needs it. + params["extract_images"] = False return params @@ -1463,6 +1500,8 @@ class CrawlerRunConfig(): Default: False. ignore_body_visibility (bool): If True, ignore whether the body is visible before proceeding. Default: True. + body_visibility_timeout (int): Maximum time in ms to wait for the body to become visible. + Default: 30000. scan_full_page (bool): If True, scroll through the entire page to load all content. Default: False. scroll_delay (float): Delay in seconds between scroll steps if scan_full_page is True. @@ -1640,6 +1679,7 @@ def __init__( c4a_script: Union[str, List[str]] = None, js_only: bool = False, ignore_body_visibility: bool = True, + body_visibility_timeout: int = 30000, scan_full_page: bool = False, scroll_delay: float = 0.2, max_scroll_steps: Optional[int] = None, @@ -1770,6 +1810,13 @@ def __init__( self.c4a_script = c4a_script self.js_only = js_only self.ignore_body_visibility = ignore_body_visibility + if ( + not isinstance(body_visibility_timeout, (int, float)) + or isinstance(body_visibility_timeout, bool) + or body_visibility_timeout <= 0 + ): + raise ValueError("body_visibility_timeout must be a positive number") + self.body_visibility_timeout = body_visibility_timeout self.scan_full_page = scan_full_page self.scroll_delay = scroll_delay self.max_scroll_steps = max_scroll_steps @@ -2137,6 +2184,7 @@ def to_dict(self): "js_code_before_wait": self.js_code_before_wait, "js_only": self.js_only, "ignore_body_visibility": self.ignore_body_visibility, + "body_visibility_timeout": self.body_visibility_timeout, "scan_full_page": self.scan_full_page, "scroll_delay": self.scroll_delay, "max_scroll_steps": self.max_scroll_steps, diff --git a/crawl4ai/async_crawler_strategy.py b/crawl4ai/async_crawler_strategy.py index 265c376e9..6d0fb4769 100644 --- a/crawl4ai/async_crawler_strategy.py +++ b/crawl4ai/async_crawler_strategy.py @@ -812,6 +812,7 @@ async def handle_request_failed_capture(request): await page.wait_for_selector("body", state="attached", timeout=30000) # Use the new check_visibility function with csp_compliant_wait + _visibility_wait_start = time.perf_counter() is_visible = await self.csp_compliant_wait( page, """() => { @@ -823,9 +824,38 @@ async def handle_request_failed_capture(request): style.opacity !== '0'; return isVisible; }""", - timeout=30000, + timeout=config.body_visibility_timeout, ) + _visibility_wait_ms = ( + time.perf_counter() - _visibility_wait_start + ) * 1000 + + # csp_compliant_wait also returns False when the evaluation itself + # fails (page closed, context destroyed by a redirect), which costs + # no time — only warn about a wait that actually burned its budget. + if ( + not is_visible + and config.ignore_body_visibility + and _visibility_wait_ms >= config.body_visibility_timeout * 0.9 + ): + # The wait timed out and its result is about to be discarded, so + # the crawl still succeeds — just this much slower, on every crawl + # of this page. force_verbose because the whole point is that the + # delay is otherwise invisible, and the servers and batch jobs that + # most need to see it run with verbose off (see #2144). + self.logger.warning( + message=( + "Body never became visible after {elapsed}ms — the page may " + "use ng-cloak/v-cloak. This delay is added to every crawl of " + "this page; lower CrawlerRunConfig.body_visibility_timeout " + "to shorten it." + ), + tag="WARNING", + params={"elapsed": round(_visibility_wait_ms)}, + force_verbose=True, + ) + if not is_visible and not config.ignore_body_visibility: visibility_info = await self.check_visibility(page) raise Error(f"Body element is hidden: {visibility_info}") @@ -1534,7 +1564,7 @@ async def remove_overlay_elements(self, page: Page) -> None: }})() """ ) - await page.wait_for_timeout(500) # Wait for any animations to complete + await page.wait_for_timeout(600) # Wait for any animations to complete except Exception as e: self.logger.warning( message="Failed to remove overlay elements: {error}", diff --git a/crawl4ai/async_webcrawler.py b/crawl4ai/async_webcrawler.py index 8216d19bc..a2aabcc41 100644 --- a/crawl4ai/async_webcrawler.py +++ b/crawl4ai/async_webcrawler.py @@ -505,7 +505,7 @@ async def arun( # Check if blocked (skip for raw: URLs — # caller-provided content, anti-bot N/A) - if _is_raw_url: + if _is_raw_url or async_response.placeholder_html: _blocked = False _block_reason = "" else: @@ -625,7 +625,8 @@ async def arun( # empty by design, and is_blocked() would misread "0 bytes # html" as a block. _has_download = bool(getattr(crawl_result, "downloaded_files", None)) - if not _fallback_succeeded and not _is_raw_url and not _has_download: + _placeholder = bool(getattr(async_response, "placeholder_html", False)) + if not _fallback_succeeded and not _is_raw_url and not _has_download and not _placeholder: _blocked, _block_reason = is_blocked( crawl_result.status_code, crawl_result.html or "") if _blocked: diff --git a/crawl4ai/browser_manager.py b/crawl4ai/browser_manager.py index d7b8009c8..f4ab0aa32 100644 --- a/crawl4ai/browser_manager.py +++ b/crawl4ai/browser_manager.py @@ -626,7 +626,15 @@ async def acquire(cls, cdp_url: str, use_undetected: bool = False): else: from playwright.async_api import async_playwright pw = await async_playwright().start() - browser = await pw.chromium.connect_over_cdp(cdp_url) + try: + browser = await pw.chromium.connect_over_cdp(cdp_url) + except BaseException: + # Stop the driver we just started so a failed connect doesn't leak it + try: + await pw.stop() + except BaseException: + pass + raise cls._cache[cdp_url] = (pw, browser, 1) return pw, browser @@ -788,6 +796,25 @@ async def start(self): Note: This method should be called in a separate task to avoid blocking the main event loop. """ + try: + await self._start_impl() + except BaseException: + # Roll back partial startup (e.g. Playwright driver) since a failed __aenter__ never triggers __aexit__ + try: + await self.close() + except BaseException: + pass + if self.playwright is not None: + # close() intentionally skips the driver for external-CDP configs; on failed startup we still own it + try: + await self.playwright.stop() + except BaseException: + pass + self.playwright = None + self.browser = None + raise + + async def _start_impl(self): if self.playwright is not None: await self.close() diff --git a/crawl4ai/js_snippet/remove_consent_popups.js b/crawl4ai/js_snippet/remove_consent_popups.js index 9aac8d345..2f7eb0f34 100644 --- a/crawl4ai/js_snippet/remove_consent_popups.js +++ b/crawl4ai/js_snippet/remove_consent_popups.js @@ -292,6 +292,7 @@ async () => { // ========================================================================= // Phase 2: Try CMP JavaScript APIs // ========================================================================= + let apiCalled = false; // IAB TCF v2 API if (typeof window.__tcfapi === 'function') { @@ -304,6 +305,7 @@ async () => { if (typeof window.Didomi !== 'undefined') { try { window.Didomi.setUserAgreeToAll(); + apiCalled = true; } catch (e) { /* continue */ } } @@ -311,6 +313,7 @@ async () => { if (typeof window.Cookiebot !== 'undefined') { try { window.Cookiebot.submitCustomConsent(true, true, true); + apiCalled = true; } catch (e) { /* continue */ } } @@ -318,6 +321,7 @@ async () => { if (typeof window.Osano !== 'undefined') { try { window.Osano.cm.acceptAll(); + apiCalled = true; } catch (e) { /* continue */ } } @@ -325,11 +329,12 @@ async () => { if (typeof window.klaro !== 'undefined') { try { window.klaro.getManager().acceptAll(); + apiCalled = true; } catch (e) { /* continue */ } } - // Wait for CMP animations/transitions - await new Promise(r => setTimeout(r, 500)); + // Wait for CMP animations/transitions - only when a consent action actually fired + if (accepted || apiCalled) await new Promise(r => setTimeout(r, 500)); // ========================================================================= // Phase 3: Remove known CMP containers by selector diff --git a/crawl4ai/js_snippet/remove_overlay_elements.js b/crawl4ai/js_snippet/remove_overlay_elements.js index a50d94274..c134cc6b1 100644 --- a/crawl4ai/js_snippet/remove_overlay_elements.js +++ b/crawl4ai/js_snippet/remove_overlay_elements.js @@ -5,6 +5,12 @@ async () => { return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0"; }; + // Never strip document structure , , or ; removing those tags empties the page. + const isDocumentStructure = (elem) => { + const tag = elem && elem.tagName; + return tag === "HTML" || tag === "HEAD" || tag === "BODY"; + }; + // Common selectors for popups and overlays const commonSelectors = [ // Close buttons first @@ -54,6 +60,7 @@ async () => { // Find elements with high z-index const allElements = document.querySelectorAll("*"); for (const elem of allElements) { + if (isDocumentStructure(elem)) continue; const style = window.getComputedStyle(elem); const zIndex = parseInt(style.zIndex); const position = style.position; @@ -74,6 +81,7 @@ async () => { for (const selector of commonSelectors) { const elements = document.querySelectorAll(selector); elements.forEach((elem) => { + if (isDocumentStructure(elem)) return; if (isVisible(elem)) { elem.remove(); } @@ -88,6 +96,7 @@ async () => { const removeFixedElements = () => { const elements = document.querySelectorAll("*"); elements.forEach((elem) => { + if (isDocumentStructure(elem)) return; const style = window.getComputedStyle(elem); if ((style.position === "fixed" || style.position === "sticky") && isVisible(elem)) { elem.remove(); @@ -114,7 +123,5 @@ async () => { document.body.style.paddingRight = "0px"; document.body.style.overflow = "auto"; - // Wait a bit for any animations to complete document.body.scrollIntoView(false); - await new Promise((resolve) => setTimeout(resolve, 50)); }; diff --git a/crawl4ai/models.py b/crawl4ai/models.py index 506538970..7ab3794cc 100644 --- a/crawl4ai/models.py +++ b/crawl4ai/models.py @@ -338,6 +338,7 @@ class AsyncCrawlResponse(BaseModel): mhtml_data: Optional[str] = None get_delayed_content: Optional[Callable[[Optional[float]], Awaitable[str]]] = None downloaded_files: Optional[List[str]] = None + placeholder_html: bool = False # True if html is a stand-in for the content, actual content is produced by the scraping strategy (eg. using PDFCrawlerStrategy) ssl_certificate: Optional[SSLCertificate] = None redirected_url: Optional[str] = None redirected_status_code: Optional[int] = None diff --git a/crawl4ai/processors/pdf/__init__.py b/crawl4ai/processors/pdf/__init__.py index 69a6f75a2..9fc5ad781 100644 --- a/crawl4ai/processors/pdf/__init__.py +++ b/crawl4ai/processors/pdf/__init__.py @@ -7,7 +7,54 @@ from crawl4ai.content_scraping_strategy import ContentScrapingStrategy from .processor import NaivePDFProcessorStrategy # Assuming your current PDF code is in pdf_processor.py +# Default resource bounds for remote PDF fetches. These cap what an untrusted +# caller can make the process download and parse; see set_url_validator() for +# the destination side of the same problem. +DEFAULT_MAX_PDF_BYTES = 100 * 1024 * 1024 # 100 MiB +DEFAULT_MAX_PDF_PAGES = 2000 +DEFAULT_MAX_REDIRECTS = 5 + +_REDIRECT_STATUSES = (301, 302, 303, 307, 308) + +# Destination-policy hooks, injected by whoever embeds the library. +# +# The library deliberately has no egress policy of its own: as a plain library +# the caller already chooses the URL, so there is nothing to defend against. +# It matters when the URL comes from an untrusted API client, which is the +# Docker server's situation -- deploy/docker/server.py installs the egress +# broker here at boot so the PDF path enforces the same non-global-IP policy +# the browser path already gets via enforce_egress(). +# +# _url_validator runs on every hop (initial URL and each redirect Location). +# _peer_ip_validator runs on the IP actually connected to; without it a name +# that passed validation could still resolve to an internal address on the +# second lookup requests performs (DNS rebinding). +# Both must raise to block; returning anything is treated as "allowed". +_url_validator = None +_peer_ip_validator = None + + +def set_url_validator(fn): + """Install a per-hop destination validator: fn(url) -> None, raises to block.""" + global _url_validator + _url_validator = fn + + +def set_peer_ip_validator(fn): + """Install a connected-peer check: fn(ip: str) -> None, raises to block.""" + global _peer_ip_validator + _peer_ip_validator = fn + + class PDFCrawlerStrategy(AsyncCrawlerStrategy): + """Crawler strategy for PDF documents. + + This strategy does not fetch or parse anything itself — it returns a + placeholder response (``placeholder_html=True``). It MUST be paired with + ``PDFContentScrapingStrategy`` (via ``CrawlerRunConfig.scraping_strategy``), + which performs the actual PDF download and content extraction. With any + other scraping strategy the result will contain only the placeholder text. + """ def __init__(self, logger: AsyncLogger = None): self.logger = logger @@ -16,7 +63,8 @@ async def crawl(self, url: str, **kwargs) -> AsyncCrawlResponse: return AsyncCrawlResponse( html="Scraper will handle the real work", # Scraper will handle the real work response_headers={"Content-Type": "application/pdf"}, - status_code=200 + status_code=200, + placeholder_html=True, # HTML is a placeholder for the actual content, which will be produced by the PDFContentScrapingStrategy ) async def close(self): @@ -53,18 +101,30 @@ class PDFContentScrapingStrategy(ContentScrapingStrategy): ) """ - def __init__(self, + def __init__(self, save_images_locally : bool = False, extract_images : bool = False, image_save_dir : str = None, batch_size: int = 4, - logger: AsyncLogger = None): + max_pdf_bytes: int = DEFAULT_MAX_PDF_BYTES, + max_pdf_pages: int = DEFAULT_MAX_PDF_PAGES, + max_redirects: int = DEFAULT_MAX_REDIRECTS, + logger: AsyncLogger = None, + url_validator=None): self.logger = logger + # Per-instance validator, set by deploy/docker/api.py on the strategy it + # builds for a request. It runs in addition to the module-level policy + # installed at server boot, not instead of it. + self.url_validator = url_validator + self.max_pdf_bytes = max_pdf_bytes + self.max_pdf_pages = max_pdf_pages + self.max_redirects = max_redirects self.pdf_processor = NaivePDFProcessorStrategy( save_images_locally=save_images_locally, extract_images=extract_images, image_save_dir=image_save_dir, - batch_size=batch_size + batch_size=batch_size, + max_pages=max_pdf_pages ) self._temp_files = [] # Track temp files for cleanup @@ -138,55 +198,159 @@ async def ascrap(self, url: str, html: str, **kwargs) -> ScrapingResult: return await asyncio.to_thread(self.scrap, url, html, **kwargs) + def _discard_temp_file(self, path: str) -> None: + Path(path).unlink(missing_ok=True) + if path in self._temp_files: + self._temp_files.remove(path) + + @staticmethod + def _peer_ip(response): + """Best-effort peer address of a streaming response, or None. + + The socket lives in a different place across urllib3 majors, and on a + bodyless response (a redirect) it is already released -- hence None + rather than an exception, and hence `strict` in _check_peer_ip. + """ + for get_sock in ( + lambda: response.raw._fp.fp.raw._sock, # urllib3 2.x + lambda: response.raw._original_response.fp.raw._sock, # urllib3 1.x + lambda: response.raw._connection.sock, + ): + try: + sock = get_sock() + if sock is not None: + return sock.getpeername()[0] + except Exception: + continue + return None + + def _check_peer_ip(self, response, strict: bool = True) -> None: + """Validate the address actually connected to. + + Validating the URL only proves the *name* resolved somewhere allowed; + requests resolves it again when it dials, so a hostname with a short + TTL can point somewhere internal by then (DNS rebinding). Checking the + connected peer is what closes that window. + + `strict` is False for redirect hops, where urllib3 has already released + the socket and no peer is observable. Those hops are still covered by + the per-hop URL validation; what strict mode protects is the response + whose body we are about to read back to the caller, which is where a + rebound connection would actually exfiltrate something. + """ + if not _peer_ip_validator: + return + + peer = self._peer_ip(response) + if peer is None: + if not strict: + return + # Fail closed: a policy was installed, so an unverifiable peer on + # the response we are about to read is not something to wave through. + response.close() + raise RuntimeError("Could not determine peer address for PDF download") + + try: + _peer_ip_validator(peer) + except Exception: + response.close() + raise + + def _fetch_with_redirect_checks(self, requests, url: str): + """GET `url`, validating every hop against the injected destination policy. + + Redirects are followed by hand because requests would otherwise chase a + Location into a private address without consulting the validator at all + -- a public URL that 302s to 169.254.169.254 was the reported SSRF. + Returns an open streaming response; the caller closes it. + """ + from urllib.parse import urljoin + + current = url + # One Session for the whole chain: a bare requests.get() per hop starts + # with an empty cookie jar, so a host that sets a cookie and then + # redirects (common for gated or CDN-signed PDFs) would never get it + # back. allow_redirects=True used to carry cookies for us. + session = requests.Session() + for _ in range(self.max_redirects + 1): + if self.url_validator: + self.url_validator(current) + if _url_validator: + _url_validator(current) + + # Connection timeout: 20s, Read timeout: 600s (for large PDFs) + response = session.get( + current, stream=True, timeout=(20, 60 * 10), allow_redirects=False + ) + is_redirect = response.status_code in _REDIRECT_STATUSES + self._check_peer_ip(response, strict=not is_redirect) + + if is_redirect: + location = response.headers.get('location') + response.close() + if not location: + raise RuntimeError(f"Redirect without Location header from {current}") + # Relative Locations are legal, so resolve against the current + # URL before validating -- the validator needs an absolute URL. + current = urljoin(current, location) + continue + + response.raise_for_status() + return response + + raise RuntimeError( + f"Too many redirects (>{self.max_redirects}) downloading PDF from {url}" + ) + def _get_pdf_path(self, url: str) -> str: if url.startswith(("http://", "https://")): import tempfile import requests - + # Create temp file with .pdf extension temp_file = tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) temp_file.close() # Close handle immediately; file persists due to delete=False self._temp_files.append(temp_file.name) - + try: if self.logger: self.logger.info(f"Downloading PDF from {url}...") - - # Download PDF with streaming and timeout - # Connection timeout: 10s, Read timeout: 300s (5 minutes for large PDFs) - response = requests.get(url, stream=True, timeout=(20, 60 * 10)) - response.raise_for_status() - - # Get file size if available - total_size = int(response.headers.get('content-length', 0)) + + response = self._fetch_with_redirect_checks(requests, url) + + # content-length is a hint only: it is attacker-controlled and + # may be absent or understated, so the running total is what + # enforces the cap. + total_size = int(response.headers.get('content-length', 0) or 0) downloaded = 0 - - # Write to temp file - with open(temp_file.name, 'wb') as f: - for chunk in response.iter_content(chunk_size=8192): - f.write(chunk) - downloaded += len(chunk) - if self.logger and total_size > 0: - progress = (downloaded / total_size) * 100 - if progress % 10 < 0.1: # Log every 10% - self.logger.debug(f"PDF download progress: {progress:.0f}%") - + + with response: + with open(temp_file.name, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + downloaded += len(chunk) + if downloaded > self.max_pdf_bytes: + raise RuntimeError( + f"PDF from {url} exceeds max_pdf_bytes " + f"({self.max_pdf_bytes} bytes)" + ) + f.write(chunk) + if self.logger and total_size > 0: + progress = (downloaded / total_size) * 100 + if progress % 10 < 0.1: # Log every 10% + self.logger.debug(f"PDF download progress: {progress:.0f}%") + if self.logger: self.logger.info(f"PDF downloaded successfully: {temp_file.name}") - + return temp_file.name - + except requests.exceptions.Timeout as e: - # Clean up temp file if download fails - Path(temp_file.name).unlink(missing_ok=True) - self._temp_files.remove(temp_file.name) + self._discard_temp_file(temp_file.name) raise RuntimeError(f"Timeout downloading PDF from {url}: {str(e)}") except Exception as e: - # Clean up temp file if download fails - Path(temp_file.name).unlink(missing_ok=True) - self._temp_files.remove(temp_file.name) + self._discard_temp_file(temp_file.name) raise RuntimeError(f"Failed to download PDF from {url}: {str(e)}") - + elif url.startswith("file://"): return url[7:] # Strip file:// prefix diff --git a/crawl4ai/processors/pdf/processor.py b/crawl4ai/processors/pdf/processor.py index 1af25c05c..96ff0d260 100644 --- a/crawl4ai/processors/pdf/processor.py +++ b/crawl4ai/processors/pdf/processor.py @@ -55,8 +55,9 @@ def process(self, pdf_path: Path) -> PDFProcessResult: pass class NaivePDFProcessorStrategy(PDFProcessorStrategy): - def __init__(self, image_dpi: int = 144, image_quality: int = 85, extract_images: bool = True, - save_images_locally: bool = False, image_save_dir: Optional[Path] = None, batch_size: int = 4): + def __init__(self, image_dpi: int = 144, image_quality: int = 85, extract_images: bool = True, + save_images_locally: bool = False, image_save_dir: Optional[Path] = None, batch_size: int = 4, + max_pages: Optional[int] = None): # Import check at initialization time try: import pypdf @@ -70,8 +71,24 @@ def __init__(self, image_dpi: int = 144, image_quality: int = 85, extract_images self.save_images_locally = save_images_locally self.image_save_dir = image_save_dir self.batch_size = batch_size + # None means unbounded, which is the right default for a library caller + # opening a file they chose. Callers accepting untrusted PDFs (the + # Docker server) pass a cap so page count can't be used to burn CPU. + self.max_pages = max_pages self._temp_dir = None + def _page_limit(self, total_pages: int) -> int: + """Number of pages to actually process, honouring max_pages.""" + if self.max_pages is None: + return total_pages + capped = min(total_pages, self.max_pages) + if capped < total_pages: + logger.warning( + f"PDF has {total_pages} pages; processing first {capped} " + f"(max_pages={self.max_pages})" + ) + return capped + def process(self, pdf_path: Path) -> PDFProcessResult: # Import inside method to allow dependency to be optional try: @@ -101,7 +118,10 @@ def process(self, pdf_path: Path) -> PDFProcessResult: self._temp_dir = tempfile.mkdtemp(prefix='pdf_images_') image_dir = Path(self._temp_dir) + page_limit = self._page_limit(len(reader.pages)) for page_num, page in enumerate(reader.pages): + if page_num >= page_limit: + break self.current_page_number = page_num + 1 pdf_page = self._process_page(page, image_dir) result.pages.append(pdf_page) @@ -149,7 +169,7 @@ def process_batch(self, pdf_path: Path) -> PDFProcessResult: with pdf_path.open('rb') as file: reader = PdfReader(file) result.metadata = self._extract_metadata(pdf_path, reader) - total_pages = len(reader.pages) + total_pages = self._page_limit(len(reader.pages)) # Handle image directory setup image_dir = None diff --git a/crawl4ai/processors/pdf/utils.py b/crawl4ai/processors/pdf/utils.py index 3dc0e739d..e7cc6328b 100644 --- a/crawl4ai/processors/pdf/utils.py +++ b/crawl4ai/processors/pdf/utils.py @@ -97,10 +97,14 @@ def flush_paragraph(): para = ' '.join(current_paragraph) para = re.sub(r'\s+', ' ', para).strip() if para: - # escaped_para = html.escape(para) - escaped_para = para - # escaped_para = re.sub(r'\.\n', '.\n\n', escaped_para) - # Split escaped_para by <|break|> to avoid HTML escaping + # Paragraph text comes verbatim from the PDF and is attacker- + # controlled. Escape it so markup like cannot + # survive into cleaned_html and execute when the result is + # rendered (DOM XSS). Every other sink in this function already + # escapes; this one was the gap. + escaped_para = html.escape(para) + # Whitespace was collapsed above, so this split yields a single + # element -- kept only to preserve the original structure. escaped_para = escaped_para.split('.\n\n') # Wrap each part in

tag escaped_para = [f'

{part}

' for part in escaped_para] diff --git a/deploy/docker/.llm.env.example b/deploy/docker/.llm.env.example index 012435d83..6f84f1ff1 100644 --- a/deploy/docker/.llm.env.example +++ b/deploy/docker/.llm.env.example @@ -1,3 +1,6 @@ +# Optional: enable declarative hooks support (disabled by default) +# CRAWL4AI_HOOKS_ENABLED=true + # LLM Provider Keys OPENAI_API_KEY=your_openai_key_here DEEPSEEK_API_KEY=your_deepseek_key_here diff --git a/deploy/docker/MIGRATION.md b/deploy/docker/MIGRATION.md index 753823060..bcd2097c7 100644 --- a/deploy/docker/MIGRATION.md +++ b/deploy/docker/MIGRATION.md @@ -25,11 +25,24 @@ loopback by default and will not expose itself without a credential. export CRAWL4AI_API_TOKEN="$(openssl rand -hex 32)" ``` +With `docker compose`, run the export in the same shell before +`docker compose up`; the compose file passes the token into the container. +For a persistent setup, put the `CRAWL4AI_API_TOKEN=...` line in a `.env` +file in the project root instead — compose reads it automatically (a shell +export still takes precedence). + +> ⚠️ For plain `docker run`, pass it explicitly: +> `-e CRAWL4AI_API_TOKEN="$CRAWL4AI_API_TOKEN"` (the value-less shorthand +> `-e CRAWL4AI_API_TOKEN` silently passes empty from a shell where the variable +> isn't set). + - With a token set, you may expose the server (put a TLS-terminating reverse proxy in front) and must send `Authorization: Bearer ` on every request except `GET /health`. -- With **no** token set, the server binds `127.0.0.1` only and prints a one-off - token at startup for local use. +- With **no** token set, the server binds `127.0.0.1` only (the **container's** + loopback — published ports answer with *connection reset* even though the + container reports healthy) and prints a one-off token at startup for + in-container use. WebSocket clients (MCP, monitor) that can't set headers may pass `?token=...`. @@ -60,6 +73,10 @@ safe maximums. ### Hooks: declarative actions instead of code +Hooks are now **disabled by default** — enable them with +`CRAWL4AI_HOOKS_ENABLED=true` in the container environment, or any request +containing `hooks` returns HTTP 403. + `hooks.code` (Python strings) is replaced by a fixed set of declarative actions: ```jsonc @@ -77,6 +94,13 @@ Available actions: `block_resources`, `add_cookies`, `set_headers`, `scroll_to_bottom`, `wait_for_timeout`. Call `GET /hooks/info` for the parameter schemas. Arbitrary hook code is available in a self-hosted in-process build. +> ⚠️ **Legacy `hooks.code` requests fail silently.** With hooks enabled, a +> request in the old format returns HTTP 200 with +> `"hooks": {"status": "success", "attached": []}` — the inline code is +> dropped without error. If `attached` is empty, your hooks did not run. +> (With hooks disabled, the same request returns the generic 403, whose +> "enable hooks" hint will not make code hooks work either.) + ### Screenshot / PDF: artifact id instead of `output_path` `output_path` is removed. The server stores the result and returns an id + URL: @@ -89,6 +113,10 @@ schemas. Arbitrary hook code is available in a self-hosted in-process build. Fetch the file with `GET /artifacts/{artifact_id}` (authenticated). Artifacts have a TTL and a storage quota. +> ⚠️ A request that still includes `output_path` is **silently ignored** — it +> returns `success: true` with an artifact id, but no file is written to the +> requested path. Update your code to fetch from `/artifacts/{artifact_id}`. + ### LLM endpoints: provider by name `base_url` is removed from `/md`, `/llm`, and `/llm/job`. Select a provider by diff --git a/deploy/docker/README.md b/deploy/docker/README.md index 7ce0dcd00..a57c857b9 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -116,6 +116,17 @@ EOL > The server will be available at `http://localhost:11235`. Visit `/playground` to access the interactive testing interface. +* **Behind a corporate proxy:** if the host reaches the internet only through + an HTTP proxy, set the standard `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` + env vars (Docker's `proxies` config injects them automatically) — the + server's egress proxy chains through it while keeping its SSRF protections + (the upstream is asked to CONNECT to an already-validated, pinned IP). + `CRAWL4AI_UPSTREAM_PROXY` overrides the env vars. Basic auth via + `http://user:pass@proxy:port` is supported; for NTLM/Kerberos proxies, + front them with a local translator (e.g. `cntlm`, `px`) and point + `CRAWL4AI_UPSTREAM_PROXY` at it. Proxies that refuse CONNECT-to-an-IP, or + containers with no DNS at all, are not yet supported. + #### 4. Stopping the Container ```bash diff --git a/deploy/docker/api.py b/deploy/docker/api.py index 1756b925f..09a783939 100644 --- a/deploy/docker/api.py +++ b/deploy/docker/api.py @@ -85,6 +85,7 @@ def _attach_declarative_hooks(crawler, hooks_config: dict) -> dict: get_llm_base_url, get_redis_task_ttl, validate_url_destination, + datetime_handler, ) from webhook import WebhookDeliveryService @@ -92,6 +93,15 @@ def _attach_declarative_hooks(crawler, hooks_config: dict) -> dict: logger = logging.getLogger(__name__) + +def _raise_for_crawl_failure(result): + if not result.success: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=result.error_message, + ) + + # --- Helper to get memory --- def _get_memory_mb(): try: @@ -147,11 +157,7 @@ async def handle_llm_qa( enforce_egress(browser_cfg) crawler = await get_crawler(browser_cfg) result = await crawler.arun(url) - if not result.success: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=result.error_message - ) + _raise_for_crawl_failure(result) content = result.markdown.fit_markdown or result.markdown.raw_markdown # Create prompt and get LLM response @@ -179,6 +185,8 @@ async def handle_llm_qa( ) return response.choices[0].message.content + except HTTPException: + raise except LLMProviderNotAllowed as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: @@ -389,11 +397,7 @@ async def handle_markdown_request( ) ) - if not result.success: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=result.error_message - ) + _raise_for_crawl_failure(result) return (result.markdown.raw_markdown if filter_type == FilterType.RAW @@ -599,11 +603,19 @@ def create_task_response(task: dict, task_id: str, base_url: str) -> dict: return response +async def _dispose_crawler(crawler): + """Close a dedicated PDF crawler (not pooled) or release a pooled one.""" + from crawl4ai.processors.pdf import PDFCrawlerStrategy + from crawler_pool import release_crawler + if isinstance(crawler.crawler_strategy, PDFCrawlerStrategy): + await crawler.close() + else: + await release_crawler(crawler) + async def stream_results(crawler: AsyncWebCrawler, results_gen: AsyncGenerator) -> AsyncGenerator[bytes, None]: """Stream results with heartbeats and completion markers.""" import json from utils import datetime_handler - from crawler_pool import release_crawler try: async for result in results_gen: @@ -631,7 +643,7 @@ async def stream_results(crawler: AsyncWebCrawler, results_gen: AsyncGenerator) logger.warning("Client disconnected during streaming") finally: if crawler: - await release_crawler(crawler) + await _dispose_crawler(crawler) def _normalize_and_validate_seeds(urls: List[str]) -> List[str]: @@ -656,6 +668,7 @@ async def handle_crawl_request( # Track request start request_id = f"req_{uuid4().hex[:8]}" crawler = None + is_pdf_crawl = False try: from monitor import get_monitor await get_monitor().track_request_start( @@ -686,7 +699,19 @@ async def handle_crawl_request( ) from crawler_pool import get_crawler, release_crawler - crawler = await get_crawler(browser_config) + from crawl4ai.processors.pdf import PDFContentScrapingStrategy, PDFCrawlerStrategy + is_pdf_crawl = isinstance(crawler_config.scraping_strategy, PDFContentScrapingStrategy) + if is_pdf_crawl: + if hooks_config: + # PDFCrawlerStrategy has no browser page, so hooks can't attach to it + raise HTTPException(status_code=400, detail="Hooks are not supported with PDFContentScrapingStrategy") + # SSRF protection: vet the PDF download URL and every redirect hop + crawler_config.scraping_strategy.url_validator = validate_url_destination + # Use PDFCrawlerStrategy when scraping PDFs, as headless Chromium can't render PDFs inline + crawler = AsyncWebCrawler(crawler_strategy=PDFCrawlerStrategy()) + await crawler.start() + else: + crawler = await get_crawler(browser_config) # Attach declarative hooks if provided hooks_status = {} @@ -706,6 +731,9 @@ async def handle_crawl_request( current_value = getattr(cfg, key) if current_value is None or current_value == "": setattr(cfg, key, value) + # SSRF: per-URL PDF strategies need the validator wired too + if isinstance(cfg.scraping_strategy, PDFContentScrapingStrategy): + cfg.scraping_strategy.url_validator = validate_url_destination effective_config = config_list else: # Single config (original behavior) @@ -768,6 +796,10 @@ async def handle_crawl_request( if result_dict.get('pdf') is not None and isinstance(result_dict.get('pdf'), bytes): result_dict['pdf'] = b64encode(result_dict['pdf']).decode('utf-8') + if is_pdf_crawl: + # PDF metadata contains datetimes that JSONResponse can't serialize + result_dict = json.loads(json.dumps(result_dict, default=datetime_handler)) + processed_results.append(result_dict) except Exception as e: logger.error(f"Error processing result: {e}") @@ -848,7 +880,10 @@ async def handle_crawl_request( ) finally: if crawler: - await release_crawler(crawler) + if is_pdf_crawl: + await crawler.close() # not pooled; release_crawler would be a no-op + else: + await release_crawler(crawler) async def handle_stream_crawl_request( urls: List[str], @@ -865,15 +900,20 @@ async def handle_stream_crawl_request( # mirroring handle_crawl_request. The streaming path previously skipped # this, leaving /crawl/stream (and /crawl with stream=true) unguarded. urls = _normalize_and_validate_seeds(urls) - browser_config = BrowserConfig.load(browser_config, provenance=Provenance.UNTRUSTED) + browser_config = BrowserConfig.load( + browser_config, provenance=Provenance.UNTRUSTED + ) # browser_config.verbose = True # Set to False or remove for production stress testing browser_config.verbose = False from egress_broker import enforce_egress + enforce_egress(browser_config) - crawler_config = CrawlerRunConfig.load(crawler_config, provenance=Provenance.UNTRUSTED) + crawler_config = CrawlerRunConfig.load( + crawler_config, provenance=Provenance.UNTRUSTED + ) from governor import clamp_deep_crawl + clamp_deep_crawl(crawler_config) - crawler_config.scraping_strategy = LXMLWebScrapingStrategy() crawler_config.stream = True # Deep crawl streaming supports exactly one start URL @@ -886,8 +926,19 @@ async def handle_stream_crawl_request( ), ) - from crawler_pool import get_crawler, release_crawler - crawler = await get_crawler(browser_config) + from crawler_pool import get_crawler + from crawl4ai.processors.pdf import PDFContentScrapingStrategy, PDFCrawlerStrategy + if isinstance(crawler_config.scraping_strategy, PDFContentScrapingStrategy): + if hooks_config: + # PDFCrawlerStrategy has no browser page, so hooks can't attach to it + raise HTTPException(status_code=400, detail="Hooks are not supported with PDFContentScrapingStrategy") + # SSRF protection: vet the PDF download URL and every redirect hop + crawler_config.scraping_strategy.url_validator = validate_url_destination + # Use PDFCrawlerStrategy when scraping PDFs, as headless Chromium can't render PDFs inline + crawler = AsyncWebCrawler(crawler_strategy=PDFCrawlerStrategy()) + await crawler.start() + else: + crawler = await get_crawler(browser_config) # Attach declarative hooks if provided if hooks_config: @@ -920,28 +971,28 @@ async def handle_stream_crawl_request( except (UntrustedConfigError, HookValidationError) as e: if crawler: - await release_crawler(crawler) + await _dispose_crawler(crawler) raise HTTPException(status_code=400, detail=f"Rejected request: {e}") except HTTPException: # Deliberate status (e.g. 400 SSRF "URL blocked") must pass through # rather than be genericized to 500 by the handler below. if crawler: - await release_crawler(crawler) + await _dispose_crawler(crawler) raise except Exception as e: - # Release crawler on setup error (for successful streams, - # release happens in stream_results finally block) + # Dispose crawler on setup error (for successful streams, + # disposal happens in stream_results finally block) if crawler: - await release_crawler(crawler) + await _dispose_crawler(crawler) logger.error(f"Stream crawl error: {str(e)}", exc_info=True) # Raising HTTPException here will prevent streaming response raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) ) - + async def handle_crawl_job( redis, background_tasks: BackgroundTasks, @@ -1023,4 +1074,4 @@ async def _runner(): except HTTPException: await redis.delete(f"task:{task_id}") raise - return {"task_id": task_id} \ No newline at end of file + return {"task_id": task_id} diff --git a/deploy/docker/config.yml b/deploy/docker/config.yml index 614ac24d1..7aabc6814 100644 --- a/deploy/docker/config.yml +++ b/deploy/docker/config.yml @@ -39,7 +39,7 @@ limits: max_body_bytes: 10485760 # 10 MiB request body cap (413 if exceeded); 0 = unbounded max_pages: 100 # deep-crawl page budget clamp (defense in depth) max_depth: 5 # deep-crawl depth clamp - wall_clock_s: 0 # per-crawl deadline in seconds (504 on timeout); 0 = no deadline + wall_clock_s: 300 # per-crawl deadline in seconds (504 on timeout); 0 = no deadline queue: # background job queue for /crawl/job and /llm/job maxsize: 1000 # max queued jobs (503 when full); 0 = unbounded workers: 4 # concurrent background workers diff --git a/deploy/docker/egress_proxy.py b/deploy/docker/egress_proxy.py index 4cdb56bd1..807bb80a1 100644 --- a/deploy/docker/egress_proxy.py +++ b/deploy/docker/egress_proxy.py @@ -13,13 +13,21 @@ against the real host - no MITM). Bound to 127.0.0.1 on an ephemeral port; started at server boot. + +If HTTP_PROXY/HTTPS_PROXY (or CRAWL4AI_UPSTREAM_PROXY) is set, we still +resolve-and-pin locally but dial via the upstream proxy, asking it to CONNECT +to the PINNED IP — never the hostname — so the rebinding guarantee holds. +NO_PROXY bypasses it; with no proxy env set, behavior is unchanged. """ from __future__ import annotations import asyncio +import base64 +import ipaddress import logging -from urllib.parse import urlsplit +import os +from urllib.parse import unquote, urlsplit from egress_broker import EgressBlocked, resolve_and_pin @@ -31,6 +39,70 @@ _MAX_HEADER_BYTES = 64 * 1024 +def upstream_proxy(scheme: str = "https"): + """(host, port, auth_header_bytes|None) of the upstream proxy, or None. + + Read per-call (not at import) so operators and tests see env changes. + The target scheme picks HTTP(S)_PROXY per convention; the first + candidate that parses wins, so junk values fall through to a fallback. + """ + order = ("HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy") if scheme == "http" \ + else ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy") + for name in ("CRAWL4AI_UPSTREAM_PROXY", *order): + raw = (os.environ.get(name) or "").strip() + if not raw: + continue + sp = urlsplit(raw if "://" in raw else "http://" + raw) + if not sp.hostname: + logger.warning("ignoring %s: unparseable proxy URL", name) + continue + if sp.scheme != "http": + # We only speak plaintext HTTP to the upstream (no TLS/socks dial). + logger.warning("ignoring %s: unsupported proxy scheme %r", name, sp.scheme) + continue + auth = None + if sp.username: + cred = f"{unquote(sp.username)}:{unquote(sp.password or '')}".encode("utf-8") + auth = b"Proxy-Authorization: Basic " + base64.b64encode(cred) + b"\r\n" + return sp.hostname, sp.port or 80, auth + return None + + +def _no_proxy_match(host: str, ip: str) -> bool: + """True if NO_PROXY says this target must bypass the upstream proxy.""" + raw = os.environ.get("NO_PROXY") or os.environ.get("no_proxy") or "" + entries = [e.strip() for e in raw.split(",") if e.strip()] + for entry in entries: + if entry == "*": + return True + try: + if ipaddress.ip_address(ip) in ipaddress.ip_network(entry, strict=False): + return True + continue + except ValueError: + pass + suffix = entry.lower().lstrip(".") + head, sep, port_part = suffix.rpartition(":") + if sep and port_part.isdigit(): + suffix = head + low = host.lower() + if low == suffix or low.endswith("." + suffix): + return True + return False + + +def _use_upstream(pin): + """The upstream (host, port, auth) to route `pin` through, or None for direct.""" + up = upstream_proxy(pin.scheme) + if up is None or _no_proxy_match(pin.host, pin.ip): + return None + return up + + +def _bracket(ip: str) -> str: + return f"[{ip}]" if ":" in ip else ip + + class PinningProxy: """Async HTTP forward-proxy that connects only to pinned, global IPs.""" @@ -52,6 +124,12 @@ async def start(self) -> str: sock = self._server.sockets[0] self.bound_host, self.bound_port = sock.getsockname()[:2] logger.info("egress pinning proxy listening on %s", self.url) + up = upstream_proxy() + if up is not None: + logger.info( + "egress pinning proxy chaining through upstream proxy %s:%s", + up[0], up[1], + ) return self.url async def stop(self) -> None: @@ -101,9 +179,7 @@ async def _handle_connect(self, target, client_reader, client_writer): await self._drain_headers(client_reader) try: - up_reader, up_writer = await asyncio.wait_for( - asyncio.open_connection(pin.ip, int(port_s)), timeout=30 - ) + up_reader, up_writer = await self._dial(pin, int(port_s)) except Exception: await self._reply(client_writer, _BLOCKED) return @@ -129,15 +205,31 @@ async def _handle_absolute(self, method, target, request_line, client_reader, cl path = sp.path or "/" if sp.query: path += "?" + sp.query + upstream = _use_upstream(pin) + dst = (upstream[0], upstream[1]) if upstream else (pin.ip, port) try: up_reader, up_writer = await asyncio.wait_for( - asyncio.open_connection(pin.ip, port), timeout=30 + asyncio.open_connection(*dst), timeout=30 ) except Exception: await self._reply(client_writer, _BLOCKED) return - # Re-issue in origin form with Host preserved. - out = f"{method} {path} HTTP/1.1\r\n".encode("latin-1") + # Re-issue with Host preserved: origin form when dialing the pinned IP + # directly, absolute form against the pinned IP when going through the + # upstream proxy (which then needs no DNS lookup of its own). + if upstream is None: + out = f"{method} {path} HTTP/1.1\r\n".encode("latin-1") + else: + out = f"{method} http://{_bracket(pin.ip)}:{port}{path} HTTP/1.1\r\n".encode("latin-1") + if upstream[2]: + out += upstream[2] + # One validated request per upstream connection: only this first + # request is pinned/rewritten, so force close to keep a reused + # client connection from smuggling unvalidated requests upstream. + headers = b"".join( + ln + b"\r\n" for ln in headers.split(b"\r\n") + if ln and not ln.lower().startswith(b"connection:") + ) + b"Connection: close\r\n" out += b"Host: " + sp.hostname.encode("latin-1") if sp.port: out += f":{sp.port}".encode("latin-1") @@ -147,6 +239,39 @@ async def _handle_absolute(self, method, target, request_line, client_reader, cl await self._splice(client_reader, client_writer, up_reader, up_writer) # ─────────────────────────── helpers ─────────────────────────── + async def _dial(self, pin, port: int): + """Open a byte pipe to the pinned IP: direct, or tunneled through the + upstream proxy via CONNECT-to-the-pinned-IP (no upstream DNS lookup).""" + upstream = _use_upstream(pin) + if upstream is None: + return await asyncio.wait_for( + asyncio.open_connection(pin.ip, port), timeout=30 + ) + p_host, p_port, auth = upstream + reader, writer = await asyncio.wait_for( + asyncio.open_connection(p_host, p_port), timeout=30 + ) + try: + dst = f"{_bracket(pin.ip)}:{port}" + req = f"CONNECT {dst} HTTP/1.1\r\nHost: {dst}\r\n".encode("latin-1") + if auth: + req += auth + req += b"\r\n" + writer.write(req) + await writer.drain() + status = await asyncio.wait_for(reader.readline(), timeout=30) + parts = status.split() + if len(parts) < 2 or parts[1] != b"200": + logger.warning("upstream proxy refused CONNECT: %r", status[:64]) + raise ConnectionError("upstream proxy refused CONNECT") + # Drain the upstream's response headers so none of them leak into + # the tunneled byte stream. + await self._drain_headers(reader) + except Exception: + await self._safe_close(writer) + raise + return reader, writer + async def _drain_headers(self, reader): read = 0 while True: diff --git a/deploy/docker/entrypoint.sh b/deploy/docker/entrypoint.sh index b624a3115..22c7483c5 100644 --- a/deploy/docker/entrypoint.sh +++ b/deploy/docker/entrypoint.sh @@ -31,6 +31,8 @@ else # No credential -> refuse to expose; serve loopback only. GUNICORN_BIND="127.0.0.1:${PORT}" echo "entrypoint: no CRAWL4AI_API_TOKEN set; binding loopback only (${GUNICORN_BIND})." >&2 + echo "entrypoint: WARNING: this is the CONTAINER's loopback - published ports (-p ${PORT}:${PORT}) will NOT work; connections from the host will be reset." >&2 + echo "entrypoint: to make the server reachable, set CRAWL4AI_API_TOKEN (docker run -e CRAWL4AI_API_TOKEN=..., or 'export CRAWL4AI_API_TOKEN=...' before 'docker compose up'), then restart. (If you enabled security.jwt_enabled in a custom config.yml, set CRAWL4AI_JWT_ENABLED=true instead.)" >&2 fi export GUNICORN_BIND diff --git a/deploy/docker/requirements.txt b/deploy/docker/requirements.txt index 212fcf036..4d1222804 100644 --- a/deploy/docker/requirements.txt +++ b/deploy/docker/requirements.txt @@ -11,6 +11,7 @@ pydantic>=2.11 rank-bm25==0.2.2 anyio==4.9.0 PyJWT==2.10.1 -mcp>=1.18.0 +mcp>=1.18.0,<2 websockets>=15.0.1 httpx[http2]>=0.27.2 +pypdf>=6.0.0 diff --git a/deploy/docker/schemas.py b/deploy/docker/schemas.py index f066e5c88..978f21999 100644 --- a/deploy/docker/schemas.py +++ b/deploy/docker/schemas.py @@ -42,6 +42,14 @@ class HookConfig(BaseModel): le=120, description="Timeout in seconds for each hook execution", ) + # Legacy 0.8.x field: inline-Python hook code, removed in 0.9.0 (it was an + # exec()-based RCE surface). Captured here (instead of being dropped by + # pydantic) solely so the server can tell the caller it was NOT executed; + # it is never run. + code: Optional[Any] = Field( + default=None, + description="REMOVED in 0.9.0: inline hook code is accepted for compatibility but never executed", + ) class Config: json_schema_extra = { @@ -84,14 +92,29 @@ class ScreenshotRequest(BaseModel): url: str screenshot_wait_for: Optional[float] = 2 wait_for_images: Optional[bool] = False - # output_path removed: callers never name a filesystem path (it was an - # arbitrary-write -> RCE vector). The server writes to the sandboxed - # artifact store and returns an opaque artifact_id. + # Deprecated no-op: caller paths were an arbitrary-write -> RCE vector. + # Never written; the server stores results in the artifact store instead. + output_path: Optional[str] = Field( + default=None, + deprecated=True, + description=( + "REMOVED in 0.9.0 and ignored - no file is written. Results are " + "stored server-side; fetch via GET /artifacts/{artifact_id}." + ), + ) class PDFRequest(BaseModel): url: str - # output_path removed (see ScreenshotRequest). + # output_path deprecated no-op (see ScreenshotRequest). + output_path: Optional[str] = Field( + default=None, + deprecated=True, + description=( + "REMOVED in 0.9.0 and ignored - no file is written. Results are " + "stored server-side; fetch via GET /artifacts/{artifact_id}." + ), + ) class JSEndpointRequest(BaseModel): diff --git a/deploy/docker/server.py b/deploy/docker/server.py index 410b8be38..3636efb93 100644 --- a/deploy/docker/server.py +++ b/deploy/docker/server.py @@ -159,6 +159,37 @@ async def capped_arun(self, *a, **kw): return await orig_arun(self, *a, **kw) AsyncWebCrawler.arun = capped_arun + +def _install_pdf_egress_policy() -> None: + """Hand the PDF scraping strategy this server's egress policy. + + PDFContentScrapingStrategy is in UNTRUSTED_ALLOWED_TYPES, so any API client + can select it and name the URL. It downloads with requests rather than the + browser, so none of the Chromium-side pinning applies to it. Injecting the + broker here makes the PDF path enforce the same non-global-IP rule on the + initial URL, on every redirect hop, and on the peer actually connected to. + """ + from crawl4ai.processors.pdf import set_peer_ip_validator, set_url_validator + from egress_broker import ( + ALLOW_INTERNAL, + EgressBlocked, + is_forbidden_ip, + resolve_and_pin, + ) + + def _validate_url(url: str) -> None: + resolve_and_pin(url) # raises EgressBlocked on a non-global destination + + def _validate_peer(ip: str) -> None: + if ALLOW_INTERNAL: + return + if is_forbidden_ip(ip): + raise EgressBlocked() + + set_url_validator(_validate_url) + set_peer_ip_validator(_validate_peer) + + # ───────────────────── FastAPI lifespan ────────────────────── @@ -182,6 +213,11 @@ async def lifespan(_: FastAPI): app.state.egress_proxy = PinningProxy() set_egress_proxy(await app.state.egress_proxy.start()) + # The pinning proxy only covers Chromium. PDFContentScrapingStrategy fetches + # with requests on its own, so hand the library the same destination policy + # or that path stays an unguarded SSRF hole. + _install_pdf_egress_policy() + # Bounded background-job queue (per-principal quotas optional). from work_queue import WorkQueue, set_job_queue from governor import job_queue_caps @@ -292,6 +328,14 @@ async def _timeline_updater(): async def root(): return RedirectResponse("/playground") + +# Pre-0.9 docs pointed at /monitor for the dashboard UI, which now lives at +# /dashboard; /monitor is the monitoring API prefix and has no page of its own. +# Only this exact path redirects - /monitor/* stays gated (see public_paths). +@app.get("/monitor", include_in_schema=False) +async def monitor_ui_redirect(): + return RedirectResponse("/dashboard") + # ─────────────────── infra / middleware ───────────────────── redis = aioredis.from_url(_build_redis_url(config)) @@ -392,7 +436,10 @@ def _current_api_token() -> str: app.add_middleware( AuthGateMiddleware, token_provider=_current_api_token, - public_paths={HEALTH_PATH, "/token"}, + # Exact paths only: "/monitor" reaches the redirect above, while every + # "/monitor/*" API route (incl. /monitor/ws and the admin actions) keeps + # requiring a credential. + public_paths={HEALTH_PATH, "/token", "/", "/monitor"}, public_prefixes=_UI_PREFIXES, ) @@ -660,6 +707,32 @@ async def get_artifact(artifact_id: str, _td: Dict = Depends(token_dep)): # Screenshot endpoint +_OUTPUT_PATH_WARNING = ( + "output_path was removed in 0.9.0 and is ignored - no file was written. " + "The result is stored server-side; fetch it with an authenticated " + "GET /artifacts/{artifact_id}." +) + +_HOOKS_CODE_WARNING = ( + "Inline hook code (hooks.code) was removed in 0.9.0 and was NOT executed. " + "Use declarative hook actions instead (GET /hooks/info for the schema)." +) + + +def _reject_disabled_hooks(hooks) -> None: + """403 for any hooks payload while hooks are disabled. Legacy inline code + gets its own detail: enabling CRAWL4AI_HOOKS_ENABLED would not run it (the + feature was removed in 0.9.0), so the generic remedy would mislead.""" + if hooks.code: + raise HTTPException( + 403, + "Inline hook code (hooks.code) was removed in 0.9.0 and cannot be " + "enabled; it was not executed. Use declarative hook actions instead " + "(GET /hooks/info), which are additionally disabled on this server " + "(CRAWL4AI_HOOKS_ENABLED).", + ) + raise HTTPException(403, "Hooks are disabled. Set CRAWL4AI_HOOKS_ENABLED=true to enable.") + @app.post("/screenshot") @limiter.limit(config["rate_limiting"]["default_limit"]) @@ -675,6 +748,9 @@ async def generate_screenshot( sandboxed artifact store; the response includes an `artifact_id` and a `url` to fetch it. """ validate_url_scheme(body.url) + # model_dump, not attribute access: the field is marked deprecated and + # reading the attribute emits DeprecationWarning on every request. + legacy_output_path = body.model_dump(include={"output_path"}).get("output_path") crawler = None try: cfg = CrawlerRunConfig(screenshot=True, screenshot_wait_for=body.screenshot_wait_for, wait_for_images=body.wait_for_images) @@ -684,7 +760,10 @@ async def generate_screenshot( raise HTTPException(500, detail=results[0].error_message or "Crawl failed") screenshot_data = results[0].screenshot art = _store_artifact("png", base64.b64decode(screenshot_data)) - return {"success": True, "screenshot": screenshot_data, **art} + response = {"success": True, "screenshot": screenshot_data, **art} + if legacy_output_path: + response["warning"] = _OUTPUT_PATH_WARNING + return response except HTTPException: raise except Exception as e: @@ -710,6 +789,9 @@ async def generate_pdf( sandboxed artifact store; the response includes an `artifact_id` and a `url` to fetch it. """ validate_url_scheme(body.url) + # model_dump, not attribute access: the field is marked deprecated and + # reading the attribute emits DeprecationWarning on every request. + legacy_output_path = body.model_dump(include={"output_path"}).get("output_path") crawler = None try: cfg = CrawlerRunConfig(pdf=True) @@ -719,7 +801,10 @@ async def generate_pdf( raise HTTPException(500, detail=results[0].error_message or "Crawl failed") pdf_data = results[0].pdf art = _store_artifact("pdf", pdf_data) - return {"success": True, "pdf": base64.b64encode(pdf_data).decode(), **art} + response = {"success": True, "pdf": base64.b64encode(pdf_data).decode(), **art} + if legacy_output_path: + response["warning"] = _OUTPUT_PATH_WARNING + return response except HTTPException: raise except Exception as e: @@ -879,7 +964,7 @@ async def crawl( if not crawl_request.urls: raise HTTPException(400, "At least one URL required") if crawl_request.hooks and not HOOKS_ENABLED: - raise HTTPException(403, "Hooks are disabled. Set CRAWL4AI_HOOKS_ENABLED=true to enable.") + _reject_disabled_hooks(crawl_request.hooks) # Check whether it is a redirection for a streaming request try: crawler_config = CrawlerRunConfig.load( @@ -889,7 +974,7 @@ async def crawl( raise HTTPException(400, f"Rejected config: {e}") if crawler_config.stream: return await stream_process(crawl_request=crawl_request) - + # Prepare hooks config if provided hooks_config = None if crawl_request.hooks: @@ -897,7 +982,7 @@ async def crawl( 'hooks': crawl_request.hooks.hooks, 'timeout': crawl_request.hooks.timeout } - + results = await handle_crawl_request( urls=crawl_request.urls, browser_config=crawl_request.browser_config, @@ -906,9 +991,11 @@ async def crawl( hooks_config=hooks_config, crawler_configs=crawl_request.crawler_configs, ) - # check if all of the results are not successful - if all(not result["success"] for result in results["results"]): - raise HTTPException(500, f"Crawl request failed: {results['results'][0]['error_message']}") + if crawl_request.hooks and crawl_request.hooks.code: + hooks_resp = results.setdefault("hooks", {"status": "ignored", "attached": []}) + if not crawl_request.hooks.hooks: + hooks_resp["status"] = "ignored" + hooks_resp["warning"] = _HOOKS_CODE_WARNING return JSONResponse(results) @@ -922,12 +1009,12 @@ async def crawl_stream( if not crawl_request.urls: raise HTTPException(400, "At least one URL required") if crawl_request.hooks and not HOOKS_ENABLED: - raise HTTPException(403, "Hooks are disabled. Set CRAWL4AI_HOOKS_ENABLED=true to enable.") + _reject_disabled_hooks(crawl_request.hooks) return await stream_process(crawl_request=crawl_request) async def stream_process(crawl_request: CrawlRequestWithHooks): - + # Prepare hooks config if provided# Prepare hooks config if provided hooks_config = None if crawl_request.hooks: @@ -953,6 +1040,8 @@ async def stream_process(crawl_request: CrawlRequestWithHooks): if hooks_info: import json headers["X-Hooks-Status"] = json.dumps(hooks_info['status']['status']) + if crawl_request.hooks and crawl_request.hooks.code: + headers["X-Hooks-Warning"] = _HOOKS_CODE_WARNING return StreamingResponse( stream_results(crawler, gen), diff --git a/deploy/docker/static/playground/index.html b/deploy/docker/static/playground/index.html index e88c281a0..49d27d436 100644 --- a/deploy/docker/static/playground/index.html +++ b/deploy/docker/static/playground/index.html @@ -617,6 +617,25 @@

🔥 Stress Test

} } + // Build a useful error message from a failed HTTP response + function httpErrorMessage(response, data) { + let detail = data && (data.detail || data.error); + // FastAPI 422s send detail as an array of error objects; other + // non-strings would render as [object Object]. + if (Array.isArray(detail)) { + detail = detail.map(e => e && e.msg ? `${(e.loc || []).join('.')}: ${e.msg}` : JSON.stringify(e)).join('; '); + } else if (detail && typeof detail !== 'string') { + detail = JSON.stringify(detail); + } + let msg = detail || `HTTP ${response.status}`; + if (response.status === 401) { + msg += getToken() + ? ' — token rejected; check the API token in the token bar (top right)' + : ' — set your API token in the token bar (top right)'; + } + return msg; + } + // Generate code snippets function generateSnippets(api, payload, method = 'POST') { // Python snippet @@ -747,11 +766,11 @@

🔥 Stress Test

method: 'GET', headers: { 'Accept': 'application/json' } }); - responseData = await response.json(); + responseData = await response.json().catch(() => ({})); const time = Math.round(performance.now() - startTime); if (!response.ok) { updateStatus('error', time); - throw new Error(responseData.error || 'Request failed'); + throw new Error(httpErrorMessage(response, responseData)); } updateStatus('success', time); document.querySelector('#response-content code').textContent = JSON.stringify(responseData, null, 2); @@ -765,6 +784,12 @@

🔥 Stress Test

body: JSON.stringify(payload) }); + if (!response.ok) { + const errData = await response.json().catch(() => ({})); + updateStatus('error', Math.round(performance.now() - startTime)); + throw new Error(httpErrorMessage(response, errData)); + } + const reader = response.body.getReader(); let text = ''; let maxMemory = 0; @@ -804,12 +829,12 @@

🔥 Stress Test

body: JSON.stringify(payload) }); - responseData = await response.json(); + responseData = await response.json().catch(() => ({})); const time = Math.round(performance.now() - startTime); if (!response.ok) { updateStatus('error', time); - throw new Error(responseData.error || 'Request failed'); + throw new Error(httpErrorMessage(response, responseData)); } updateStatus( @@ -975,9 +1000,11 @@

🔥 Stress Test

// Save current scroll position (important for large code blocks) const scrollTop = element.parentElement.scrollTop; - // Reset the element - const text = element.textContent; - element.innerHTML = text; + // Reset highlight.js state. NOTE: do NOT round-trip through + // innerHTML here — the response text contains attacker-controlled + // crawled content and `element.innerHTML = element.textContent` + // re-parses it as live HTML (DOM-based XSS). highlight.js renders + // safely from textContent and emits escaped markup. element.removeAttribute('data-highlighted'); // Reapply highlighting diff --git a/deploy/docker/supervisord.conf b/deploy/docker/supervisord.conf index 91b4b2538..d726e2c6c 100644 --- a/deploy/docker/supervisord.conf +++ b/deploy/docker/supervisord.conf @@ -6,7 +6,7 @@ logfile_maxbytes=0 [program:redis] ; Loopback-only and password-protected. REDIS_PASSWORD is exported by ; entrypoint.sh from the mounted secret; the app must supply it to connect. -command=/usr/bin/redis-server --loglevel notice --bind 127.0.0.1 ::1 --requirepass "%(ENV_REDIS_PASSWORD)s" --dir /var/lib/redis +command=/usr/bin/redis-server --loglevel notice --bind 127.0.0.1 -::1 --requirepass "%(ENV_REDIS_PASSWORD)s" --dir /var/lib/redis user=appuser ; Run redis as our non-root user autorestart=true priority=10 @@ -31,4 +31,4 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr ; Redirect gunicorn stderr to container stderr stderr_logfile_maxbytes=0 -# Optional: Add filebeat or other logging agents here if needed \ No newline at end of file +# Optional: Add filebeat or other logging agents here if needed diff --git a/deploy/docker/tests/requirements.txt b/deploy/docker/tests/requirements.txt index 5f7a842fe..b0206020f 100644 --- a/deploy/docker/tests/requirements.txt +++ b/deploy/docker/tests/requirements.txt @@ -1,2 +1,3 @@ httpx>=0.25.0 docker>=7.0.0 +pyyaml>=6.0 diff --git a/deploy/docker/tests/test_api_crawl_failures.py b/deploy/docker/tests/test_api_crawl_failures.py new file mode 100644 index 000000000..04b7037c1 --- /dev/null +++ b/deploy/docker/tests/test_api_crawl_failures.py @@ -0,0 +1,58 @@ +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from api import _raise_for_crawl_failure + + +def test_crawl_failure_is_reported_as_bad_gateway(): + result = SimpleNamespace(success=False, error_message="Blocked by anti-bot protection: challenge") + + with pytest.raises(HTTPException) as raised: + _raise_for_crawl_failure(result) + + assert raised.value.status_code == 502 + assert raised.value.detail == result.error_message + + +@pytest.mark.parametrize( + ("method", "path", "payload"), + [ + ("post", "/md", {"url": "https://example.com", "f": "raw"}), + ("get", "/llm/example.com?q=summarize", None), + ], +) +def test_single_url_crawl_failure_reaches_client( + stock_client, server_module, monkeypatch, method, path, payload +): + error_message = "Blocked by anti-bot protection: challenge" + failed_result = SimpleNamespace(success=False, error_message=error_message) + + class FailedCrawler: + async def arun(self, *args, **kwargs): + return failed_result + + async def get_failed_crawler(*args, **kwargs): + return FailedCrawler() + + async def release_crawler(*args, **kwargs): + return None + + import api + import crawler_pool + from auth import create_access_token + + monkeypatch.setattr(api, "validate_url_destination", lambda url: None) + monkeypatch.setattr(crawler_pool, "get_crawler", get_failed_crawler) + monkeypatch.setattr(crawler_pool, "release_crawler", release_crawler) + + token = create_access_token({"sub": "test@example.com"}) + request = getattr(stock_client, method) + kwargs = {"json": payload} if payload is not None else {} + response = request( + path, headers={"Authorization": f"Bearer {token}"}, **kwargs + ) + + assert response.status_code == 502 + assert response.json() == {"detail": error_message} diff --git a/deploy/docker/tests/test_crawl_failure_response.py b/deploy/docker/tests/test_crawl_failure_response.py new file mode 100644 index 000000000..188da944a --- /dev/null +++ b/deploy/docker/tests/test_crawl_failure_response.py @@ -0,0 +1,28 @@ +def test_all_failed_crawl_returns_results(stock_client, server_module, monkeypatch): + async def failed_crawl(**kwargs): + return { + "success": True, + "results": [ + { + "url": "https://example.com", + "success": False, + "error_message": "Wait condition failed: selector not found", + } + ], + } + + monkeypatch.setattr(server_module, "handle_crawl_request", failed_crawl) + + from auth import create_access_token + + token = create_access_token({"sub": "test@example.com"}) + response = stock_client.post( + "/crawl", + json={"urls": ["https://example.com"]}, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200 + result = response.json()["results"][0] + assert result["success"] is False + assert "Wait condition failed" in result["error_message"] diff --git a/deploy/docker/tests/test_legacy_compat.py b/deploy/docker/tests/test_legacy_compat.py new file mode 100644 index 000000000..a8a4039e3 --- /dev/null +++ b/deploy/docker/tests/test_legacy_compat.py @@ -0,0 +1,253 @@ +""" +Behavioral tests for 0.9.x legacy-compatibility handling: + + * UI redirects - "/" and "/monitor" are public and redirect to + /playground and /dashboard instead of dying in the auth + gate with a bare 401; the /monitor/* API routes and the + data routes stay gated. + * output_path - /screenshot and /pdf still accept the 0.8.x output_path + field but return a warning saying no file was written, + instead of silently dropping it. + * legacy hooks - hooks.code (removed 0.8.x inline Python) is captured, + never executed, and reported as status "ignored" with a + warning when hooks are enabled; any hooks payload is + still refused (403) while hooks are disabled. + +(The compose PID-cap check lives in test_security_container_posture.py.) + +These exercise the running app via TestClient (no browser / Redis needed); +crawl internals are stubbed where a handler would otherwise need a browser. +""" + +import base64 +from types import SimpleNamespace + +import pytest + +from auth import create_access_token # noqa: E402 + + +def _bearer() -> dict: + return {"Authorization": f"Bearer {create_access_token({'sub': 'user@x.com'}, scope='data')}"} + + +# ───────────────────────── UI redirects ───────────────────────── + + +class TestUiRedirects: + def test_root_is_public_and_redirects_to_playground(self, stock_client): + r = stock_client.get("/", follow_redirects=False) + assert r.status_code in (302, 307), ( + f"GET / returned {r.status_code}; expected a redirect. The auth " + f"gate must allow the exact path '/' so the redirect route runs." + ) + assert r.headers["location"] == "/playground" + + def test_monitor_redirects_to_dashboard(self, stock_client): + # Pre-0.9 docs sent people to /monitor for the dashboard UI. That exact + # path redirects to /dashboard (UI-public) instead of dead-ending in the + # auth gate with a bare 401. + r = stock_client.get("/monitor", follow_redirects=False) + assert r.status_code in (302, 307), ( + f"GET /monitor returned {r.status_code}; expected a redirect. The " + f"auth gate must allow the exact path '/monitor' so the route runs." + ) + assert r.headers["location"] == "/dashboard" + + @pytest.mark.parametrize( + "method,path", + [ + ("get", "/monitor/health"), + ("get", "/monitor/requests"), + ("get", "/monitor/browsers"), + ("get", "/monitor/timeline"), + ("get", "/monitor/logs/errors"), + ("post", "/monitor/actions/cleanup"), + ("post", "/monitor/actions/kill_browser"), + ("post", "/monitor/stats/reset"), + ], + ) + def test_monitor_api_routes_stay_gated(self, stock_client, method, path): + # The redirect above is exact-path only. Making the /monitor *prefix* + # public would expose request logs, browser state and the destructive + # admin actions without a credential. + assert getattr(stock_client, method)(path).status_code == 401 + + def test_monitor_websocket_stays_gated(self, stock_client): + # /monitor/ws is where live stats stream; it must not open unauthenticated. + with pytest.raises(Exception): + with stock_client.websocket_connect("/monitor/ws"): + pass + + def test_data_routes_stay_gated(self, stock_client): + assert stock_client.post("/crawl", json={"urls": ["https://x"]}).status_code == 401 + + +# ───────────────────────── output_path warning ───────────────────────── + + +@pytest.fixture +def stub_crawler(server_module, monkeypatch): + """Stub the crawler pool + artifact store so /screenshot and /pdf run + without a browser. Returns the fake artifact dict for assertions.""" + art = {"artifact_id": "a1", "url": "/artifacts/a1", "mime": "x", "size": 1} + png_b64 = base64.b64encode(b"fake-png").decode() + + fake_result = SimpleNamespace(success=True, screenshot=png_b64, pdf=b"fake-pdf") + + class _FakeCrawler: + async def arun(self, url, config): + return [fake_result] + + async def fake_get_crawler(cfg): + return _FakeCrawler() + + async def fake_release_crawler(crawler): + pass + + monkeypatch.setattr(server_module, "get_crawler", fake_get_crawler) + monkeypatch.setattr(server_module, "release_crawler", fake_release_crawler) + monkeypatch.setattr(server_module, "_store_artifact", lambda kind, data: dict(art)) + return art + + +class TestOutputPathWarning: + @pytest.mark.parametrize("endpoint,payload_key", [("/screenshot", "screenshot"), ("/pdf", "pdf")]) + def test_output_path_accepted_with_warning(self, stock_client, stub_crawler, endpoint, payload_key): + r = stock_client.post( + endpoint, + json={"url": "https://example.com", "output_path": "/tmp/x.bin"}, + headers=_bearer(), + ) + assert r.status_code == 200 + body = r.json() + assert body["success"] is True + assert "warning" in body, "output_path must not be silently dropped" + assert "no file was written" in body["warning"] + assert body["artifact_id"] == stub_crawler["artifact_id"] + + @pytest.mark.parametrize("endpoint", ["/screenshot", "/pdf"]) + def test_no_warning_without_output_path(self, stock_client, stub_crawler, endpoint): + r = stock_client.post(endpoint, json={"url": "https://example.com"}, headers=_bearer()) + assert r.status_code == 200 + assert "warning" not in r.json() + + @pytest.mark.parametrize("endpoint", ["/screenshot", "/pdf"]) + def test_output_path_is_never_written(self, stock_client, stub_crawler, endpoint, tmp_path): + """Security tripwire for the 0.8.x arbitrary-write vuln: the handler + runs for real here (only crawler/artifact store are stubbed), so any + reintroduced write of body.output_path creates this file and fails.""" + target = tmp_path / "out.bin" + r = stock_client.post( + endpoint, json={"url": "https://example.com", "output_path": str(target)}, + headers=_bearer(), + ) + assert r.status_code == 200 + assert not target.exists(), "output_path must never be written to disk" + + +# ───────────────────────── legacy hooks.code ───────────────────────── + +LEGACY_HOOKS = {"code": {"before_goto": "async def hook(p, c, u, **kw): return p"}} +DECLARATIVE_HOOKS = {"hooks": [{"action": "scroll_to_bottom", "params": {"max_steps": 2}}]} + + +class TestLegacyHookCode: + def test_hook_config_captures_code_field(self): + """The legacy field must be parsed (not dropped) so it can be reported.""" + from schemas import CrawlRequestWithHooks + + req = CrawlRequestWithHooks(urls=["https://x"], hooks=LEGACY_HOOKS) + assert req.hooks.code == LEGACY_HOOKS["code"] + assert req.hooks.hooks == [] + + @pytest.mark.parametrize("code", [ + "def hook(): ...", # bare string + {"before_goto": {"nested": "dict"}}, # non-string values + ["a", "list"], # wrong container entirely + ]) + def test_hook_code_accepts_any_legacy_shape(self, code): + """The 0.8.x wire shape was never pinned; a 422 on a field we only + report about would be worse than the silent drop it replaces.""" + from schemas import CrawlRequestWithHooks + + req = CrawlRequestWithHooks(urls=["https://x"], hooks={"code": code}) + assert req.hooks.code == code + + # Any hooks payload is refused while hooks are disabled, but the detail + # must not mislead legacy callers: enabling the flag would not run + # hooks.code (removed in 0.9.0), so payloads carrying it get a removal + # message instead of the generic 'set CRAWL4AI_HOOKS_ENABLED' hint. + @pytest.mark.parametrize("hooks_payload,expected_detail", [ + (LEGACY_HOOKS, "removed in 0.9.0"), # code-only + ({**DECLARATIVE_HOOKS, **LEGACY_HOOKS}, "removed in 0.9.0"), # mixed + (DECLARATIVE_HOOKS, "Set CRAWL4AI_HOOKS_ENABLED=true"), # declarative-only + ]) + def test_any_hooks_payload_403_when_disabled( + self, server_module, monkeypatch, stock_client, hooks_payload, expected_detail + ): + monkeypatch.setattr(server_module, "HOOKS_ENABLED", False) + r = stock_client.post( + "/crawl", + json={"urls": ["https://example.com"], "hooks": hooks_payload}, + headers=_bearer(), + ) + assert r.status_code == 403 + assert expected_detail in r.json()["detail"] + + def _stub_crawl(self, server_module, monkeypatch, results): + async def fake_handle_crawl_request(**kwargs): + return dict(results) + + monkeypatch.setattr(server_module, "handle_crawl_request", fake_handle_crawl_request) + + def test_legacy_code_warned_and_ignored_when_enabled(self, server_module, monkeypatch, stock_client): + monkeypatch.setattr(server_module, "HOOKS_ENABLED", True) + # Empty declarative specs -> api.py reports a vacuous success + self._stub_crawl( + server_module, monkeypatch, + {"success": True, "results": [{"success": True}], + "hooks": {"status": "success", "attached": []}}, + ) + r = stock_client.post( + "/crawl", + json={"urls": ["https://example.com"], "hooks": LEGACY_HOOKS}, + headers=_bearer(), + ) + assert r.status_code == 200 + hooks = r.json()["hooks"] + assert hooks["status"] == "ignored", "vacuous 'success' must be rewritten" + assert hooks["attached"] == [] + assert "NOT executed" in hooks["warning"] + + def test_mixed_request_keeps_declarative_status_and_warns(self, server_module, monkeypatch, stock_client): + monkeypatch.setattr(server_module, "HOOKS_ENABLED", True) + self._stub_crawl( + server_module, monkeypatch, + {"success": True, "results": [{"success": True}], + "hooks": {"status": "success", "attached": ["before_retrieve_html"]}}, + ) + r = stock_client.post( + "/crawl", + json={"urls": ["https://example.com"], + "hooks": {**DECLARATIVE_HOOKS, **LEGACY_HOOKS}}, + headers=_bearer(), + ) + assert r.status_code == 200 + hooks = r.json()["hooks"] + assert hooks["status"] == "success", "real declarative execution must not be relabeled" + assert hooks["attached"] == ["before_retrieve_html"] + assert "NOT executed" in hooks["warning"] + + def test_no_hooks_response_untouched(self, server_module, monkeypatch, stock_client): + self._stub_crawl( + server_module, monkeypatch, + {"success": True, "results": [{"success": True}]}, + ) + r = stock_client.post("/crawl", json={"urls": ["https://example.com"]}, headers=_bearer()) + assert r.status_code == 200 + assert "hooks" not in r.json() + + +# The compose PID-cap check lives in +# test_security_container_posture.py::test_pids_limit (YAML-parsed). diff --git a/deploy/docker/tests/test_security_2026_04.py b/deploy/docker/tests/test_security_2026_04.py index 4109de046..e015274b6 100644 --- a/deploy/docker/tests/test_security_2026_04.py +++ b/deploy/docker/tests/test_security_2026_04.py @@ -82,23 +82,29 @@ def validate_webhook_url(url): # ============================================================================ class TestOutputPathRemoved(unittest.TestCase): - """output_path is gone; the server owns paths via the artifact store. + """output_path never reaches the filesystem; the server owns paths via the + artifact store. The old string-only validate_output_path was bypassable (symlink/TOCTOU, sibling-prefix '...-evil') -> arbitrary write -> RCE. The fix is to never - accept a caller path at all. Behavioral artifact-store coverage (O_NOFOLLOW, - O_EXCL, hex id, TTL, quota) lives in test_security_artifact_store.py. + use a caller path: output_path exists only as a deprecated no-op for 0.8.x + compatibility and must stay that way. Artifact-store coverage lives in + test_security_artifact_store.py; warning behavior in test_legacy_compat.py. """ - def test_screenshot_request_has_no_output_path(self): + def test_screenshot_output_path_is_deprecated_noop(self): sys.path.insert(0, DEPLOY_DIR) from schemas import ScreenshotRequest - self.assertNotIn("output_path", ScreenshotRequest.model_fields) + field = ScreenshotRequest.model_fields["output_path"] + self.assertTrue(field.deprecated, "output_path must be marked deprecated") + self.assertIsNone(field.default) - def test_pdf_request_has_no_output_path(self): + def test_pdf_output_path_is_deprecated_noop(self): sys.path.insert(0, DEPLOY_DIR) from schemas import PDFRequest - self.assertNotIn("output_path", PDFRequest.model_fields) + field = PDFRequest.model_fields["output_path"] + self.assertTrue(field.deprecated, "output_path must be marked deprecated") + self.assertIsNone(field.default) def test_validate_output_path_deleted(self): sys.path.insert(0, DEPLOY_DIR) @@ -110,18 +116,21 @@ def test_validate_output_path_deleted(self): class TestPydanticPathValidator(unittest.TestCase): - """output_path (and its traversal validator) are gone entirely. + """The traversal validator is gone entirely. Traversal rejection used to be the mitigation; the real fix is that no - caller path is accepted at all, so there is nothing to traverse. The - sandboxed artifact store owns all paths now. + caller path is ever *used*, so there is nothing to traverse. output_path + survives only as a deprecated no-op field (see TestOutputPathRemoved); the + sandboxed artifact store owns all paths. """ - def test_no_output_path_field_on_request_models(self): + def test_output_path_is_inert_on_request_models(self): sys.path.insert(0, DEPLOY_DIR) from schemas import ScreenshotRequest, PDFRequest - self.assertNotIn("output_path", ScreenshotRequest.model_fields) - self.assertNotIn("output_path", PDFRequest.model_fields) + for model in (ScreenshotRequest, PDFRequest): + field = model.model_fields["output_path"] + self.assertTrue(field.deprecated) + self.assertIsNone(field.default) def test_traversal_validator_removed(self): # No reject_traversal validator should remain registered on the models. diff --git a/deploy/docker/tests/test_security_container_posture.py b/deploy/docker/tests/test_security_container_posture.py index 871e642de..eb5df6153 100644 --- a/deploy/docker/tests/test_security_container_posture.py +++ b/deploy/docker/tests/test_security_container_posture.py @@ -78,8 +78,8 @@ class TestSupervisord: def test_redis_requires_password(self, supervisord): assert "--requirepass" in supervisord - def test_redis_bound_loopback(self, supervisord): - assert "--bind 127.0.0.1" in supervisord + def test_redis_bound_loopback_with_optional_ipv6(self, supervisord): + assert "--bind 127.0.0.1 -::1" in supervisord def test_gunicorn_bind_is_env_driven(self, supervisord): # entrypoint.sh resolves GUNICORN_BIND (loopback unless a credential). @@ -104,7 +104,14 @@ def test_no_host_dev_shm_bind(self, compose): assert "shm_size" in compose def test_pids_limit(self, compose): - assert "pids_limit" in compose + # Parse, don't grep: a raw-text search matched the word "pids_limit" + # inside a comment and guarded nothing. The cap lives under + # deploy.resources.limits (not pids_limit) for Compose v5 compatibility. + import yaml + + base = yaml.safe_load(compose)["x-base-config"] + assert "pids_limit" not in base + assert base["deploy"]["resources"]["limits"]["pids"] == 512 def test_read_only_runtime_tmpfs_are_appuser_owned(self, compose): assert "/var/lib/redis:uid=999,gid=999,mode=0700" in compose diff --git a/deploy/docker/tests/test_security_egress_proxy.py b/deploy/docker/tests/test_security_egress_proxy.py index 03ebae1ea..9dc377e25 100644 --- a/deploy/docker/tests/test_security_egress_proxy.py +++ b/deploy/docker/tests/test_security_egress_proxy.py @@ -20,6 +20,18 @@ pytestmark = pytest.mark.posture +_PROXY_ENV = ( + "CRAWL4AI_UPSTREAM_PROXY", "HTTP_PROXY", "http_proxy", + "HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy", +) + + +@pytest.fixture(autouse=True) +def _clear_proxy_env(monkeypatch): + # Keep the suite deterministic on dev machines that sit behind a proxy. + for name in _PROXY_ENV: + monkeypatch.delenv(name, raising=False) + async def _fake_upstream(): async def handle(reader, writer): @@ -121,6 +133,160 @@ async def test_malformed_connect_400(self): await proxy.stop() +async def _fake_corporate_proxy(seen): + """Minimal HTTP proxy: records the CONNECT request line, replies 200, then + answers any tunneled bytes with TUNNEL-OK.""" + async def handle(reader, writer): + line = await reader.readline() + seen.append(line) + while True: # drain CONNECT headers + h = await reader.readline() + if h in (b"\r\n", b"\n", b""): + break + writer.write(b"HTTP/1.1 200 Connection established\r\nVia: fake\r\n\r\n") + await writer.drain() + await reader.read(65536) + writer.write(b"TUNNEL-OK") + await writer.drain() + writer.close() + server = await asyncio.start_server(handle, "127.0.0.1", 0) + return server, server.sockets[0].getsockname()[1] + + +@pytest.mark.asyncio +class TestUpstreamChaining: + async def test_chained_connect_pins_ip_and_blocks_before_upstream(self, monkeypatch): + """The chained-CONNECT security contract: the upstream receives the + PINNED IP (never a hostname to resolve), its response headers do not + leak into the tunnel, and a blocked target produces an opaque 403 + with zero upstream traffic.""" + seen = [] + corp, corp_port = await _fake_corporate_proxy(seen) + monkeypatch.setenv("HTTPS_PROXY", f"http://127.0.0.1:{corp_port}") + + def fake_pin(url): + if "internal.example" in url: + raise EgressBlocked() + return PinnedTarget("https", "good.example", 443, "203.0.113.7") + monkeypatch.setattr(egress_proxy, "resolve_and_pin", fake_pin) + + proxy = PinningProxy() + await proxy.start() + try: + r, w = await asyncio.open_connection(proxy.bound_host, proxy.bound_port) + w.write(b"CONNECT good.example:443 HTTP/1.1\r\n\r\n") + await w.drain() + status = await asyncio.wait_for(r.readline(), timeout=5) + assert b"200" in status + await r.readline() # blank line after the 200 + w.write(b"hello") + await w.drain() + body = await asyncio.wait_for(r.read(100), timeout=5) + # Upstream's Via header must NOT leak into the tunnel. + assert body == b"TUNNEL-OK" + w.close() + + r, w = await asyncio.open_connection(proxy.bound_host, proxy.bound_port) + w.write(b"CONNECT internal.example:443 HTTP/1.1\r\n\r\n") + await w.drain() + status = await asyncio.wait_for(r.readline(), timeout=5) + assert b"403" in status + w.close() + finally: + await proxy.stop() + corp.close() + # The upstream saw ONLY the pinned IP of the allowed target. + assert seen == [b"CONNECT 203.0.113.7:443 HTTP/1.1\r\n"] + + async def test_chained_plain_http_pinned_absolute_form_no_smuggling(self, monkeypatch): + """Plain HTTP via upstream: the request is re-issued in absolute form + against the PINNED IP (no name for the upstream to resolve), carries + Connection: close, and a reused client connection cannot smuggle a + second, unvalidated request upstream.""" + lines = [] + + async def handle(reader, writer): + req = b"" + while b"\r\n\r\n" not in req: + req += await reader.read(4096) + lines.append(req) + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nhi") + await writer.drain() + writer.close() + corp = await asyncio.start_server(handle, "127.0.0.1", 0) + corp_port = corp.sockets[0].getsockname()[1] + monkeypatch.setenv("HTTP_PROXY", f"http://127.0.0.1:{corp_port}") + + def fake_pin(url): + return PinnedTarget("http", "plain.example", 80, "203.0.113.7") + monkeypatch.setattr(egress_proxy, "resolve_and_pin", fake_pin) + + proxy = PinningProxy() + await proxy.start() + try: + r, w = await asyncio.open_connection(proxy.bound_host, proxy.bound_port) + w.write(b"GET http://plain.example/ HTTP/1.1\r\n" + b"Host: plain.example\r\nConnection: keep-alive\r\n\r\n") + await w.drain() + first = await asyncio.wait_for(r.read(200), timeout=5) + assert b"200" in first + # Attempt to smuggle an unvalidated request on the same connection. + w.write(b"GET http://rebind.evil/ HTTP/1.1\r\nHost: rebind.evil\r\n\r\n") + await w.drain() + leftover = await asyncio.wait_for(r.read(200), timeout=5) + assert leftover == b"" # upstream closed; nothing came back + w.close() + finally: + await proxy.stop() + corp.close() + sent = b"".join(lines) + assert sent.startswith(b"GET http://203.0.113.7:80/ HTTP/1.1\r\n") + assert b"Connection: close" in sent + assert b"keep-alive" not in sent + assert b"rebind.evil" not in sent # the smuggled request never got upstream + + +def test_upstream_proxy_env_parsing(monkeypatch): + assert egress_proxy.upstream_proxy() is None + monkeypatch.setenv("HTTP_PROXY", "http://192.168.180.254:56560") + assert egress_proxy.upstream_proxy() == ("192.168.180.254", 56560, None) + monkeypatch.setenv("HTTPS_PROXY", "http://user:p%40ss@10.0.0.1:8080") + host, port, auth = egress_proxy.upstream_proxy() + assert (host, port) == ("10.0.0.1", 8080) + import base64 + assert base64.b64decode(auth.split(b" ")[-1].strip()) == b"user:p@ss" + # scheme-aware selection: http targets prefer HTTP_PROXY + assert egress_proxy.upstream_proxy("http") == ("192.168.180.254", 56560, None) + monkeypatch.setenv("CRAWL4AI_UPSTREAM_PROXY", "proxy.corp:3128") + assert egress_proxy.upstream_proxy() == ("proxy.corp", 3128, None) + # whitespace-only env var means unset, not a proxy named " " + monkeypatch.setenv("CRAWL4AI_UPSTREAM_PROXY", " ") + monkeypatch.delenv("HTTP_PROXY") + monkeypatch.delenv("HTTPS_PROXY") + assert egress_proxy.upstream_proxy() is None + # a junk/unsupported candidate falls through to a valid fallback + monkeypatch.setenv("HTTP_PROXY", "http://good:3128") + monkeypatch.setenv("HTTPS_PROXY", "http://") + assert egress_proxy.upstream_proxy() == ("good", 3128, None) + monkeypatch.setenv("HTTPS_PROXY", "https://tls-proxy.corp") # unsupported scheme + assert egress_proxy.upstream_proxy() == ("good", 3128, None) + monkeypatch.delenv("CRAWL4AI_UPSTREAM_PROXY") + monkeypatch.delenv("HTTP_PROXY") + assert egress_proxy.upstream_proxy() is None # https:// alone -> refused, not mis-dialed + # non-latin-1 credentials must not raise (encoded as UTF-8) + monkeypatch.setenv("HTTPS_PROXY", "http://u:%E5%AF%86%E7%A0%81@10.0.0.1:8080") + assert egress_proxy.upstream_proxy()[2] is not None + # NO_PROXY routing: suffix and CIDR entries force a direct dial + pin = PinnedTarget("https", "site.corp.example", 443, "203.0.113.7") + assert egress_proxy._use_upstream(pin) is not None + monkeypatch.setenv("NO_PROXY", ".corp.example") + assert egress_proxy._use_upstream(pin) is None + monkeypatch.setenv("NO_PROXY", "203.0.113.0/24") + assert egress_proxy._use_upstream(pin) is None + monkeypatch.setenv("NO_PROXY", "site.corp.example:443") # host:port form + assert egress_proxy._use_upstream(pin) is None + + class TestEnforceEgressWiring: def test_enforce_egress_sets_proxy(self, monkeypatch): import egress_broker diff --git a/deploy/docker/tests/test_security_pdf_image_write.py b/deploy/docker/tests/test_security_pdf_image_write.py new file mode 100644 index 000000000..4f171ce4a --- /dev/null +++ b/deploy/docker/tests/test_security_pdf_image_write.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +""" +Regression test for the PDF-image arbitrary-write via untrusted config body +(reported by sec-reex). + +Root cause: PDFContentScrapingStrategy is an UNTRUSTED_ALLOWED_TYPE but had no +field allowlist, so _filter_untrusted_fields fell open and kept its +filesystem-write knobs (image_save_dir / save_images_locally). A request body +the API loads as UNTRUSTED could then steer the PDF image writer to an +attacker-chosen directory. + +These tests hit the real deserialization path (from_serializable_dict / +CrawlerRunConfig.load with provenance=UNTRUSTED), not a copy. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))) + +from crawl4ai.async_configs import ( + CrawlerRunConfig, + Provenance, + UntrustedConfigError, + from_serializable_dict, +) + + +def _pdf_strategy(params: dict) -> dict: + return {"type": "PDFContentScrapingStrategy", "params": params} + + +class TestPdfImageWriteGate(unittest.TestCase): + def test_untrusted_image_save_dir_is_rejected(self): + """image_save_dir on an untrusted body must raise (loud 400), not pass.""" + body = _pdf_strategy({"save_images_locally": True, "image_save_dir": "/home/appuser/.ssh"}) + with self.assertRaises(UntrustedConfigError): + from_serializable_dict(body, provenance=Provenance.UNTRUSTED) + + def test_untrusted_save_images_locally_is_rejected(self): + """The boolean that turns the write on is forbidden on untrusted bodies too.""" + body = _pdf_strategy({"save_images_locally": True}) + with self.assertRaises(UntrustedConfigError): + from_serializable_dict(body, provenance=Provenance.UNTRUSTED) + + def test_untrusted_rejected_when_nested_in_crawlerrunconfig(self): + """The real server path: PDF strategy nested under CrawlerRunConfig.scraping_strategy.""" + body = { + "type": "CrawlerRunConfig", + "params": { + "scraping_strategy": _pdf_strategy( + {"save_images_locally": True, "image_save_dir": "/tmp/c4_oob"} + ) + }, + } + with self.assertRaises(UntrustedConfigError): + CrawlerRunConfig.load(body, provenance=Provenance.UNTRUSTED) + + def test_untrusted_extract_images_still_allowed(self): + """Negative control: extract_images returns bytes base64-inline, no disk + write, so it must NOT be blocked (feature preserved over the API).""" + body = _pdf_strategy({"extract_images": True}) + obj = from_serializable_dict(body, provenance=Provenance.UNTRUSTED) + self.assertEqual(type(obj).__name__, "PDFContentScrapingStrategy") + + def test_trusted_path_unchanged(self): + """No regression: the in-process SDK (TRUSTED) may still set image_save_dir.""" + body = _pdf_strategy({"save_images_locally": True, "image_save_dir": "/tmp/mine"}) + obj = from_serializable_dict(body, provenance=Provenance.TRUSTED) + self.assertEqual(type(obj).__name__, "PDFContentScrapingStrategy") + + def test_global_backstop_covers_future_types(self): + """The global forbidden set catches a write-arg regardless of per-type map. + LXMLWebScrapingStrategy has no path arg today, but a smuggled downloads_path + must still be rejected by the fail-closed backstop.""" + body = {"type": "LXMLWebScrapingStrategy", "params": {"downloads_path": "/etc"}} + with self.assertRaises(UntrustedConfigError): + from_serializable_dict(body, provenance=Provenance.UNTRUSTED) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/docker-compose.yml b/docker-compose.yml index 1cd87ad93..02cbe0584 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,23 @@ -version: '3.8' - # Shared configuration for all environments x-base-config: &base-config ports: - "11235:11235" # Gunicorn port env_file: - - .llm.env # API keys (create from .llm.env.example) - # Uncomment to set default environment variables (will overwrite .llm.env) - # environment: - # - OPENAI_API_KEY=${OPENAI_API_KEY:-} - # - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-} - # - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - # - GROQ_API_KEY=${GROQ_API_KEY:-} - # - TOGETHER_API_KEY=${TOGETHER_API_KEY:-} - # - MISTRAL_API_KEY=${MISTRAL_API_KEY:-} - # - GEMINI_API_KEY=${GEMINI_API_KEY:-} - # - LLM_PROVIDER=${LLM_PROVIDER:-} # Optional: Override default provider (e.g., "anthropic/claude-3-opus") + # API keys (create from .llm.env.example); optional so a fresh clone runs. + - path: .llm.env + required: false + environment: + # Auth token passthrough from the host shell (overwrites .llm.env). + - CRAWL4AI_API_TOKEN=${CRAWL4AI_API_TOKEN:-} + # Uncomment to set default environment variables (will overwrite .llm.env) + # - OPENAI_API_KEY=${OPENAI_API_KEY:-} + # - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-} + # - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + # - GROQ_API_KEY=${GROQ_API_KEY:-} + # - TOGETHER_API_KEY=${TOGETHER_API_KEY:-} + # - MISTRAL_API_KEY=${MISTRAL_API_KEY:-} + # - GEMINI_API_KEY=${GEMINI_API_KEY:-} + # - LLM_PROVIDER=${LLM_PROVIDER:-} # Optional: Override default provider (e.g., "anthropic/claude-3-opus") # Chromium needs shared memory but the host /dev/shm bind was a shared, # writable mount; use a private sized tmpfs instead. shm_size: "1gb" @@ -24,7 +26,6 @@ x-base-config: &base-config - ALL security_opt: - no-new-privileges:true - pids_limit: 512 # Read-only root filesystem; only these paths are writable (tmpfs). read_only: true tmpfs: @@ -39,6 +40,9 @@ x-base-config: &base-config resources: limits: memory: 4G + # PID cap; expressed here (not as pids_limit) so the file stays valid + # on Compose v5+, which rejects pids_limit alongside a limits block. + pids: 512 reservations: memory: 1G restart: unless-stopped diff --git a/docs/blog/release-v0.9.3.md b/docs/blog/release-v0.9.3.md new file mode 100644 index 000000000..256c4b3fc --- /dev/null +++ b/docs/blog/release-v0.9.3.md @@ -0,0 +1,149 @@ +# Crawl4AI v0.9.3: Security Release + +*August 2026 - 4 min read* + +--- + +I'm releasing Crawl4AI v0.9.3, a security release that closes five coordinated-disclosure advisories. It also carries the 33 bug fixes that piled up on `develop` since 0.9.2, most of them in the Docker server. No new features, no breaking changes. + +Four of the five are in the PDF path, and they share one root cause. `PDFContentScrapingStrategy` can be selected from an untrusted Docker API request body, and it downloads with `requests` rather than through the browser. Every control the server applies on the Chromium side (destination pinning, resource caps) simply did not reach it. The fifth is a DOM-based XSS in the Playground UI that could hand an operator's API token to an attacker. + +If you self-host the Docker server and accept crawl requests from clients you do not fully trust, upgrade. If you use the pip library and open PDFs from sources you do not control, upgrade. + +## What's fixed at a glance + +- **Arbitrary file write**: an untrusted request body could choose where extracted PDF images were written +- **SSRF**: the PDF download followed redirects into internal addresses +- **Denial of service**: remote PDFs were downloaded and parsed with no size or page cap +- **XSS**: PDF text was written into `cleaned_html` without escaping +- **DOM XSS**: the Playground result viewer re-parsed crawled content as live HTML +- **33 other bug fixes**: Docker server, crawler, and PDF handling, listed further down + +## Security fixes + +### Arbitrary file write via PDF image-write fields + +**GHSA-xpp7-j28w-2gvx, CWE-22, high.** Credit: Zhixi "Jace" Sun, independent security researcher. + +`PDFContentScrapingStrategy` was in the untrusted-allowed type list but had no field allowlist of its own. A request body could therefore set `save_images_locally: true` and `image_save_dir` to any path, and the server would write extracted PDF images there. + +Both fields are now filtered out of untrusted bodies at the trust boundary, and `extract_images` is forced off for them. Rasterizing every page of a caller-supplied PDF is the most expensive thing this strategy can do, and nothing about untrusted crawling needs it. + +### SSRF via PDF download redirects + +**GHSA-q5rj-45vw-vp2g, CWE-918, high.** Credit: Nguyen Tran Thanh Lam ([c240030](https://github.com/c240030)). + +The PDF download let `requests` follow redirects on its own. A public, allowed URL that returned a 302 to `169.254.169.254` or any other internal address was fetched without the destination ever being checked. + +Redirects are now resolved by hand, one hop at a time, with the destination policy consulted before each hop is fetched and a cap of five hops. The peer IP of the response whose body is actually read back is validated too, which closes DNS rebinding: passing the URL check only proves the *name* resolved somewhere allowed, and `requests` resolves it again when it dials. + +The library ships with no egress policy of its own, because a plain library caller already chose the URL. The Docker server installs its existing `egress_broker` policy into the PDF path at boot, so that path now gets the same non-global-IP rule the browser path already had. + +### Denial of service via unbounded PDF size and page count + +**GHSA-v2rm-hvrj-2x9q, CWE-400, medium.** Credit: Nguyen Tran Thanh Lam ([c240030](https://github.com/c240030)). + +A remote PDF was streamed to a temp file with no cap and then parsed with no page limit. One request pointing at a large or many-page PDF could exhaust disk, CPU, and bandwidth on a shared worker. + +Downloads now stop at `max_pdf_bytes`, 100 MiB by default. The cap is enforced on the running byte total, not on the `content-length` header, because that header is attacker-controlled and may be absent or understated. Parsing stops at `max_pdf_pages`, 2000 by default, in both the single and batch paths. Untrusted request bodies have both caps clamped, so a client cannot raise its own limits back to unbounded. + +The Docker config also ships a non-zero `limits.wall_clock_s` of 300 seconds. It was `0`, meaning no per-crawl deadline at all. + +### XSS via unescaped PDF text in cleaned_html + +**GHSA-7g3g-vhm6-79f3, CWE-79, medium.** Credit: Nguyen Tran Thanh Lam ([c240030](https://github.com/c240030)). + +`clean_pdf_text_to_html()` escapes every sink it writes to except one: paragraph text. The `html.escape()` call on that path had been commented out. Markup embedded in a PDF therefore survived verbatim into `cleaned_html` and executed wherever the result was rendered. + +The escape is restored. + +### DOM-based XSS in the Playground leading to API token theft + +**GHSA-m446-hp3q-qfxp, CWE-79, high.** Credit: [e1codes](https://github.com/e1codes). + +The Playground result viewer reset syntax highlighting with: + +```js +const text = element.textContent; +element.innerHTML = text; +``` + +That round trip re-parses the text as live HTML. The text is crawl output, so it is whatever the crawled page contained. Script in a crawled page could therefore run in the operator's Playground session and read the API token held there. + +The round trip is removed. highlight.js renders from `textContent` and emits escaped markup on its own, so it was never needed. + +## Bug fixes + +Everything below landed on `develop` between 0.9.2 and this release. None of it is security related. + +### Docker server + +- **PDF works out of the box**: PDF scraping is supported by default, and a request that selects `PDFContentScrapingStrategy` is now routed to `PDFCrawlerStrategy` automatically. Before this you had to pair them by hand or get a placeholder back. (#2094, #2150) +- **Upstream proxy chaining**: the egress proxy now chains through `HTTP_PROXY` / `HTTPS_PROXY` instead of ignoring them, so the server works behind a corporate proxy. (#2142) +- **Proxy env handling**: junk values in the proxy environment fall through to the next candidate rather than failing the crawl, and non-http proxy schemes are refused up front. (#2094) +- **Compose v5**: compatibility fixes, clearer warnings when a legacy field is used, and better error handling in the playground. (#2094) +- **Token and LLM env**: `CRAWL4AI_API_TOKEN` is forwarded through compose, and `.llm.env` is optional instead of required. (#2094) +- **Clearer 403 on hooks**: the disabled-hooks response now says when the request used the removed `hooks.code` field, rather than giving one generic refusal. (#2094) +- **`output_path`**: declared a deprecated no-op instead of being silently ignored. (#2094) +- **`GET /monitor`**: redirects to the dashboard UI instead of returning nothing useful. (#2157, issue #2091) +- **Failed crawls are reported**: failure details are preserved for both batch and single-URL requests instead of being dropped. (#2094, #2134, issue #2133) +- **IPv6 loopback**: an unavailable IPv6 loopback no longer breaks startup. (#2081, issue #2078) +- **MCP pinning**: `mcp` is capped below 2, keeping the v1 low-level API that `mcp_bridge` depends on. (#2148, thanks @weike-zhang) +- **Compose formatting**: commented environment variable lines are aligned with the environment list. (#2156) + +### Crawler and core + +- **Playwright driver leak**: when a browser failed to launch inside `__aenter__`, the Playwright driver subprocess was left running. Repeated failed launches leaked one process each time. It is now cleaned up. (#2160) +- **PDF crawls wrongly flagged as blocked**: `PDFCrawlerStrategy` returns a deliberately near-empty placeholder response, and the anti-bot check read that as a block. Every PDF crawl failed, burned the whole retry budget, and then called the fallback fetch. The placeholder is now recognised. (#2138, issue #2135) +- **Cookies across PDF redirects**: redirects are followed by hand, which meant each hop started with an empty cookie jar. A host that set a cookie and then redirected never got it back, so gated and CDN-signed PDFs failed. One session now covers the whole chain. (#2159) +- **Overlay removal hang**: the overlay and consent removal scripts waited on unconditional `setTimeout` calls, which could hang a crawl under a restrictive CSP. The waits are gone. (#2139) +- **`remove_overlay_elements` deleting the page**: a page whose `` carried a global popup class had its entire body removed, leaving an empty result. (#2163, thanks @Nalhin) +- **Body-visibility timeout**: now configurable and validated, and the timeout warning is emitted even when `verbose=False`. (#2117, #2131, #2145, issues #2116, #2129, #2144) + +### Documentation + +- Self-hosting and migration guides updated for 0.9.x. (#2093) +- The `PDFCrawlerStrategy` plus `PDFContentScrapingStrategy` pairing requirement is now documented. + +## Tests + +- `tests/unit/test_pdf_download_limits.py`, 22 tests: per-hop destination validation, DNS rebinding, redirect bounds, byte and page caps, untrusted-body clamping, and temp-file cleanup on abort. +- `tests/unit/test_pdf_html_escaping.py`, 7 tests: escaping of PDF paragraph text in `cleaned_html`. +- `deploy/docker/tests/test_security_pdf_image_write.py`, 6 tests: rejection of image-write fields from untrusted bodies. +- Docker endpoint coverage for crawl failures and for the per-URL `crawler_configs` PDF guard. + +## Breaking changes + +None. + +Two defaults changed, which is worth knowing if you process large PDFs in a self-hosted deployment: + +- `PDFContentScrapingStrategy` now caps downloads at 100 MiB and parsing at 2000 pages. Raise them with `max_pdf_bytes` and `max_pdf_pages` when you call it from the SDK. Requests arriving over the Docker API cannot raise them past those caps. +- `limits.wall_clock_s` in `deploy/docker/config.yml` is now `300` instead of `0`. Set it back to `0` if you deliberately want no per-crawl deadline. + +## Upgrade + +```bash +pip install -U crawl4ai +crawl4ai-doctor # verify installation +``` + +Docker users: pull the latest image once the Docker release workflow finishes. + +```bash +docker pull unclecode/crawl4ai:0.9.3 +``` + +## Acknowledgments + +Thank you to Zhixi "Jace" Sun, Nguyen Tran Thanh Lam ([c240030](https://github.com/c240030)), and [e1codes](https://github.com/e1codes) for reporting these issues privately and giving us time to fix them before disclosure. All reporters are listed in [SECURITY-CREDITS.md](https://github.com/unclecode/crawl4ai/blob/main/SECURITY-CREDITS.md). + +Thanks to the community contributors behind the bug fixes in this release: @nightcityblade (#2130, #2131, #2117, #2134, #2081), @weike-zhang (#2148), and @Nalhin (#2163). + +If you find a security issue in Crawl4AI, please report it privately. See [SECURITY.md](https://github.com/unclecode/crawl4ai/blob/main/SECURITY.md). + +## Support & Resources + +- [Documentation](https://docs.crawl4ai.com) +- [GitHub Issues](https://github.com/unclecode/crawl4ai/issues) +- [Discord Community](https://discord.gg/crawl4ai) diff --git a/docs/md_v2/advanced/pdf-parsing.md b/docs/md_v2/advanced/pdf-parsing.md index 909c0dd17..3d2e7cf71 100644 --- a/docs/md_v2/advanced/pdf-parsing.md +++ b/docs/md_v2/advanced/pdf-parsing.md @@ -7,6 +7,8 @@ Crawl4AI provides specialized strategies for handling and extracting content fro ### Overview `PDFCrawlerStrategy` is an implementation of `AsyncCrawlerStrategy` designed specifically for PDF documents. Instead of interpreting the input URL as an HTML webpage, this strategy treats it as a pointer to a PDF file. It doesn't perform deep crawling or HTML parsing itself but rather prepares the PDF source for a dedicated PDF scraping strategy. Its primary role is to identify the PDF source (web URL or local file) and pass it along the processing pipeline in a way that `AsyncWebCrawler` can handle. +> **Must be paired with `PDFContentScrapingStrategy`.** `PDFCrawlerStrategy` fetches and parses nothing on its own — it returns a placeholder response and lets the scraping strategy do the download and extraction. Always set `CrawlerRunConfig(scraping_strategy=PDFContentScrapingStrategy())`. With any other scraping strategy the crawl still reports `success=True`, but the result contains only the placeholder text instead of your PDF content. + ### When to Use Use `PDFCrawlerStrategy` when you need to: - Process PDF files using the `AsyncWebCrawler`. @@ -20,8 +22,9 @@ Use `PDFCrawlerStrategy` when you need to: - **`async crawl(self, url: str, **kwargs) -> AsyncCrawlResponse`**: - This method is called by the `AsyncWebCrawler` during the `arun` process. - It takes the `url` (which should point to a PDF) and creates a minimal `AsyncCrawlResponse`. - - The `html` attribute of this response is typically empty or a placeholder, as the actual PDF content processing is deferred to the `PDFContentScrapingStrategy` (or a similar PDF-aware scraping strategy). + - The `html` attribute of this response is a short placeholder string, as the actual PDF content processing is deferred to the `PDFContentScrapingStrategy` (or a similar PDF-aware scraping strategy). - It sets `response_headers` to indicate "application/pdf" and `status_code` to 200. + - It also sets `placeholder_html=True` on the response. This tells `AsyncWebCrawler` that the `html` is a stand-in, so the anti-bot content heuristics skip it — otherwise the short placeholder would be misread as a blocked, near-empty page and the crawl would be marked failed. Any custom crawler strategy that defers content extraction to its scraping strategy should set the same flag. - **`async close(self)`**: - A method for cleaning up any resources used by the strategy. For `PDFCrawlerStrategy`, this is usually minimal. - **`async __aenter__(self)` / `async __aexit__(self, exc_type, exc_val, exc_tb)`**: @@ -37,8 +40,8 @@ async def main(): # Initialize the PDF crawler strategy pdf_crawler_strategy = PDFCrawlerStrategy() - # PDFCrawlerStrategy is typically used in conjunction with PDFContentScrapingStrategy - # The scraping strategy handles the actual PDF content extraction + # PDFCrawlerStrategy must be paired with PDFContentScrapingStrategy — + # the scraping strategy is what actually downloads and extracts the PDF pdf_scraping_strategy = PDFContentScrapingStrategy() run_config = CrawlerRunConfig(scraping_strategy=pdf_scraping_strategy) diff --git a/docs/md_v2/api/parameters.md b/docs/md_v2/api/parameters.md index 568e14c30..f9f759a58 100644 --- a/docs/md_v2/api/parameters.md +++ b/docs/md_v2/api/parameters.md @@ -159,6 +159,7 @@ Use these for controlling whether you read or write from a local content cache. | **`c4a_script`** | `str or list[str]` (None) | C4A script that compiles to JavaScript. Alternative to writing raw JS. | | **`js_only`** | `bool` (False) | If `True`, indicates we're reusing an existing session and only applying JS. No full reload. | | **`ignore_body_visibility`** | `bool` (True) | Skip checking if `` is visible. Usually best to keep `True`. | +| **`body_visibility_timeout`** | `int` (30000) | Maximum time in milliseconds to wait for `` to become visible. Must be positive. | | **`scan_full_page`** | `bool` (False) | If `True`, auto-scroll the page to load dynamic content (infinite scroll). | | **`scroll_delay`** | `float` (0.2) | Delay between scroll steps when scanning the full page (`scan_full_page=True`) or capturing full-page screenshots. | | **`max_scroll_steps`** | `int or None` (None) | Maximum number of scroll steps during full page scan. If None, scrolls until entire page is loaded. | diff --git a/docs/md_v2/complete-sdk-reference.md b/docs/md_v2/complete-sdk-reference.md index aa0517b2d..1f3299075 100644 --- a/docs/md_v2/complete-sdk-reference.md +++ b/docs/md_v2/complete-sdk-reference.md @@ -1791,6 +1791,7 @@ run_cfg = CrawlerRunConfig( | **`js_code_before_wait`** | `str or list[str]` (None) | JavaScript to run **before** `wait_for`. Use for triggering loading that `wait_for` then checks. | | **`js_only`** | `bool` (False) | If `True`, indicates we're reusing an existing session and only applying JS. No full reload. | | **`ignore_body_visibility`** | `bool` (True) | Skip checking if `` is visible. Usually best to keep `True`. | +| **`body_visibility_timeout`** | `int` (30000) | Maximum time in milliseconds to wait for `` to become visible. Must be positive. | | **`scan_full_page`** | `bool` (False) | If `True`, auto-scroll the page to load dynamic content (infinite scroll). | | **`scroll_delay`** | `float` (0.2) | Delay between scroll steps when scanning the full page (`scan_full_page=True`) or capturing full-page screenshots. | | **`process_iframes`** | `bool` (False) | Inlines iframe content for single-page extraction. | diff --git a/docs/md_v2/core/self-hosting.md b/docs/md_v2/core/self-hosting.md index 1420774d1..68a8a1065 100644 --- a/docs/md_v2/core/self-hosting.md +++ b/docs/md_v2/core/self-hosting.md @@ -1,13 +1,17 @@ # Self-Hosting Crawl4AI 🚀 -> **🔐 0.9.0 is secure-by-default (breaking changes).** The self-hosted Docker -> server now requires authentication by default, binds to loopback unless you +> **🔐 0.9.0+ is secure-by-default (breaking changes).** The self-hosted Docker +> server requires authentication by default, binds to loopback unless you > set a token, validates request bodies against a strict trust boundary, uses > declarative hooks instead of inline Python, and returns artifact ids for > screenshot/pdf. If you are upgrading from 0.8.x, read the > [migration guide](https://github.com/unclecode/crawl4ai/blob/main/deploy/docker/MIGRATION.md) -> first. Some examples below are being updated for 0.9.0; the migration guide is -> the authoritative reference for the new defaults. +> first. +> +> **The single most important thing to know:** without a `CRAWL4AI_API_TOKEN`, +> the server binds loopback **inside the container** — published ports will +> answer with *connection reset* even though the container reports healthy. +> Every quickstart below therefore starts by setting a token. **Take Control of Your Web Crawling Infrastructure** @@ -58,7 +62,7 @@ When you self-host, you can scale from a single container to a full browser infr ## Prerequisites Before we dive in, make sure you have: -- Docker installed and running (version 20.10.0 or higher), including `docker compose` (usually bundled with Docker Desktop). +- Docker installed and running (version 20.10.0 or higher), including `docker compose` v2.24+ (usually bundled with Docker Desktop). - `git` for cloning the repository. - At least 4GB of RAM available for the container (more recommended for heavy use). - Python 3.10+ (if using the Python SDK). @@ -76,13 +80,13 @@ Pull and run images directly from Docker Hub without building locally. #### 1. Pull the Image -Our latest release is `0.8.0`. Images are built with multi-arch manifests, so Docker automatically pulls the correct version for your system. +Our latest release is `0.9.2`. Images are built with multi-arch manifests, so Docker automatically pulls the correct version for your system. -> 💡 **Note**: The `latest` tag points to the stable `0.8.0` version. +> 💡 **Note**: The `latest` tag points to the most recent stable version. ```bash # Pull the latest version -docker pull unclecode/crawl4ai:0.8.0 +docker pull unclecode/crawl4ai:0.9.2 # Or pull using the latest tag docker pull unclecode/crawl4ai:latest @@ -121,7 +125,23 @@ EOL ``` > 🔑 **Note**: Keep your API keys secure! Never commit `.llm.env` to version control. -#### 3. Run the Container +#### 3. Set an API Token (Required to Reach the Server) + +Since 0.9.0 the server is secure-by-default: **without a token it binds +loopback inside the container**, so the published port answers with +*connection reset* — even though `docker ps` shows the container healthy and +the port mapped. Generate a token first: + +```bash +export CRAWL4AI_API_TOKEN="$(openssl rand -hex 32)" +``` + +> ⚠️ Use the explicit form `-e CRAWL4AI_API_TOKEN="$CRAWL4AI_API_TOKEN"` below. +> The shorthand `-e CRAWL4AI_API_TOKEN` (no value) forwards the variable from +> the *current shell* — in a shell where it isn't set, it silently passes an +> empty value and the server starts in loopback mode with no host-side warning. + +#### 4. Run the Container * **Basic run:** ```bash @@ -129,6 +149,7 @@ EOL -p 11235:11235 \ --name crawl4ai \ --shm-size=1g \ + -e CRAWL4AI_API_TOKEN="$CRAWL4AI_API_TOKEN" \ unclecode/crawl4ai:latest ``` @@ -140,12 +161,30 @@ EOL --name crawl4ai \ --env-file .llm.env \ --shm-size=1g \ + -e CRAWL4AI_API_TOKEN="$CRAWL4AI_API_TOKEN" \ unclecode/crawl4ai:latest ``` -> The server will be available at `http://localhost:11235`. Visit `/playground` to access the interactive testing interface. +The server will be available at `http://localhost:11235` after a short startup +(allow ~10 seconds; the healthcheck allows up to 40). Verify: -#### 4. Stopping the Container +```bash +curl http://localhost:11235/health # no auth needed for /health +``` + +Every other endpoint requires `Authorization: Bearer $CRAWL4AI_API_TOKEN`. +Visit `/playground` for the interactive testing interface and `/dashboard` for +the monitoring UI — both have an **API token** bar in the top navigation; paste +your token there and click **Set**. + +> 💡 **Troubleshooting "connection reset":** during the ~10s startup window the +> published port also answers with connection reset — the same symptom as the +> missing-token failure. If resets persist after 20s, check +> `docker logs crawl4ai` for `binding loopback only`: that means no token +> reached the container (note the `127.0.0.1` in that log line is the +> *container's* loopback, not your host's — the port mapping cannot reach it). + +#### 5. Stopping the Container ```bash docker stop crawl4ai && docker rm crawl4ai @@ -154,7 +193,7 @@ docker stop crawl4ai && docker rm crawl4ai #### Docker Hub Versioning Explained * **Image Name:** `unclecode/crawl4ai` -* **Tag Format:** `LIBRARY_VERSION[-SUFFIX]` (e.g., `0.8.0`) +* **Tag Format:** `LIBRARY_VERSION[-SUFFIX]` (e.g., `0.9.2`) * `LIBRARY_VERSION`: The semantic version of the core `crawl4ai` Python library * `SUFFIX`: Optional tag for release candidates (``) and revisions (`r1`) * **`latest` Tag:** Points to the most recent stable version @@ -171,17 +210,39 @@ git clone https://github.com/unclecode/crawl4ai.git cd crawl4ai ``` -#### 2. Environment Setup (API Keys) +#### 2. Environment Setup (Required) -If you plan to use LLMs, copy the example environment file and add your API keys. This file should be in the **project root directory**. +Export an API token in your shell — the compose file passes it into the +container. **Required**, or the server will be unreachable (loopback-only, +published port → connection reset): + +```bash +export CRAWL4AI_API_TOKEN="$(openssl rand -hex 32)" +``` + +Prefer a file? Put the same line in a `.env` file in the project root — +compose reads it automatically on every run, no export needed (if both are +set, the shell export wins): + +```bash +echo "CRAWL4AI_API_TOKEN=$(openssl rand -hex 32)" > .env +``` + +If you use LLMs, also create `.llm.env` in the **project root directory** with +your API keys (optional — compose starts fine without it): ```bash # Make sure you are in the 'crawl4ai' root directory cp deploy/docker/.llm.env.example .llm.env -# Now edit .llm.env and add your API keys +# Now edit .llm.env and add your LLM API keys ``` +> ⚠️ Run the export in the **same shell** you run `docker compose up` from. +> With compose, only the shell export or a `.env` line carries the token — a +> `CRAWL4AI_API_TOKEN=` line in `.llm.env` is overridden by the compose +> passthrough. + **Flexible LLM Provider Configuration:** The Docker setup now supports flexible LLM provider configuration through a hierarchical system: @@ -249,7 +310,9 @@ The `docker-compose.yml` file in the project root provides a simplified approach ENABLE_GPU=true docker compose up --build -d ``` -> The server will be available at `http://localhost:11235`. +> The server will be available at `http://localhost:11235` (allow ~10 seconds +> for startup). All endpoints except `GET /health` require +> `Authorization: Bearer `. #### 4. Stopping the Service @@ -287,12 +350,20 @@ docker buildx build \ #### 3. Run the Container +Set a token first (required — without it the server is loopback-only and the +published port answers with connection reset): + +```bash +export CRAWL4AI_API_TOKEN="$(openssl rand -hex 32)" +``` + * **Basic run (no LLM support):** ```bash docker run -d \ -p 11235:11235 \ --name crawl4ai-standalone \ --shm-size=1g \ + -e CRAWL4AI_API_TOKEN="$CRAWL4AI_API_TOKEN" \ crawl4ai-local:latest ``` @@ -304,10 +375,13 @@ docker buildx build \ --name crawl4ai-standalone \ --env-file .llm.env \ --shm-size=1g \ + -e CRAWL4AI_API_TOKEN="$CRAWL4AI_API_TOKEN" \ crawl4ai-local:latest ``` -> The server will be available at `http://localhost:11235`. +> The server will be available at `http://localhost:11235` (allow ~10 seconds +> for startup). All endpoints except `GET /health` require +> `Authorization: Bearer $CRAWL4AI_API_TOKEN`. #### 4. Stopping the Manual Container @@ -402,13 +476,32 @@ Captures a full-page PNG screenshot of the specified URL. ```json { "url": "https://example.com", - "screenshot_wait_for": 2, - "output_path": "/path/to/save/screenshot.png" + "screenshot_wait_for": 2 } ``` - `screenshot_wait_for`: Optional delay in seconds before capture (default: 2) -- `output_path`: Optional path to save the screenshot (recommended) + +The response contains the image inline (base64) **and** an artifact id you can +fetch later: + +```json +{"success": true, "screenshot": "", + "artifact_id": "…", "url": "/artifacts/…", "mime": "image/png", "size": 16668} +``` + +Download the stored file with an authenticated request (artifacts have a TTL +and a storage quota): + +```bash +curl -H "Authorization: Bearer $CRAWL4AI_API_TOKEN" \ + -o screenshot.png http://localhost:11235/artifacts/ +``` + +> **⚠️ Changed in 0.9.0:** `output_path` was removed (server-side file writes +> were a path-traversal risk). A request that still includes `output_path` +> currently returns `success: true` but **silently ignores the field** — no +> file is written. Use the artifact flow above instead. ### PDF Export Endpoint @@ -420,12 +513,13 @@ Generates a PDF document of the specified URL. ```json { - "url": "https://example.com", - "output_path": "/path/to/save/document.pdf" + "url": "https://example.com" } ``` -- `output_path`: Optional path to save the PDF (recommended) +Like `/screenshot`, the response returns the document plus an `artifact_id`; +fetch the file via `GET /artifacts/{artifact_id}` with your Bearer token. +`output_path` was removed in 0.9.0 and is silently ignored if sent. ### JavaScript Execution Endpoint @@ -449,684 +543,112 @@ Executes JavaScript snippets on the specified URL and returns the full crawl res --- -## User-Provided Hooks API - -> **⚠️ Changed in 0.9.0.** The inline-Python hook API described below was removed. -> Sending arbitrary Python code to the server is no longer accepted (it was an -> unauthenticated code-execution surface). 0.9.0 replaces it with **declarative -> hooks**: a fixed set of safe, server-validated actions (for example -> `add_cookies`, `set_headers`, `block_resources`) supplied as JSON, with no code -> execution. See the [migration guide](https://github.com/unclecode/crawl4ai/blob/main/deploy/docker/MIGRATION.md) -> for the declarative hook format. The inline-code examples in this section apply -> to 0.8.x only and are kept for reference until this page is fully rewritten. +## Hooks (Declarative Actions) -The Docker API supports user-provided hook functions, allowing you to customize the crawling behavior by injecting your own Python code at specific points in the crawling pipeline. This powerful feature enables authentication, performance optimization, and custom content extraction without modifying the server code. +> **⚠️ Changed in 0.9.0.** The previous hooks API — sending Python code strings in +> `hooks.code` — was removed. It was an unauthenticated remote-code-execution +> surface. The server now accepts **declarative hooks**: a fixed set of safe, +> server-validated actions supplied as JSON. No code execution. If you need +> arbitrary hook code, use the in-process Python SDK (`AsyncWebCrawler`), where +> you keep full control. -> ⚠️ **IMPORTANT SECURITY WARNING**: -> - **Never use hooks with untrusted code or on untrusted websites** -> - **Be extremely careful when crawling sites that might be phishing or malicious** -> - **Hook code has access to page context and can interact with the website** -> - **Always validate and sanitize any data extracted through hooks** -> - **Never expose credentials or sensitive data in hook code** -> - **Consider running the Docker container in an isolated network when testing** +### Enabling hooks -### Hook Information Endpoint - -``` -GET /hooks/info -``` - -Returns information about available hook points and their signatures: +Hooks are **disabled by default**. Enable them with an environment variable when +starting the container: ```bash -curl http://localhost:11235/hooks/info +docker run -d \ + -p 11235:11235 \ + --name crawl4ai \ + --shm-size=1g \ + -e CRAWL4AI_API_TOKEN="$CRAWL4AI_API_TOKEN" \ + -e CRAWL4AI_HOOKS_ENABLED=true \ + unclecode/crawl4ai:latest ``` -### Available Hook Points - -The API supports 8 hook points that match the local SDK: - -| Hook Point | Parameters | Description | Best Use Cases | -|------------|------------|-------------|----------------| -| `on_browser_created` | `browser` | After browser instance creation | Light setup tasks | -| `on_page_context_created` | `page, context` | After page/context creation | **Authentication, cookies, route blocking** | -| `before_goto` | `page, context, url` | Before navigating to URL | Custom headers, logging | -| `after_goto` | `page, context, url, response` | After navigation completes | Verification, waiting for elements | -| `on_user_agent_updated` | `page, context, user_agent` | When user agent changes | UA-specific logic | -| `on_execution_started` | `page, context` | When JS execution begins | JS-related setup | -| `before_retrieve_html` | `page, context` | Before getting final HTML | **Scrolling, lazy loading** | -| `before_return_html` | `page, context, html` | Before returning HTML | Final modifications, metrics | - -### Using Hooks in Requests - -Add hooks to any crawl request by including the `hooks` parameter: +Without the flag, any request containing `hooks` returns: ```json -{ - "urls": ["https://httpbin.org/html"], - "hooks": { - "code": { - "hook_point_name": "async def hook(...): ...", - "another_hook": "async def hook(...): ..." - }, - "timeout": 30 // Optional, default 30 seconds (max 120) - } -} -``` - -### Hook Examples with Real URLs - -#### 1. Authentication with Cookies (GitHub) - -```python -import requests - -# Example: Setting GitHub session cookie (use your actual session) -hooks_code = { - "on_page_context_created": """ -async def hook(page, context, **kwargs): - # Add authentication cookies for GitHub - # WARNING: Never hardcode real credentials! - await context.add_cookies([ - { - 'name': 'user_session', - 'value': 'your_github_session_token', # Replace with actual token - 'domain': '.github.com', - 'path': '/', - 'httpOnly': True, - 'secure': True, - 'sameSite': 'Lax' - } - ]) - return page -""" -} - -response = requests.post("http://localhost:11235/crawl", json={ - "urls": ["https://github.com/settings/profile"], # Protected page - "hooks": {"code": hooks_code, "timeout": 30} -}) +{"detail": "Hooks are disabled. Set CRAWL4AI_HOOKS_ENABLED=true to enable."} ``` -#### 2. Basic Authentication (httpbin.org for testing) - -```python -# Safe testing with httpbin.org (a service designed for HTTP testing) -hooks_code = { - "before_goto": """ -async def hook(page, context, url, **kwargs): - import base64 - # httpbin.org/basic-auth expects username="user" and password="passwd" - credentials = base64.b64encode(b"user:passwd").decode('ascii') - - await page.set_extra_http_headers({ - 'Authorization': f'Basic {credentials}' - }) - return page -""" -} +### Available actions -response = requests.post("http://localhost:11235/crawl", json={ - "urls": ["https://httpbin.org/basic-auth/user/passwd"], - "hooks": {"code": hooks_code, "timeout": 15} -}) -``` - -#### 3. Performance Optimization (News Sites) +| Action | Hook point | Description | +|--------|-----------|-------------| +| `block_resources` | `on_page_context_created` | Abort matching resource types (`image`, `stylesheet`, `font`, `media`) | +| `add_cookies` | `on_page_context_created` | Add cookies to the browser context before navigation (auth) | +| `set_headers` | `before_goto` | Set extra HTTP request headers before navigating | +| `scroll_to_bottom` | `before_retrieve_html` | Scroll to the page bottom in bounded steps (lazy-load), `max_steps` 1–50, `delay_ms` 0–5000 | +| `wait_for_timeout` | `before_retrieve_html` | Wait a bounded number of milliseconds (0–60000) before retrieving HTML | -```python -# Example: Optimizing crawling of news sites like CNN or BBC -hooks_code = { - "on_page_context_created": """ -async def hook(page, context, **kwargs): - # Block images, fonts, and media to speed up crawling - await context.route("**/*.{png,jpg,jpeg,gif,webp,svg,ico}", lambda route: route.abort()) - await context.route("**/*.{woff,woff2,ttf,otf,eot}", lambda route: route.abort()) - await context.route("**/*.{mp4,webm,ogg,mp3,wav,flac}", lambda route: route.abort()) - - # Block common tracking and ad domains - await context.route("**/googletagmanager.com/*", lambda route: route.abort()) - await context.route("**/google-analytics.com/*", lambda route: route.abort()) - await context.route("**/doubleclick.net/*", lambda route: route.abort()) - await context.route("**/facebook.com/tr/*", lambda route: route.abort()) - await context.route("**/amazon-adsystem.com/*", lambda route: route.abort()) - - # Disable CSS animations for faster rendering - await page.add_style_tag(content=''' - *, *::before, *::after { - animation-duration: 0s !important; - transition-duration: 0s !important; - } - ''') - - return page -""" -} +Get the full parameter schemas at runtime: -response = requests.post("http://localhost:11235/crawl", json={ - "urls": ["https://www.bbc.com/news"], # Heavy news site - "hooks": {"code": hooks_code, "timeout": 30} -}) +```bash +curl -H "Authorization: Bearer $CRAWL4AI_API_TOKEN" \ + http://localhost:11235/hooks/info ``` -#### 4. Handling Infinite Scroll (Twitter/X) +### Using hooks in a request -```python -# Example: Scrolling on Twitter/X (requires authentication) -hooks_code = { - "before_retrieve_html": """ -async def hook(page, context, **kwargs): - # Scroll to load more tweets - previous_height = 0 - for i in range(5): # Limit scrolls to avoid infinite loop - current_height = await page.evaluate("document.body.scrollHeight") - if current_height == previous_height: - break # No more content to load - - await page.evaluate("window.scrollTo(0, document.body.scrollHeight)") - await page.wait_for_timeout(2000) # Wait for content to load - previous_height = current_height - - return page -""" -} - -# Note: Twitter requires authentication for most content -response = requests.post("http://localhost:11235/crawl", json={ - "urls": ["https://twitter.com/nasa"], # Public profile - "hooks": {"code": hooks_code, "timeout": 30} -}) -``` - -#### 5. E-commerce Login (Example Pattern) +Add a `hooks` object with a list of actions (maximum 10 per request): -```python -# SECURITY WARNING: This is a pattern example. -# Never use real credentials in code! -# Always use environment variables or secure vaults. - -hooks_code = { - "on_page_context_created": """ -async def hook(page, context, **kwargs): - # Example pattern for e-commerce sites - # DO NOT use real credentials here! - - # Navigate to login page first - await page.goto("https://example-shop.com/login") - - # Wait for login form to load - await page.wait_for_selector("#email", timeout=5000) - - # Fill login form (use environment variables in production!) - await page.fill("#email", "test@example.com") # Never use real email - await page.fill("#password", "test_password") # Never use real password - - # Handle "Remember Me" checkbox if present - try: - await page.uncheck("#remember_me") # Don't remember on shared systems - except: - pass - - # Submit form - await page.click("button[type='submit']") - - # Wait for redirect after login - await page.wait_for_url("**/account/**", timeout=10000) - - return page -""" -} +```bash +curl -X POST http://localhost:11235/crawl \ + -H "Authorization: Bearer $CRAWL4AI_API_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "urls": ["https://example.com"], + "hooks": { + "hooks": [ + {"action": "block_resources", "params": {"resource_types": ["image", "font"]}}, + {"action": "scroll_to_bottom", "params": {"max_steps": 10, "delay_ms": 500}} + ] + } + }' ``` -#### 6. Extracting Structured Data (Wikipedia) +The response reports what was attached and executed: -```python -# Safe example using Wikipedia -hooks_code = { - "after_goto": """ -async def hook(page, context, url, response, **kwargs): - # Wait for Wikipedia content to load - await page.wait_for_selector("#content", timeout=5000) - return page -""", - - "before_retrieve_html": """ -async def hook(page, context, **kwargs): - # Extract structured data from Wikipedia infobox - metadata = await page.evaluate('''() => { - const infobox = document.querySelector('.infobox'); - if (!infobox) return null; - - const data = {}; - const rows = infobox.querySelectorAll('tr'); - - rows.forEach(row => { - const header = row.querySelector('th'); - const value = row.querySelector('td'); - if (header && value) { - data[header.innerText.trim()] = value.innerText.trim(); - } - }); - - return data; - }''') - - if metadata: - print("Extracted metadata:", metadata) - - return page -""" -} - -response = requests.post("http://localhost:11235/crawl", json={ - "urls": ["https://en.wikipedia.org/wiki/Python_(programming_language)"], - "hooks": {"code": hooks_code, "timeout": 20} -}) +```json +{"success": true, "results": [...], "hooks": {"status": "success", "attached": ["before_retrieve_html"]}} ``` -### Security Best Practices - -> 🔒 **Critical Security Guidelines**: - -1. **Never Trust User Input**: If accepting hook code from users, always validate and sandbox it -2. **Avoid Phishing Sites**: Never use hooks on suspicious or unverified websites -3. **Protect Credentials**: - - Never hardcode passwords, tokens, or API keys in hook code - - Use environment variables or secure secret management - - Rotate credentials regularly -4. **Network Isolation**: Run the Docker container in an isolated network when testing -5. **Audit Hook Code**: Always review hook code before execution -6. **Limit Permissions**: Use the least privileged access needed -7. **Monitor Execution**: Check hook execution logs for suspicious behavior -8. **Timeout Protection**: Always set reasonable timeouts (default 30s) - -### Hook Response Information - -When hooks are used, the response includes detailed execution information: +Cookie example (authentication): ```json { - "success": true, - "results": [...], + "urls": ["https://example.com/account"], "hooks": { - "status": { - "status": "success", // or "partial" or "failed" - "attached_hooks": ["on_page_context_created", "before_retrieve_html"], - "validation_errors": [], - "successfully_attached": 2, - "failed_validation": 0 - }, - "execution_log": [ - { - "hook_point": "on_page_context_created", - "status": "success", - "execution_time": 0.523, - "timestamp": 1234567890.123 - } - ], - "errors": [], // Any runtime errors - "summary": { - "total_executions": 2, - "successful": 2, - "failed": 0, - "timed_out": 0, - "success_rate": 100.0 - } + "hooks": [ + {"action": "add_cookies", "params": {"cookies": [ + {"name": "session", "value": "your-session-token", + "domain": ".example.com", "path": "/", "secure": true} + ]}}, + {"action": "set_headers", "params": {"headers": {"Accept-Language": "en-US"}}} + ] } } ``` -### Error Handling - -The hooks system is designed to be resilient: - -1. **Validation Errors**: Caught before execution (syntax errors, wrong parameters) -2. **Runtime Errors**: Handled gracefully - crawl continues with original page object -3. **Timeout Protection**: Hooks automatically terminated after timeout (configurable 1-120s) - -### Complete Example: Safe Multi-Hook Crawling - -```python -import requests -import json -import os - -# Safe example using httpbin.org for testing -hooks_code = { - "on_page_context_created": """ -async def hook(page, context, **kwargs): - # Set viewport and test cookies - await page.set_viewport_size({"width": 1920, "height": 1080}) - await context.add_cookies([ - {"name": "test_cookie", "value": "test_value", "domain": ".httpbin.org", "path": "/"} - ]) - - # Block unnecessary resources for httpbin - await context.route("**/*.{png,jpg,jpeg}", lambda route: route.abort()) - return page -""", - - "before_goto": """ -async def hook(page, context, url, **kwargs): - # Add custom headers for testing - await page.set_extra_http_headers({ - "X-Test-Header": "crawl4ai-test", - "Accept-Language": "en-US,en;q=0.9" - }) - print(f"[HOOK] Navigating to: {url}") - return page -""", - - "before_retrieve_html": """ -async def hook(page, context, **kwargs): - # Simple scroll for any lazy-loaded content - await page.evaluate("window.scrollTo(0, document.body.scrollHeight)") - await page.wait_for_timeout(1000) - return page -""" -} - -# Make the request to safe testing endpoints -response = requests.post("http://localhost:11235/crawl", json={ - "urls": [ - "https://httpbin.org/html", - "https://httpbin.org/json" - ], - "hooks": { - "code": hooks_code, - "timeout": 30 - }, - "crawler_config": { - "cache_mode": "bypass" - } -}) - -# Check results -if response.status_code == 200: - data = response.json() - - # Check hook execution - if data['hooks']['status']['status'] == 'success': - print(f"✅ All {len(data['hooks']['status']['attached_hooks'])} hooks executed successfully") - print(f"Execution stats: {data['hooks']['summary']}") - - # Process crawl results - for result in data['results']: - print(f"Crawled: {result['url']} - Success: {result['success']}") -else: - print(f"Error: {response.status_code}") -``` - -> 💡 **Remember**: Always test your hooks on safe, known websites first before using them on production sites. Never crawl sites that you don't have permission to access or that might be malicious. - -### Hooks Utility: Function-Based Approach (Python) - -For Python developers, Crawl4AI provides a more convenient way to work with hooks using the `hooks_to_string()` utility function and Docker client integration. - -#### Why Use Function-Based Hooks? - -**String-Based Approach (shown above)**: -```python -hooks_code = { - "on_page_context_created": """ -async def hook(page, context, **kwargs): - await page.set_viewport_size({"width": 1920, "height": 1080}) - return page -""" -} -``` - -**Function-Based Approach (recommended for Python)**: -```python -from crawl4ai import Crawl4aiDockerClient - -async def my_hook(page, context, **kwargs): - await page.set_viewport_size({"width": 1920, "height": 1080}) - return page - -async with Crawl4aiDockerClient(base_url="http://localhost:11235") as client: - result = await client.crawl( - ["https://example.com"], - hooks={"on_page_context_created": my_hook} - ) -``` - -**Benefits**: -- ✅ Write hooks as regular Python functions -- ✅ Full IDE support (autocomplete, syntax highlighting, type checking) -- ✅ Easy to test and debug -- ✅ Reusable hook libraries -- ✅ Automatic conversion to API format - -#### Using the Hooks Utility - -The `hooks_to_string()` utility converts Python function objects to the string format required by the API: - -```python -from crawl4ai import hooks_to_string - -# Define your hooks as functions -async def setup_hook(page, context, **kwargs): - await page.set_viewport_size({"width": 1920, "height": 1080}) - await context.add_cookies([{ - "name": "session", - "value": "token", - "domain": ".example.com" - }]) - return page - -async def scroll_hook(page, context, **kwargs): - await page.evaluate("window.scrollTo(0, document.body.scrollHeight)") - return page - -# Convert to string format -hooks_dict = { - "on_page_context_created": setup_hook, - "before_retrieve_html": scroll_hook -} -hooks_string = hooks_to_string(hooks_dict) - -# Now use with REST API or Docker client -# hooks_string contains the string representations -``` - -#### Docker Client with Automatic Conversion - -The Docker client automatically detects and converts function objects: - -```python -from crawl4ai import Crawl4aiDockerClient - -async def auth_hook(page, context, **kwargs): - """Add authentication cookies""" - await context.add_cookies([{ - "name": "auth_token", - "value": "your_token", - "domain": ".example.com" - }]) - return page - -async def performance_hook(page, context, **kwargs): - """Block unnecessary resources""" - await context.route("**/*.{png,jpg,gif}", lambda r: r.abort()) - await context.route("**/analytics/*", lambda r: r.abort()) - return page - -async with Crawl4aiDockerClient(base_url="http://localhost:11235") as client: - # Pass functions directly - automatic conversion! - result = await client.crawl( - ["https://example.com"], - hooks={ - "on_page_context_created": performance_hook, - "before_goto": auth_hook - }, - hooks_timeout=30 # Optional timeout in seconds (1-120) - ) - - print(f"Success: {result.success}") - print(f"HTML: {len(result.html)} chars") -``` - -#### Creating Reusable Hook Libraries - -Build collections of reusable hooks: - -```python -# hooks_library.py -class CrawlHooks: - """Reusable hook collection for common crawling tasks""" - - @staticmethod - async def block_images(page, context, **kwargs): - """Block all images to speed up crawling""" - await context.route("**/*.{png,jpg,jpeg,gif,webp}", lambda r: r.abort()) - return page - - @staticmethod - async def block_analytics(page, context, **kwargs): - """Block analytics and tracking scripts""" - tracking_domains = [ - "**/google-analytics.com/*", - "**/googletagmanager.com/*", - "**/facebook.com/tr/*", - "**/doubleclick.net/*" - ] - for domain in tracking_domains: - await context.route(domain, lambda r: r.abort()) - return page - - @staticmethod - async def scroll_infinite(page, context, **kwargs): - """Handle infinite scroll to load more content""" - previous_height = 0 - for i in range(5): # Max 5 scrolls - current_height = await page.evaluate("document.body.scrollHeight") - if current_height == previous_height: - break - await page.evaluate("window.scrollTo(0, document.body.scrollHeight)") - await page.wait_for_timeout(1000) - previous_height = current_height - return page - - @staticmethod - async def wait_for_dynamic_content(page, context, url, response, **kwargs): - """Wait for dynamic content to load""" - await page.wait_for_timeout(2000) - try: - # Click "Load More" if present - load_more = await page.query_selector('[class*="load-more"]') - if load_more: - await load_more.click() - await page.wait_for_timeout(1000) - except: - pass - return page - -# Use in your application -from hooks_library import CrawlHooks -from crawl4ai import Crawl4aiDockerClient - -async def crawl_with_optimizations(url): - async with Crawl4aiDockerClient() as client: - result = await client.crawl( - [url], - hooks={ - "on_page_context_created": CrawlHooks.block_images, - "before_retrieve_html": CrawlHooks.scroll_infinite - } - ) - return result -``` - -#### Choosing the Right Approach - -| Approach | Best For | IDE Support | Language | -|----------|----------|-------------|----------| -| **String-based** | Non-Python clients, REST APIs, other languages | ❌ None | Any | -| **Function-based** | Python applications, local development | ✅ Full | Python only | -| **Docker Client** | Python apps with automatic conversion | ✅ Full | Python only | - -**Recommendation**: -- **Python applications**: Use Docker client with function objects (easiest) -- **Non-Python or REST API**: Use string-based hooks (most flexible) -- **Manual control**: Use `hooks_to_string()` utility (middle ground) +### Migrating from 0.8.x `hooks.code` -#### Complete Example with Function Hooks +Requests using the removed `hooks.code` (Python strings) format are **not +executed**. Be aware of the current behavior: -```python -from crawl4ai import Crawl4aiDockerClient, BrowserConfig, CrawlerRunConfig, CacheMode - -# Define hooks as regular Python functions -async def setup_environment(page, context, **kwargs): - """Setup crawling environment""" - # Set viewport - await page.set_viewport_size({"width": 1920, "height": 1080}) - - # Block resources for speed - await context.route("**/*.{png,jpg,gif}", lambda r: r.abort()) - - # Add custom headers - await page.set_extra_http_headers({ - "Accept-Language": "en-US", - "X-Custom-Header": "Crawl4AI" - }) - - print("[HOOK] Environment configured") - return page - -async def extract_content(page, context, **kwargs): - """Extract and prepare content""" - # Scroll to load lazy content - await page.evaluate("window.scrollTo(0, document.body.scrollHeight)") - await page.wait_for_timeout(1000) - - # Extract metadata - metadata = await page.evaluate('''() => ({ - title: document.title, - links: document.links.length, - images: document.images.length - })''') - - print(f"[HOOK] Page metadata: {metadata}") - return page - -async def main(): - async with Crawl4aiDockerClient(base_url="http://localhost:11235", verbose=True) as client: - # Configure crawl - browser_config = BrowserConfig(headless=True) - crawler_config = CrawlerRunConfig(cache_mode=CacheMode.BYPASS) - - # Crawl with hooks - result = await client.crawl( - ["https://httpbin.org/html"], - browser_config=browser_config, - crawler_config=crawler_config, - hooks={ - "on_page_context_created": setup_environment, - "before_retrieve_html": extract_content - }, - hooks_timeout=30 - ) - - if result.success: - print(f"✅ Crawl successful!") - print(f" URL: {result.url}") - print(f" HTML: {len(result.html)} chars") - print(f" Markdown: {len(result.markdown)} chars") - else: - print(f"❌ Crawl failed: {result.error_message}") - -if __name__ == "__main__": - import asyncio - asyncio.run(main()) -``` +- With hooks disabled (default): HTTP 403 "Hooks are disabled…" — note that + enabling hooks will *not* make code hooks work. +- With hooks enabled: the request succeeds (HTTP 200) but the inline code is + **silently ignored** — the response shows `"hooks": {"status": "success", + "attached": []}`. If you see an empty `attached` list, your hooks did not run. -#### Additional Resources +Map your old hook code to declarative actions where possible (resource blocking, +cookies, headers, scrolling, waits). For anything beyond the fixed action set — +custom JavaScript, form logins, conditional logic — use the in-process Python +SDK, which retains the full 8-hook-point API described in the +[hooks documentation](../advanced/hooks-auth.md). -- **Comprehensive Examples**: See `/docs/examples/hooks_docker_client_example.py` for Python function-based examples -- **REST API Examples**: See `/docs/examples/hooks_rest_api_example.py` for string-based examples -- **Comparison Guide**: See `/docs/examples/README_HOOKS.md` for detailed comparison -- **Utility Documentation**: See `/docs/hooks-utility-guide.md` for complete guide --- @@ -1634,7 +1156,14 @@ Communicate with the running Docker server via its REST API (defaulting to `http ### Playground Interface -A built-in web playground is available at `http://localhost:11235/playground` for testing and generating API requests. The playground allows you to: +A built-in web playground is available at `http://localhost:11235/playground` for testing and generating API requests. + +> 🔑 Before running requests, paste your API token into the **API token** bar in +> the top navigation and click **Set** — otherwise every request returns +> `{"detail": "Authentication required"}` (note: the status banner may still +> show "Success" for such error responses; check the response body). + +The playground allows you to: 1. Configure `CrawlerRunConfig` and `BrowserConfig` using the main library's Python syntax 2. Test crawling operations directly from the interface @@ -1646,7 +1175,13 @@ This is the easiest way to translate Python configuration to JSON requests when Install the SDK: `pip install crawl4ai` -The Python SDK provides a convenient way to interact with the Docker API, including **automatic hook conversion** when using function objects. +The Python SDK provides a convenient way to interact with the Docker API. + +> **⚠️ Changed in 0.9.0:** the SDK's function-based hooks (`hooks={...}` with +> Python functions) no longer work against the Docker server — they were +> converted to code strings server-side, and request-supplied hook code was +> removed. Use [declarative hooks](#hooks-declarative-actions) over the REST +> API, or run the in-process SDK (`AsyncWebCrawler`) for full hook support. ```python import asyncio @@ -1687,25 +1222,6 @@ async def main(): except Exception as e: print(f"Streaming crawl failed: {e}") - # Example with hooks (Python function objects) - print("\n--- Crawl with Hooks ---") - - async def my_hook(page, context, **kwargs): - """Custom hook to optimize performance""" - await page.set_viewport_size({"width": 1920, "height": 1080}) - await context.route("**/*.{png,jpg}", lambda r: r.abort()) - print("[HOOK] Page optimized") - return page - - result = await client.crawl( - ["https://httpbin.org/html"], - browser_config=BrowserConfig(headless=True), - crawler_config=CrawlerRunConfig(cache_mode=CacheMode.BYPASS), - hooks={"on_page_context_created": my_hook}, # Pass function directly! - hooks_timeout=30 - ) - print(f"Crawl with hooks success: {result.success}") - # Example Get schema print("\n--- Getting Schema ---") schema = await client.get_schema() @@ -1730,8 +1246,6 @@ The Docker client supports the following parameters: - `urls` (List[str]): List of URLs to crawl - `browser_config` (Optional[BrowserConfig]): Browser configuration - `crawler_config` (Optional[CrawlerRunConfig]): Crawler configuration -- `hooks` (Optional[Dict]): Hook functions or strings - **automatically converts function objects!** -- `hooks_timeout` (int): Timeout for each hook execution in seconds (default: 30) **Returns**: - Single URL: `CrawlResult` object @@ -1978,9 +1492,16 @@ One of the key advantages of self-hosting is complete visibility into your infra Access the **built-in real-time monitoring dashboard** for complete operational visibility: ``` -http://localhost:11235/monitor +http://localhost:11235/dashboard ``` +> ⚠️ The dashboard UI lives at `/dashboard`. `/monitor` is the API namespace +> (`/monitor/health`, `/monitor/ws`, …); older docs pointed there, so that exact +> URL now redirects to `/dashboard` for convenience — the `/monitor/*` routes +> themselves still require a token. On the dashboard, paste +> your API token into the **API token** bar (top right) and click **Set**; the +> WebSocket then connects and live stats appear. + ![Monitoring Dashboard](https://via.placeholder.com/800x400?text=Crawl4AI+Monitoring+Dashboard) **Dashboard Features:** @@ -2425,7 +1946,7 @@ Returns: ```json { "status": "healthy", - "version": "0.7.4" + "version": "0.9.2" } ``` @@ -2622,14 +2143,14 @@ By self-hosting Crawl4AI, you: **Next Steps:** 1. **Start Simple**: Deploy with Docker Hub image and test with the playground -2. **Monitor Everything**: Open `http://localhost:11235/monitor` to watch your server +2. **Monitor Everything**: Open `http://localhost:11235/dashboard` to watch your server 3. **Integrate**: Connect your applications using the Python SDK or REST API 4. **Scale Smart**: Use the monitoring data to optimize your deployment 5. **Go Production**: Set up alerting, log aggregation, and automated cleanup **Key Resources:** - 🎮 **Playground**: `http://localhost:11235/playground` - Interactive testing -- 📊 **Monitor Dashboard**: `http://localhost:11235/monitor` - Real-time visibility +- 📊 **Monitor Dashboard**: `http://localhost:11235/dashboard` - Real-time visibility - 📖 **Architecture Docs**: `deploy/docker/ARCHITECTURE.md` - Deep technical dive - 💬 **Discord Community**: Get help and share experiences - ⭐ **GitHub**: Report issues, contribute, show support diff --git a/tests/docker/test_server_requests.py b/tests/docker/test_server_requests.py index ae838c058..cb7bcbe4f 100644 --- a/tests/docker/test_server_requests.py +++ b/tests/docker/test_server_requests.py @@ -683,9 +683,9 @@ async def test_invalid_url_handling(self, async_client: httpx.AsyncClient): # Should return 200 with failed results, not 500 print(f"Status code: {response.status_code}") print(f"Response: {response.text}") - assert response.status_code == 500 + assert response.status_code == 200 data = response.json() - assert data["detail"].startswith("Crawl request failed:") + assert all(not result["success"] for result in data["results"]) async def test_mixed_success_failure_urls(self, async_client: httpx.AsyncClient): """Test handling of mixed success/failure URLs.""" @@ -887,4 +887,4 @@ async def test_malformed_request_handling(self, async_client: httpx.AsyncClient) # Execute pytest exit_code = pytest.main(pytest_args) - print(f"Pytest finished with exit code: {exit_code}") \ No newline at end of file + print(f"Pytest finished with exit code: {exit_code}") diff --git a/tests/regression/test_reg_browser.py b/tests/regression/test_reg_browser.py index ba901178b..dac55a841 100644 --- a/tests/regression/test_reg_browser.py +++ b/tests/regression/test_reg_browser.py @@ -7,6 +7,7 @@ real browser crawling with no mocking. """ +import asyncio import base64 import time @@ -37,6 +38,87 @@ async def test_browser_lifecycle(local_server): await crawler.close() +@pytest.mark.asyncio +async def test_failed_browser_launch_leaves_no_driver(monkeypatch): + """Issue #2155: a browser-launch failure inside __aenter__ must roll back + partial startup — no leaked Playwright driver process, no half-built state. + + Launch failure is forced by pointing PLAYWRIGHT_BROWSERS_PATH at an empty + directory, so the driver starts but the browser executable is missing. + """ + import tempfile + + import psutil + + from crawl4ai.browser_manager import BrowserManager + + monkeypatch.setenv( + "PLAYWRIGHT_BROWSERS_PATH", tempfile.mkdtemp(prefix="c4ai-no-browsers-") + ) + before = {p.pid for p in psutil.Process().children(recursive=True)} + + manager = BrowserManager(browser_config=BrowserConfig(headless=True)) + with pytest.raises(Exception, match="Executable doesn't exist"): + await manager.start() + + # Half-built state must be rolled back (this also proves the driver was + # stopped, since only close()/stop() reset it). + assert manager.playwright is None, "playwright driver not rolled back" + assert manager.browser is None, "browser reference not rolled back" + + # No Playwright driver child process may survive the failed start. + await asyncio.sleep(0.5) # allow the stopped subprocess to be reaped + leaked = [] + for child in psutil.Process().children(recursive=True): + if child.pid in before: + continue + try: + cmd = " ".join(child.cmdline()) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + if "playwright" in cmd and "run-driver" in cmd: + leaked.append(child.pid) + assert leaked == [], f"leaked Playwright driver process(es): {leaked}" + + +@pytest.mark.asyncio +async def test_failed_cached_cdp_connect_leaves_no_driver(): + """Issue #2155: a failed connect inside _CDPConnectionCache.acquire() must + stop the driver it started (the manager never sees it, so start()'s + rollback alone cannot clean it up).""" + import psutil + + from crawl4ai.browser_manager import BrowserManager + + before = {p.pid for p in psutil.Process().children(recursive=True)} + config = BrowserConfig( + headless=True, + cdp_url="http://127.0.0.1:9", # unreachable endpoint + cache_cdp_connection=True, + ) + manager = BrowserManager(browser_config=config) + # Matching on connect_over_cdp proves the driver was already started + # when the failure happened (the scenario under test). + with pytest.raises(Exception, match="connect_over_cdp"): + await manager.start() + + assert manager.playwright is None + assert manager.browser is None + + await asyncio.sleep(0.5) + leaked = [] + for child in psutil.Process().children(recursive=True): + if child.pid in before: + continue + try: + cmd = " ".join(child.cmdline()) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + if "playwright" in cmd and "run-driver" in cmd: + leaked.append(child.pid) + assert leaked == [], f"leaked Playwright driver process(es): {leaked}" + + @pytest.mark.asyncio async def test_browser_context_manager(local_server): """Verify async with pattern works and cleanup happens without error.""" @@ -289,6 +371,26 @@ async def test_remove_overlay_elements(local_server): assert len(result.html) > 0, "HTML should still be present after overlay removal" +@pytest.mark.asyncio +@pytest.mark.network +async def test_overlay_removal_on_csp_sandbox_page(): + """raw.githubusercontent.com serves CSP `sandbox`, which disables page + timers; overlay/consent removal must not stall waiting on in-page + setTimeout (used to hang ~30s per page). URL is commit-pinned so the + response never changes.""" + url = "https://raw.githubusercontent.com/unclecode/crawl4ai/055e2ecdc702228a80363e2c51ca79e473222072/README.md" + config = CrawlerRunConfig( + remove_overlay_elements=True, remove_consent_popups=True, verbose=False + ) + async with AsyncWebCrawler(config=BrowserConfig(headless=True, verbose=False)) as crawler: + start = time.perf_counter() + result = await crawler.arun(url=url, config=config) + elapsed = time.perf_counter() - start + assert result.success, f"Crawl failed on CSP-sandboxed page: {result.error_message}" + assert "Crawl4AI" in result.html, "Page content should be captured" + assert elapsed < 20, f"Overlay removal stalled on CSP-sandboxed page ({elapsed:.1f}s)" + + # --------------------------------------------------------------------------- # Stealth mode # --------------------------------------------------------------------------- diff --git a/tests/test_body_visibility_warning.py b/tests/test_body_visibility_warning.py new file mode 100644 index 000000000..7f967665d --- /dev/null +++ b/tests/test_body_visibility_warning.py @@ -0,0 +1,128 @@ +"""The body-visibility wait must not time out silently (issue #2144). + +When the wait times out and `ignore_body_visibility` is True (the default), the +result is discarded and the crawl succeeds — just `body_visibility_timeout` ms +slower, with no signal at all. That silence is what made the delay in #2129 +impossible to attribute without instrumenting the pipeline, and what makes +`body_visibility_timeout` undiscoverable. A warning names the option. +""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from crawl4ai.async_configs import CrawlerRunConfig +from crawl4ai.async_crawler_strategy import AsyncPlaywrightCrawlerStrategy + + +def _strategy(is_visible: bool, wait_seconds: float = 0.0): + """A strategy whose body-visibility wait returns `is_visible` after + `wait_seconds` of (real) waiting.""" + page = MagicMock() + page.evaluate = AsyncMock() + page.set_content = AsyncMock() + page.wait_for_selector = AsyncMock() + page.content = AsyncMock(return_value="hi") + + strategy = AsyncPlaywrightCrawlerStrategy.__new__(AsyncPlaywrightCrawlerStrategy) + strategy.browser_config = SimpleNamespace( + use_persistent_context=False, accept_downloads=False, text_mode=True, verbose=False + ) + strategy.browser_manager = SimpleNamespace( + get_page=AsyncMock(return_value=(page, MagicMock())) + ) + strategy.execute_hook = AsyncMock() + + async def wait(*args, **kwargs): + await asyncio.sleep(wait_seconds) + return is_visible + + strategy.csp_compliant_wait = AsyncMock(side_effect=wait) + strategy.check_visibility = AsyncMock(return_value={}) + strategy.logger = MagicMock() + return strategy + + +def _visibility_warnings(strategy): + """The body-visibility warning calls, selected by message rather than by + position — other warnings may fire on the same crawl.""" + return [ + call + for call in strategy.logger.warning.call_args_list + if "never became visible" in call.kwargs.get("message", "") + ] + + +@pytest.mark.asyncio +async def test_warns_when_wait_times_out_and_result_is_discarded(): + strategy = _strategy(is_visible=False, wait_seconds=0.1) + config = CrawlerRunConfig(session_id="body-vis-warn", body_visibility_timeout=100) + + await strategy._crawl_web("raw:hi", config) + + calls = _visibility_warnings(strategy) + assert len(calls) == 1, strategy.logger.warning.call_args_list + assert "body_visibility_timeout" in calls[0].kwargs["message"] + # The time actually spent is reported, so the number matches the delay the + # user is trying to explain. + assert calls[0].kwargs["params"]["elapsed"] >= 90 + + +@pytest.mark.asyncio +async def test_warning_is_not_suppressed_by_verbose_off(): + """Servers and batch jobs run with verbose off — the case that most needs + this warning. Without force_verbose the logger drops it (#2144).""" + strategy = _strategy(is_visible=False, wait_seconds=0.1) + config = CrawlerRunConfig( + session_id="body-vis-quiet-logger", body_visibility_timeout=100, verbose=False + ) + + await strategy._crawl_web("raw:hi", config) + + calls = _visibility_warnings(strategy) + assert len(calls) == 1 + assert calls[0].kwargs.get("force_verbose") is True + + +@pytest.mark.asyncio +async def test_no_warning_when_wait_fails_early(): + """csp_compliant_wait also returns False when the evaluation errors out + (page closed, context destroyed) — that costs no time, so blaming + body_visibility_timeout for it would send the user after the wrong knob.""" + strategy = _strategy(is_visible=False, wait_seconds=0.0) + config = CrawlerRunConfig( + session_id="body-vis-early-fail", body_visibility_timeout=30000 + ) + + await strategy._crawl_web("raw:hi", config) + + assert _visibility_warnings(strategy) == [] + + +@pytest.mark.asyncio +async def test_no_warning_when_body_is_visible(): + strategy = _strategy(is_visible=True) + config = CrawlerRunConfig(session_id="body-vis-quiet") + + await strategy._crawl_web("raw:hi", config) + + assert _visibility_warnings(strategy) == [] + + +@pytest.mark.asyncio +async def test_no_warning_when_hidden_body_is_treated_as_an_error(): + """With ignore_body_visibility=False the hidden body raises with details, + so the warning would be redundant noise.""" + from playwright.async_api import Error + + strategy = _strategy(is_visible=False) + config = CrawlerRunConfig( + session_id="body-vis-strict", ignore_body_visibility=False + ) + + with pytest.raises(Error, match="Body element is hidden"): + await strategy._crawl_web("raw:hi", config) + + assert _visibility_warnings(strategy) == [] diff --git a/tests/test_config_defaults.py b/tests/test_config_defaults.py index 700886aa9..3a7fba641 100644 --- a/tests/test_config_defaults.py +++ b/tests/test_config_defaults.py @@ -1,7 +1,12 @@ """Tests for BrowserConfig.set_defaults / CrawlerRunConfig.set_defaults.""" +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + import pytest + from crawl4ai.async_configs import BrowserConfig, CrawlerRunConfig +from crawl4ai.async_crawler_strategy import AsyncPlaywrightCrawlerStrategy @pytest.fixture(autouse=True) @@ -226,13 +231,57 @@ def test_dump_load_survives_reset(self): assert loaded.headless is False def test_crawler_run_config_dump_load(self): - CrawlerRunConfig.set_defaults(verbose=False, scan_full_page=True) + assert CrawlerRunConfig().body_visibility_timeout == 30000 + CrawlerRunConfig.set_defaults( + verbose=False, scan_full_page=True, body_visibility_timeout=2000 + ) cfg = CrawlerRunConfig() data = cfg.dump() CrawlerRunConfig.reset_defaults() loaded = CrawlerRunConfig.load(data) assert loaded.verbose is False assert loaded.scan_full_page is True + assert loaded.body_visibility_timeout == 2000 + + @pytest.mark.parametrize("timeout", [None, 0, -1, "1000", True]) + def test_body_visibility_timeout_must_be_positive_number(self, timeout): + with pytest.raises(ValueError, match="must be a positive number"): + CrawlerRunConfig(body_visibility_timeout=timeout) + + def test_untrusted_body_visibility_timeout_is_clamped(self): + from crawl4ai.async_configs import Provenance + + config = CrawlerRunConfig.load( + {"body_visibility_timeout": 500_000}, provenance=Provenance.UNTRUSTED + ) + assert config.body_visibility_timeout == 60_000 + + @pytest.mark.asyncio + async def test_body_visibility_timeout_reaches_wait(self): + page = MagicMock() + page.evaluate = AsyncMock() + page.set_content = AsyncMock() + page.wait_for_selector = AsyncMock() + page.content = AsyncMock(return_value="visible") + + strategy = AsyncPlaywrightCrawlerStrategy.__new__( + AsyncPlaywrightCrawlerStrategy + ) + strategy.browser_config = SimpleNamespace( + use_persistent_context=False, accept_downloads=False, text_mode=True + ) + strategy.browser_manager = SimpleNamespace( + get_page=AsyncMock(return_value=(page, MagicMock())) + ) + strategy.execute_hook = AsyncMock() + strategy.csp_compliant_wait = AsyncMock(return_value=True) + + config = CrawlerRunConfig( + session_id="body-timeout-test", body_visibility_timeout=1234 + ) + await strategy._crawl_web("raw:visible", config) + + assert strategy.csp_compliant_wait.await_args.kwargs["timeout"] == 1234 def test_to_dict_includes_user_default_values(self): BrowserConfig.set_defaults(headless=False) diff --git a/tests/test_docker_pdf_crawler_pairing.py b/tests/test_docker_pdf_crawler_pairing.py new file mode 100644 index 000000000..2749d6407 --- /dev/null +++ b/tests/test_docker_pdf_crawler_pairing.py @@ -0,0 +1,273 @@ +"""Tests for the Docker API's PDF crawler pairing. + +When a client requests PDFContentScrapingStrategy, the crawl handlers must +pair it with PDFCrawlerStrategy (which downloads the PDF itself) instead of a +pooled Playwright crawler — headless Chromium cannot render PDFs inline, so +browser navigation fails with "Page.goto: Download is starting" before the +scraping strategy ever runs. +""" + +import importlib +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from crawl4ai.processors.pdf import PDFContentScrapingStrategy, PDFCrawlerStrategy + +ROOT = Path(__file__).resolve().parent.parent + +CONFIG = { + "crawler": { + "memory_threshold_percent": 90, + "rate_limiter": {"enabled": False, "base_delay": [0.1, 0.3]}, + "base_config": {}, + } +} + + +def _crawler_config_payload(with_pdf_strategy): + params = {"cache_mode": "bypass"} + if with_pdf_strategy: + params["scraping_strategy"] = { + "type": "PDFContentScrapingStrategy", + "params": {}, + } + return {"type": "CrawlerRunConfig", "params": params} + + +@pytest.fixture +def api(monkeypatch): + monkeypatch.syspath_prepend(str(ROOT / "deploy" / "docker")) + return importlib.import_module("api") + + +@pytest.fixture +def pool_mock(api, monkeypatch): + crawler_pool = importlib.import_module("crawler_pool") + egress_broker = importlib.import_module("egress_broker") + governor = importlib.import_module("governor") + + pooled = MagicMock() + pooled.arun = AsyncMock(return_value=[]) + pooled.arun_many = AsyncMock(return_value=[]) # per-URL config list path + pooled.active_requests = 1 # release_crawler decrements this int + mock = AsyncMock(return_value=pooled) + monkeypatch.setattr(crawler_pool, "get_crawler", mock) + monkeypatch.setattr(api, "_normalize_and_validate_seeds", lambda urls: urls) + monkeypatch.setattr(egress_broker, "enforce_egress", lambda _: None) + monkeypatch.setattr(governor, "clamp_deep_crawl", lambda _: None) + return mock + + +@pytest.mark.asyncio +async def test_pdf_scraping_strategy_gets_pdf_crawler(api, pool_mock, monkeypatch): + used = {} + real_crawler_cls = api.AsyncWebCrawler + + def spy_crawler(*args, **kwargs): + crawler = real_crawler_cls(*args, **kwargs) + used["crawler"] = crawler + used["crawler_strategy"] = crawler.crawler_strategy + crawler.arun = AsyncMock(return_value=[]) + crawler.close = AsyncMock(wraps=crawler.close) + return crawler + + monkeypatch.setattr(api, "AsyncWebCrawler", spy_crawler) + + response = await api.handle_crawl_request( + urls=["https://example.com/document.pdf"], + browser_config={"type": "BrowserConfig", "params": {}}, + crawler_config=_crawler_config_payload(with_pdf_strategy=True), + config=CONFIG, + ) + + assert response["success"] is True + assert isinstance(used["crawler_strategy"], PDFCrawlerStrategy) + pool_mock.assert_not_awaited() + # The dedicated PDF crawler is not pooled, so the handler must close it. + used["crawler"].close.assert_awaited_once() + # SSRF protection: the handler must wire the server's URL validator into + # the scraping strategy so every download/redirect hop is vetted. + effective_config = used["crawler"].arun.await_args.kwargs["config"] + assert effective_config.scraping_strategy.url_validator is api.validate_url_destination + + +@pytest.mark.asyncio +async def test_pdf_strategy_with_hooks_rejected(api, pool_mock): + from fastapi import HTTPException + + # A VALID hook action: without the guard this reaches set_hook and blows up + # with AttributeError (PDFCrawlerStrategy has no set_hook) -> 500. An invalid + # action would 400 via HookValidationError even without the guard, proving nothing. + hooks = {"hooks": [{"action": "block_resources", "params": {"resource_types": ["image"]}}]} + with pytest.raises(HTTPException) as exc_info: + await api.handle_crawl_request( + urls=["https://example.com/document.pdf"], + browser_config={"type": "BrowserConfig", "params": {}}, + crawler_config=_crawler_config_payload(with_pdf_strategy=True), + config=CONFIG, + hooks_config=hooks, + ) + + assert exc_info.value.status_code == 400 + assert "PDFContentScrapingStrategy" in exc_info.value.detail + pool_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_default_strategy_still_uses_pool(api, pool_mock): + response = await api.handle_crawl_request( + urls=["https://example.com/"], + browser_config={"type": "BrowserConfig", "params": {}}, + crawler_config=_crawler_config_payload(with_pdf_strategy=False), + config=CONFIG, + ) + + assert response["success"] is True + pool_mock.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_per_url_pdf_strategy_gets_validator(api, pool_mock): + """SSRF: a PDF strategy sent per-URL via crawler_configs must be vetted too. + + crawler_configs is a public per-URL field on /crawl, and the handler builds + that config list on a separate branch from the top-level config. Wiring + url_validator only on the top-level branch leaves the per-URL one doing an + unvalidated download of whatever the request names. + """ + pdf_config = _crawler_config_payload(with_pdf_strategy=True) + plain_config = _crawler_config_payload(with_pdf_strategy=False) + + response = await api.handle_crawl_request( + # The config list branch only engages with more than one URL. + urls=["https://example.com/document.pdf", "https://example.com/page.html"], + browser_config={"type": "BrowserConfig", "params": {}}, + crawler_config=plain_config, # top-level is clean; the PDF rides per-URL + crawler_configs=[pdf_config, plain_config], + config=CONFIG, + ) + + assert response["success"] is True + configs = pool_mock.return_value.arun_many.await_args.kwargs["config"] + pdf_strategies = [ + cfg.scraping_strategy for cfg in configs + if isinstance(cfg.scraping_strategy, PDFContentScrapingStrategy) + ] + # Guard the guard: if deserialization ever drops the strategy, the loop + # below would pass vacuously and the test would protect nothing. + assert len(pdf_strategies) == 1 + for strategy in pdf_strategies: + assert strategy.url_validator is api.validate_url_destination + + +# --------------------------------------------------------------------------- +# PDF download redirect handling (url_validator SSRF guard) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def redirect_server(): + """Real HTTP server: /hop redirects to /doc.pdf, which serves a tiny PDF.""" + import http.server + import socket + import threading + + pdf_bytes = (b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n" + b"xref\n0 3\n0000000000 65535 f \n0000000009 00000 n \n" + b"0000000058 00000 n \ntrailer\n<< /Size 3 /Root 1 0 R >>\n" + b"startxref\n110\n%%EOF\n") + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/hop": + self.send_response(302) + self.send_header("Location", "/doc.pdf") + self.end_headers() + elif self.path == "/loop": + self.send_response(302) + self.send_header("Location", "/loop") + self.end_headers() + elif self.path == "/gated": + # Sets a cookie, then redirects to a target that demands it. + self.send_response(302) + self.send_header("Set-Cookie", "sess=abc123; Path=/") + self.send_header("Location", "/gated-doc.pdf") + self.end_headers() + elif self.path == "/gated-doc.pdf": + if "sess=abc123" not in self.headers.get("Cookie", ""): + self.send_response(403) + self.end_headers() + return + self.send_response(200) + self.send_header("Content-Type", "application/pdf") + self.end_headers() + self.wfile.write(pdf_bytes) + else: + self.send_response(200) + self.send_header("Content-Type", "application/pdf") + self.end_headers() + self.wfile.write(pdf_bytes) + + def log_message(self, *args): + pass + + with socket.socket() as s: + s.bind(("localhost", 0)) + port = s.getsockname()[1] + httpd = http.server.ThreadingHTTPServer(("localhost", port), Handler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + yield f"http://localhost:{port}" + httpd.shutdown() + + +def test_download_validator_vets_every_redirect_hop(redirect_server): + """The validator must see BOTH the original URL and the redirect target, + and a raising validator must abort the download before the hop is fetched.""" + seen = [] + + def validator(u): + seen.append(u) + if u.endswith("/doc.pdf"): + raise ValueError("blocked hop") + + strategy = PDFContentScrapingStrategy(url_validator=validator) + with pytest.raises(RuntimeError, match="Failed to download"): + strategy._get_pdf_path(f"{redirect_server}/hop") + + assert seen == [f"{redirect_server}/hop", f"{redirect_server}/doc.pdf"] + + +def test_download_redirects_still_followed_without_validator(redirect_server): + """Back-compat: with no validator, redirects are followed as before.""" + strategy = PDFContentScrapingStrategy() + path = strategy._get_pdf_path(f"{redirect_server}/hop") + try: + assert Path(path).read_bytes().startswith(b"%PDF") + finally: + Path(path).unlink(missing_ok=True) + + +def test_download_carries_cookies_across_redirect_hops(redirect_server): + """Cookies set by a redirecting host must reach the next hop. + + Following redirects by hand (needed so url_validator can vet each hop) + means a bare requests.get() per hop starts with an empty cookie jar, so + a host that sets a cookie and then redirects never gets it back. That is + the normal shape for gated or CDN-signed PDFs, and allow_redirects=True + used to handle it implicitly. + """ + strategy = PDFContentScrapingStrategy() + path = strategy._get_pdf_path(f"{redirect_server}/gated") + try: + assert Path(path).read_bytes().startswith(b"%PDF") + finally: + Path(path).unlink(missing_ok=True) + + +def test_download_redirect_loop_aborts(redirect_server): + """An endless redirect chain must abort after the cap, not hang.""" + strategy = PDFContentScrapingStrategy() + with pytest.raises(RuntimeError, match="[Tt]oo many redirects"): + strategy._get_pdf_path(f"{redirect_server}/loop") diff --git a/tests/test_issue_2127_docker_pdf.py b/tests/test_issue_2127_docker_pdf.py new file mode 100644 index 000000000..952f1daa2 --- /dev/null +++ b/tests/test_issue_2127_docker_pdf.py @@ -0,0 +1,71 @@ +import importlib +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +from packaging.requirements import InvalidRequirement, Requirement + +from crawl4ai.processors.pdf import PDFContentScrapingStrategy + +ROOT = Path(__file__).resolve().parent.parent + + +def test_default_docker_dependencies_include_pypdf(): + lines = (ROOT / "deploy" / "docker" / "requirements.txt").read_text().splitlines() + names = set() + for line in lines: + line = line.strip() + if not line or line.startswith(("#", "-")): + continue + try: + names.add(Requirement(line).name) + except InvalidRequirement: + continue + + assert "pypdf" in names + + +@pytest.mark.asyncio +async def test_stream_handler_preserves_requested_scraping_strategy(monkeypatch): + docker_dir = ROOT / "deploy" / "docker" + monkeypatch.syspath_prepend(str(docker_dir)) + + api = importlib.import_module("api") + egress_broker = importlib.import_module("egress_broker") + governor = importlib.import_module("governor") + + crawler = MagicMock() + crawler.arun_many = AsyncMock(return_value=MagicMock()) + crawler.start = AsyncMock() + monkeypatch.setattr(api, "_normalize_and_validate_seeds", lambda urls: urls) + monkeypatch.setattr(egress_broker, "enforce_egress", lambda _: None) + monkeypatch.setattr(governor, "clamp_deep_crawl", lambda _: None) + monkeypatch.setattr(api, "AsyncWebCrawler", MagicMock(return_value=crawler)) + + crawler_config = { + "type": "CrawlerRunConfig", + "params": { + "cache_mode": "bypass", + "stream": False, + "scraping_strategy": { + "type": "PDFContentScrapingStrategy", + "params": {"extract_images": False, "batch_size": 8}, + }, + }, + } + config = { + "crawler": { + "memory_threshold_percent": 90, + "rate_limiter": {"base_delay": [0.1, 0.3]}, + } + } + + await api.handle_stream_crawl_request( + urls=["https://example.com/document.pdf"], + browser_config={"type": "BrowserConfig", "params": {}}, + crawler_config=crawler_config, + config=config, + ) + + effective_config = crawler.arun_many.await_args.kwargs["config"] + assert isinstance(effective_config.scraping_strategy, PDFContentScrapingStrategy) diff --git a/tests/test_issue_2161_overlay_html_body.py b/tests/test_issue_2161_overlay_html_body.py new file mode 100644 index 000000000..7855a8e87 --- /dev/null +++ b/tests/test_issue_2161_overlay_html_body.py @@ -0,0 +1,81 @@ +"""Tests for issue #2161: remove_overlay_elements can remove body if it has a global popup class. + +https://github.com/unclecode/crawl4ai/issues/2161 + +WordPress themes such as Qode/Bridge put classes like +`.qode_popup_menu_push_text_right` on . The overlay snippet matches +`[class*="popup"]` and used to delete the entire document, leaving an empty page. +""" + +import pytest + +from crawl4ai import AsyncWebCrawler, CrawlerRunConfig + + +QODE_BODY_HTML = """\ + + +Qode popup body class + +
+

Tunnel Girona

+

Portfolio page content that must survive overlay removal.

+
+ + + +""" + +FIXED_BODY_HTML = """\ + + +Fixed body scroll lock + +
+

Locked body

+

Body is position:fixed while a modal is open; it must not be removed.

+
+ + + +""" + + +@pytest.mark.asyncio +async def test_overlay_removal_keeps_body_with_popup_in_class(): + """#2161: Body/html with a class substring 'popup' must remain after overlay removal.""" + async with AsyncWebCrawler() as crawler: + result = await crawler.arun( + f"raw:{QODE_BODY_HTML}", + config=CrawlerRunConfig(remove_overlay_elements=True, verbose=False), + ) + + assert result.success, result.error_message + html = result.html.lower() + assert " bytes: + """Build a tiny single-page PDF containing `text`, valid for pypdf.""" + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]" + b" /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + None, # content stream, filled below + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ] + stream = f"BT /F1 18 Tf 72 720 Td ({text}) Tj ET".encode() + objects[3] = ( + b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + + stream + b"\nendstream" + ) + out = bytearray(b"%PDF-1.4\n") + offsets = [] + for i, body in enumerate(objects, start=1): + offsets.append(len(out)) + out += f"{i} 0 obj\n".encode() + body + b"\nendobj\n" + xref_pos = len(out) + out += f"xref\n0 {len(objects) + 1}\n".encode() + out += b"0000000000 65535 f \n" + for off in offsets: + out += f"{off:010d} 00000 n \n".encode() + out += b"trailer\n<< /Size " + str(len(objects) + 1).encode() + b" /Root 1 0 R >>\n" + out += b"startxref\n" + str(xref_pos).encode() + b"\n%%EOF\n" + return bytes(out) + + +@pytest.fixture +def pdf_file(tmp_path): + pytest.importorskip("pypdf", reason="requires the crawl4ai[pdf] extra") + path = tmp_path / "fixture.pdf" + path.write_bytes(_build_minimal_pdf()) + return path + + +def _run_pdf_crawl(pdf_file, **config_kwargs): + """Crawl the fixture PDF with the documented strategy pairing.""" + from crawl4ai.processors.pdf import ( + PDFContentScrapingStrategy, + PDFCrawlerStrategy, + ) + + async def run(): + config = CrawlerRunConfig( + cache_mode=CacheMode.BYPASS, + scraping_strategy=PDFContentScrapingStrategy(extract_images=False), + **config_kwargs, + ) + async with AsyncWebCrawler(crawler_strategy=PDFCrawlerStrategy()) as crawler: + return await crawler.arun(f"file://{pdf_file}", config=config) + + return asyncio.run(run()) + + +def test_placeholder_html_defaults_false(): + """No strategy opts in implicitly — every existing strategy is unaffected.""" + response = AsyncCrawlResponse( + html="", response_headers={}, status_code=200 + ) + assert response.placeholder_html is False + + +def test_is_blocked_still_flags_near_empty_html(): + """The anti-bot heuristic itself is unchanged; only the PDF path opts out.""" + blocked, reason = is_blocked(200, "Scraper will handle the real work") + assert blocked + assert "Near-empty content" in reason + + +def test_pdf_pairing_is_not_vetoed_by_antibot(pdf_file): + """The documented PDFCrawlerStrategy + PDFContentScrapingStrategy pairing + (docs/md_v2/advanced/pdf-parsing.md) must report success, not an anti-bot + block, when extraction succeeds.""" + result = _run_pdf_crawl(pdf_file) + + assert result.success, f"crawl failed: {result.error_message}" + assert "anti-bot" not in (result.error_message or "") + markdown = ( + result.markdown.raw_markdown + if hasattr(result.markdown, "raw_markdown") + else result.markdown + ) + assert PDF_TEXT in (markdown or "") + + +def test_pdf_crawl_does_not_burn_retries_or_fallback(pdf_file): + """The attempt loop must not classify a placeholder response as blocked. + + Pre-fix, the phantom "blocked" verdict exhausted every retry attempt and + then invoked the fallback fetch, all on a crawl that had already + succeeded. With max_retries=2 a pre-fix run burns all 3 attempts and + calls the fallback; post-fix the first attempt resolves directly. + """ + fallback_calls = [] + + async def fake_fallback(url): + fallback_calls.append(url) + return "should not be used" + + result = _run_pdf_crawl( + pdf_file, max_retries=2, fallback_fetch_function=fake_fallback + ) + + stats = result.crawl_stats or {} + assert stats.get("resolved_by") == "direct" + assert stats.get("attempts") == 1 + assert all(not p.get("blocked") for p in stats.get("proxies_used", [])) + assert not fallback_calls + assert stats.get("fallback_fetch_used") is False diff --git a/tests/unit/test_pdf_download_limits.py b/tests/unit/test_pdf_download_limits.py new file mode 100644 index 000000000..993107354 --- /dev/null +++ b/tests/unit/test_pdf_download_limits.py @@ -0,0 +1,409 @@ +"""Regression tests for the PDF fetch path: destination validation and resource caps. + +Covers two reported issues: + - a public URL that redirects to an internal address must fail closed (SSRF) + - an oversized remote PDF must abort instead of filling the disk (DoS) + +The fetch helper takes the `requests` module as an argument, so these drive it +with a stub and need no network or live server. +""" + +import os + +import pytest + +from crawl4ai.processors.pdf import ( + PDFContentScrapingStrategy, + set_peer_ip_validator, + set_url_validator, +) +import crawl4ai.processors.pdf as pdf_module + + +class Blocked(Exception): + """Stand-in for egress_broker.EgressBlocked.""" + + +class FakeResponse: + def __init__(self, status_code=200, headers=None, chunks=(), peer_ip="93.184.216.34"): + self.status_code = status_code + self.headers = headers or {} + self._chunks = list(chunks) + self.closed = False + self.raw = self._make_raw(peer_ip) + + def _make_raw(self, peer_ip): + """Mirror urllib3 2.x layout: raw._fp.fp.raw._sock. + + Worth keeping faithful -- an earlier version of this stub exposed the + socket somewhere urllib3 does not, and hid a real bug. + """ + if peer_ip is None: + return object() # bodyless response: socket already released + + class _Sock: + def getpeername(self_inner): + return (peer_ip, 443) + + class _SocketIO: + _sock = _Sock() + + class _Buffered: + raw = _SocketIO() + + class _HTTPResponse: + fp = _Buffered() + + class _Raw: + _fp = _HTTPResponse() + + return _Raw() + + def iter_content(self, chunk_size=8192): + for chunk in self._chunks: + yield chunk + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + def close(self): + self.closed = True + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + return False + + +class FakeRequests: + """Stub of the `requests` module surface the downloader uses.""" + + class exceptions: + class Timeout(Exception): + pass + + def __init__(self, responses): + self._responses = list(responses) + self.calls = [] + + def get(self, url, **kwargs): + self.calls.append((url, kwargs)) + return self._responses.pop(0) + + def Session(self): + # The downloader opens one Session for the whole redirect chain, so a + # host that sets a cookie and then redirects gets it back on the next + # hop. Returning self keeps every call recorded in one place. + return self + + +@pytest.fixture(autouse=True) +def _reset_validators(): + """Validators are module-level globals; leaking one breaks later tests.""" + yield + set_url_validator(None) + set_peer_ip_validator(None) + + +def _strategy(**kwargs): + return PDFContentScrapingStrategy(**kwargs) + + +# ── destination validation (SSRF) ────────────────────────────────────── + + +def test_redirect_target_is_validated_and_blocked(): + seen = [] + + def validator(url): + seen.append(url) + if "169.254.169.254" in url or "127.0.0.1" in url: + raise Blocked(url) + + set_url_validator(validator) + strategy = _strategy() + requests = FakeRequests([ + FakeResponse(302, {"location": "http://169.254.169.254/latest/meta-data/"}), + ]) + + with pytest.raises(Blocked): + strategy._fetch_with_redirect_checks(requests, "https://public.example/doc.pdf") + + assert seen == [ + "https://public.example/doc.pdf", + "http://169.254.169.254/latest/meta-data/", + ], "validator must run on the redirect target, not just the seed" + + +def test_redirects_are_never_followed_by_requests_itself(): + set_url_validator(lambda url: None) + strategy = _strategy() + requests = FakeRequests([ + FakeResponse(302, {"location": "https://public.example/real.pdf"}), + FakeResponse(200, {"content-length": "4"}, [b"%PDF"]), + ]) + + strategy._fetch_with_redirect_checks(requests, "https://public.example/doc.pdf") + + assert [kw["allow_redirects"] for _, kw in requests.calls] == [False, False] + + +def test_relative_location_is_resolved_before_validation(): + seen = [] + set_url_validator(seen.append) + strategy = _strategy() + requests = FakeRequests([ + FakeResponse(302, {"location": "/elsewhere.pdf"}), + FakeResponse(200, {}, [b"%PDF"]), + ]) + + strategy._fetch_with_redirect_checks(requests, "https://public.example/a/doc.pdf") + + assert seen[1] == "https://public.example/elsewhere.pdf" + + +def test_redirect_chain_is_bounded(): + set_url_validator(lambda url: None) + strategy = _strategy(max_redirects=2) + requests = FakeRequests([ + FakeResponse(302, {"location": f"https://public.example/{i}.pdf"}) + for i in range(5) + ]) + + with pytest.raises(RuntimeError, match="Too many redirects"): + strategy._fetch_with_redirect_checks(requests, "https://public.example/doc.pdf") + + +def test_redirect_without_location_is_rejected(): + set_url_validator(lambda url: None) + strategy = _strategy() + requests = FakeRequests([FakeResponse(302, {})]) + + with pytest.raises(RuntimeError, match="Redirect without Location"): + strategy._fetch_with_redirect_checks(requests, "https://public.example/doc.pdf") + + +def test_peer_ip_is_checked_closing_dns_rebinding(): + """The name may pass validation and still resolve inward when requests dials.""" + set_url_validator(lambda url: None) + + def peer_validator(ip): + if ip.startswith("127.") or ip.startswith("169.254."): + raise Blocked(ip) + + set_peer_ip_validator(peer_validator) + strategy = _strategy() + response = FakeResponse(200, {}, [b"%PDF"], peer_ip="169.254.169.254") + requests = FakeRequests([response]) + + with pytest.raises(Blocked): + strategy._fetch_with_redirect_checks(requests, "https://public.example/doc.pdf") + assert response.closed, "blocked response must be closed, not leaked" + + +def test_unverifiable_peer_fails_closed_on_the_body_response(): + set_url_validator(lambda url: None) + set_peer_ip_validator(lambda ip: None) + strategy = _strategy() + response = FakeResponse(200, {}, [b"%PDF"], peer_ip=None) # no reachable socket + requests = FakeRequests([response]) + + with pytest.raises(RuntimeError, match="Could not determine peer address"): + strategy._fetch_with_redirect_checks(requests, "https://public.example/doc.pdf") + + +def test_redirect_hop_without_observable_socket_is_not_fatal(): + """urllib3 releases the socket on a bodyless 302; that must not break redirects.""" + set_url_validator(lambda url: None) + set_peer_ip_validator(lambda ip: None) + strategy = _strategy() + requests = FakeRequests([ + FakeResponse(302, {"location": "https://public.example/real.pdf"}, peer_ip=None), + FakeResponse(200, {}, [b"%PDF"]), + ]) + + response = strategy._fetch_with_redirect_checks(requests, "https://public.example/doc.pdf") + + assert response.status_code == 200 + + +def test_no_validator_installed_leaves_plain_library_use_working(): + strategy = _strategy() + requests = FakeRequests([FakeResponse(200, {}, [b"%PDF"])]) + + response = strategy._fetch_with_redirect_checks(requests, "https://public.example/doc.pdf") + + assert response.status_code == 200 + + +# ── resource caps (DoS) ──────────────────────────────────────────────── + + +def test_download_aborts_past_max_bytes_and_cleans_up(monkeypatch): + strategy = _strategy(max_pdf_bytes=1024) + # Understated content-length must not be trusted: the running total decides. + requests = FakeRequests([ + FakeResponse(200, {"content-length": "10"}, [b"A" * 512] * 8), + ]) + monkeypatch.setattr(pdf_module, "requests", requests, raising=False) + + import sys + monkeypatch.setitem(sys.modules, "requests", requests) + + with pytest.raises(RuntimeError, match="exceeds max_pdf_bytes"): + strategy._get_pdf_path("https://public.example/huge.pdf") + + assert strategy._temp_files == [], "temp file must be dropped from tracking" + + +def test_download_under_cap_succeeds(monkeypatch): + import sys + + strategy = _strategy(max_pdf_bytes=1024) + requests = FakeRequests([FakeResponse(200, {"content-length": "4"}, [b"%PDF"])]) + monkeypatch.setitem(sys.modules, "requests", requests) + + path = strategy._get_pdf_path("https://public.example/small.pdf") + + try: + assert os.path.getsize(path) == 4 + finally: + strategy._discard_temp_file(path) + + +def test_page_limit_caps_page_count(): + from crawl4ai.processors.pdf.processor import NaivePDFProcessorStrategy + + processor = NaivePDFProcessorStrategy(max_pages=10) + assert processor._page_limit(5000) == 10 + assert processor._page_limit(3) == 3 + + +def test_page_limit_unbounded_by_default(): + from crawl4ai.processors.pdf.processor import NaivePDFProcessorStrategy + + processor = NaivePDFProcessorStrategy() + assert processor._page_limit(5000) == 5000 + + +# ── untrusted-body clamping ──────────────────────────────────────────── + + +@pytest.mark.parametrize( + "sent, expected_bytes, expected_pages", + [ + ({"max_pdf_bytes": 10**12, "max_pdf_pages": 10**6}, 100 * 1024 * 1024, 2000), + ({"max_pdf_bytes": 0, "max_pdf_pages": 0}, 100 * 1024 * 1024, 2000), + ({"max_pdf_bytes": -1, "max_pdf_pages": -1}, 100 * 1024 * 1024, 2000), + ({"max_pdf_bytes": "huge", "max_pdf_pages": None}, 100 * 1024 * 1024, 2000), + ({"max_pdf_bytes": 1024, "max_pdf_pages": 5}, 1024, 5), + ], +) +def test_untrusted_body_cannot_raise_its_own_caps(sent, expected_bytes, expected_pages): + """An untrusted client must not be able to restore unbounded behavior.""" + from crawl4ai.async_configs import _clamp_untrusted + + out = _clamp_untrusted("PDFContentScrapingStrategy", dict(sent)) + + assert out["max_pdf_bytes"] == expected_bytes + assert out["max_pdf_pages"] == expected_pages + + +# ── against real requests/urllib3 ─────────────────────────────────────── + + +@pytest.fixture +def live_server(): + """Loopback HTTP server serving a redirect and a small PDF body.""" + import http.server + import socketserver + import threading + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/redirect": + self.send_response(302) + self.send_header("Location", "/doc.pdf") + self.end_headers() + return + body = b"%PDF-1.4 " + b"x" * 4096 + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + server = socketserver.TCPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}" + finally: + server.shutdown() + server.server_close() + + +def test_real_redirect_chain_validates_every_hop_and_reads_peer(live_server): + """Exercises the actual urllib3 object graph, not a stub of it.""" + import requests as real_requests + + seen_urls, seen_ips = [], [] + set_url_validator(seen_urls.append) + set_peer_ip_validator(seen_ips.append) + strategy = _strategy() + + response = strategy._fetch_with_redirect_checks(real_requests, f"{live_server}/redirect") + response.close() + + assert seen_urls == [f"{live_server}/redirect", f"{live_server}/doc.pdf"] + assert seen_ips == ["127.0.0.1"], "peer must be read from the body response" + + +def test_real_internal_redirect_is_blocked(live_server): + """The reported attack shape: public seed -> 302 -> internal address.""" + import requests as real_requests + + def validator(url): + if "169.254.169.254" in url: + raise Blocked(url) + + set_url_validator(validator) + strategy = _strategy() + + # Point the seed at a redirect whose target is the metadata service. + requests = FakeRequests([ + FakeResponse(302, {"location": "http://169.254.169.254/latest/meta-data/"}), + ]) + with pytest.raises(Blocked): + strategy._fetch_with_redirect_checks(requests, f"{live_server}/redirect") + + # And the real client never followed anything on its own. + response = real_requests.get(f"{live_server}/redirect", allow_redirects=False) + assert response.status_code == 302 + response.close() + + +def test_real_oversize_body_aborts(live_server, monkeypatch): + import sys + + import requests as real_requests + + monkeypatch.setitem(sys.modules, "requests", real_requests) + strategy = _strategy(max_pdf_bytes=1024) # server sends ~4 KiB + + with pytest.raises(RuntimeError, match="exceeds max_pdf_bytes"): + strategy._get_pdf_path(f"{live_server}/doc.pdf") + + assert strategy._temp_files == [] + + +def test_untrusted_body_cannot_enable_image_extraction(): + from crawl4ai.async_configs import _clamp_untrusted + + out = _clamp_untrusted("PDFContentScrapingStrategy", {"extract_images": True}) + + assert out["extract_images"] is False diff --git a/tests/unit/test_pdf_html_escaping.py b/tests/unit/test_pdf_html_escaping.py new file mode 100644 index 000000000..3fba16a73 --- /dev/null +++ b/tests/unit/test_pdf_html_escaping.py @@ -0,0 +1,76 @@ +"""Regression test for GHSA-7g3g-vhm6-79f3. + +PDF paragraph text is attacker-controlled and flows verbatim into cleaned_html +(clean_pdf_text_to_html -> pdf_page.html -> cleaned_html -> crawl JSON). It must +be HTML-escaped so injected markup cannot execute when the result is rendered. + +The escaping was disabled at the paragraph sink; every other sink in the +function already escaped. These tests pin the paragraph sink specifically and +assert the advisory's acceptance criterion: < > " ' survive only as entities. +""" + +import pytest + +from crawl4ai.processors.pdf.utils import clean_pdf_text_to_html + + +# A leading short line becomes an

title (its own escaped path); the blank +# line then starts a fresh paragraph, which is the sink under test. +TITLE = "Quarterly Financial Report\n\n" + + +def _paragraph_html(body: str) -> str: + return clean_pdf_text_to_html(2, TITLE + body) + + +def test_event_handler_markup_does_not_survive_as_live_html(): + payload = "" + body = f"Attackers embed {payload} inside pdf paragraph body text that an operator later views in the playground here." + + html = _paragraph_html(body) + + assert "" + body = f"A crafted string {payload} placed in the paragraph flow of the document body for testing purposes here now." + + html = _paragraph_html(body) + + assert "", ">"), ('"', """), ("'", "'")], +) +def test_dangerous_chars_appear_only_as_entities(char, entity): + body = ( + f"Paragraph body text containing a raw {char} character that must be " + f"escaped before it reaches cleaned html output for safety reasons here." + ) + + html = _paragraph_html(body) + paragraph = html.split('
', 1)[1] + + assert entity in paragraph, f"{char!r} should be emitted as {entity}" + # The only literal angle brackets/quotes allowed in the paragraph region are + # the ones this code emits itself (

,

,
,
). The injected + # char must not appear raw inside the text. + text = paragraph.replace("

", "").replace("

", "") + text = text.replace('
', "").replace("

", "") + assert char not in text, f"raw {char!r} survived into paragraph text" + + +def test_benign_paragraph_text_is_unchanged(): + body = "An ordinary paragraph of report text with no special characters at all in it whatsoever today." + + html = _paragraph_html(body) + + assert "ordinary paragraph of report text" in html + assert "<" not in html and "&" not in html