chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:12:13 +08:00
commit 0446c45d8e
898 changed files with 328024 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# .dockerignore
*
# Allow specific files and directories when using local installation
!crawl4ai/
!docs/
!deploy/docker/
!setup.py
!pyproject.toml
!README.md
!LICENSE
!MANIFEST.in
!setup.cfg
!mkdocs.yml
.git/
__pycache__/
*.pyc
*.pyo
*.pyd
.DS_Store
.env
.venv
venv/
tests/
coverage.xml
*.log
*.swp
*.egg-info/
dist/
build/
+32
View File
@@ -0,0 +1,32 @@
# LLM Provider Keys
OPENAI_API_KEY=your_openai_key_here
DEEPSEEK_API_KEY=your_deepseek_key_here
ANTHROPIC_API_KEY=your_anthropic_key_here
GROQ_API_KEY=your_groq_key_here
TOGETHER_API_KEY=your_together_key_here
MISTRAL_API_KEY=your_mistral_key_here
GEMINI_API_TOKEN=your_gemini_key_here
# Optional: Override the default LLM provider
# Examples: "openai/gpt-4", "anthropic/claude-3-opus", "deepseek/chat", etc.
# If not set, uses the provider specified in config.yml (default: openai/gpt-4o-mini)
# LLM_PROVIDER=anthropic/claude-3-opus
# Optional: Global LLM temperature setting (0.0-2.0)
# Controls randomness in responses. Lower = more focused, Higher = more creative
# LLM_TEMPERATURE=0.7
# Optional: Global custom API base URL
# Use this to point to custom endpoints or proxy servers
# LLM_BASE_URL=https://api.custom.com/v1
# Optional: Provider-specific temperature overrides
# These take precedence over the global LLM_TEMPERATURE
# OPENAI_TEMPERATURE=0.5
# ANTHROPIC_TEMPERATURE=0.3
# GROQ_TEMPERATURE=0.8
# Optional: Provider-specific base URL overrides
# Use for provider-specific proxy endpoints
# OPENAI_BASE_URL=https://custom-openai.company.com/v1
# GROQ_BASE_URL=https://custom-groq.company.com/v1
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
# Migration Guide — Docker Server Hardening Release
This is a major, **secure-by-default** release of the Crawl4AI **Docker API
server** (`deploy/docker/`). Several defaults changed in breaking ways so the
out-of-the-box deployment is safe. The core pip library (SDK / in-process use)
is **unchanged** — these notes apply only to the self-hosted HTTP server.
How much you have to do scales with how much you drove through the API. A plain
"crawl these URLs with a normal config" user only does the two steps in
**Everyone**. The rest applies only if you used that specific feature.
> Upgrading from a self-hosted server? Read this first, then roll out behind a
> staging environment. See `SECURITY-VERIFY.md` for the deployment checklist.
---
## Everyone (2 steps)
### 1. Set an API token
The server no longer serves an unauthenticated API on `0.0.0.0`. It binds
loopback by default and will not expose itself without a credential.
```bash
export CRAWL4AI_API_TOKEN="$(openssl rand -hex 32)"
```
- With a token set, you may expose the server (put a TLS-terminating reverse
proxy in front) and must send `Authorization: Bearer <token>` 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.
WebSocket clients (MCP, monitor) that can't set headers may pass `?token=...`.
### 2. Re-issue any tokens
The JWT implementation changed; tokens issued by older versions are no longer
valid. Re-mint via `POST /token` (which now requires the server to have an
`api_token` configured).
---
## Only if you used that feature
### Request bodies accept declarative options only
A crawl request body now carries scalar, declarative options only. The
following are **rejected with HTTP 400** when sent over the network; configure
them server-side or run a self-hosted in-process build (the SDK keeps full
control):
`js_code`, `js_code_before_wait`, `c4a_script`, `proxy` / `proxy_config`,
`extra_args`, `user_data_dir`, `cdp_url`, `cookies`, `headers`, `init_scripts`,
`base_url`, `deep_crawl_strategy`, `simulate_user`, `magic`,
`process_in_browser`, and nested LLM config objects.
Unknown fields are dropped; timeouts, viewport and scroll counts are clamped to
safe maximums.
### Hooks: declarative actions instead of code
`hooks.code` (Python strings) is replaced by a fixed set of declarative actions:
```jsonc
{
"hooks": {
"hooks": [
{"action": "block_resources", "params": {"resource_types": ["image", "font"]}},
{"action": "scroll_to_bottom", "params": {"max_steps": 10, "delay_ms": 500}}
]
}
}
```
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.
### Screenshot / PDF: artifact id instead of `output_path`
`output_path` is removed. The server stores the result and returns an id + URL:
```jsonc
{"success": true, "screenshot": "<base64>",
"artifact_id": "….", "url": "/artifacts/….", "mime": "image/png", "size": 12345}
```
Fetch the file with `GET /artifacts/{artifact_id}` (authenticated). Artifacts
have a TTL and a storage quota.
### LLM endpoints: provider by name
`base_url` is removed from `/md`, `/llm`, and `/llm/job`. Select a provider by
**name** only; the endpoint and key are configured server-side via env
(`OPENAI_BASE_URL` / `LLM_BASE_URL`) and `config.llm.allowed_providers`. A
provider outside the allowed family returns 400.
### Monitor actions need an admin token
`POST /monitor/actions/cleanup|kill_browser|restart_browser` and
`/monitor/stats/reset` require an **admin-scope** principal (the static
`CRAWL4AI_API_TOKEN` is admin; `/token`-issued JWTs are `data` scope).
### Browser / JS clients: allowlist your origin (CORS)
Cross-origin browser requests are denied unless allowlisted:
```yaml
security:
cors_allow_origins: ["https://your-frontend.example"]
```
### TLS verification is on
Self-signed / internal TLS crawl targets now fail by default. For trusted
internal testing only: `CRAWL4AI_ALLOW_INSECURE_TLS=true`. Internal-network
crawling escape hatch: `CRAWL4AI_ALLOW_INTERNAL_URLS=true`.
### Webhook headers are validated
Custom webhook headers must be well-formed names with no control characters and
may not set hop-by-hop / sensitive headers (`Host`, `Content-Length`,
`Transfer-Encoding`, `Authorization`, `Cookie`, …). Invalid headers → 422.
### Redis requires a password
Redis runs in-container, loopback-only, password-protected, and its port is no
longer published. For an **external** redis, set `REDIS_PASSWORD`.
### Resource limits (all configurable; `0` = unbounded)
```yaml
limits:
max_body_bytes: 10485760 # request body cap (413); 0 = unbounded
wall_clock_s: 0 # per-crawl deadline (504); 0 = none
queue:
maxsize: 1000 # background job queue (503 when full); 0 = unbounded
workers: 4
per_principal: 0 # max concurrent jobs per caller (429); 0 = unlimited
```
To keep the previous behavior exactly, set the caps you don't want to `0`.
### Error responses are generic
5xx responses return `{"error": "Internal server error", "correlation_id": "…"}`.
Match the correlation id in the server logs for detail. Developer-facing 4xx
messages are unchanged.
---
## Defaults summary
| Setting | Old | New |
| --- | --- | --- |
| Bind | `0.0.0.0`, open | `127.0.0.1`; exposing requires a token |
| Auth | off by default | on by default |
| Security headers / CSP | off | on (strict on the API surface) |
| CORS | none | deny-by-default |
| TLS verification | disabled | enabled |
| Redis | no password, port published | password, loopback, not published |
| `output_path` | accepted | removed (artifact store) |
| LLM `base_url` in request | honored | removed |
| Hooks | Python code | declarative actions |
| Background jobs | unbounded | bounded queue (configurable, 0 = unbounded) |
## Operational notes
- **`--no-sandbox`** is still set by default (the container runs as non-root
without a usable sandbox). To drop it, run the container with an unprivileged
user namespace (`unprivileged_userns_clone=1`) or a seccomp profile, then set
`CRAWL4AI_CHROMIUM_SANDBOX=true`. See `SECURITY-VERIFY.md`.
- The hardened `docker-compose.yml` uses `read_only: true` + tmpfs, `cap_drop:
[ALL]`, `no-new-privileges`, and `shm_size` instead of a host `/dev/shm` bind.
Mirror these in a custom compose file.
- The `/dashboard` and `/playground` UIs get baseline headers
(`nosniff`, `X-Frame-Options: DENY`) and are auth-gated; a stricter CSP for
the UIs is planned in a follow-up.
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
# Security Hardening — Build/Runtime Verification Runbook
The offline test suite (`pytest deploy/docker/tests/test_security_*.py`) covers
the logic of the hardening. A handful of items can only be confirmed with a real
`docker build` + boot or a browser. This runbook lists those, with exact
commands and expected results. Run it before relying on the hardened image.
Prereqs: Docker + docker compose, a checkout of this branch.
---
## 0. Offline suite (no Docker) — should already pass
```bash
python -m pip install -e . && pip install -r deploy/docker/requirements.txt pytest pytest-asyncio
pytest deploy/docker/tests/test_security_*.py -q
```
Expected: all pass, `1 xfailed` (the `--no-sandbox` posture test, see §3).
---
## 1. Build the hardened image
```bash
IMAGE=local-sec docker compose build
# or: docker build -t unclecode/crawl4ai:local-sec .
```
Expected: build succeeds. Note the `/app` dir is now root-owned + read-only and
the artifact dir `/var/lib/crawl4ai/outputs` is created 0700.
---
## 2. Boot + bind posture (entrypoint)
### 2a. No credential -> loopback only (refuses to expose)
```bash
docker run --rm -p 11235:11235 unclecode/crawl4ai:local-sec &
sleep 8
docker logs <id> 2>&1 | grep -i "binding loopback only" # expect this line
curl -fsS http://localhost:11235/health # expect: NOT reachable
```
Expected: entrypoint logs "no CRAWL4AI_API_TOKEN set; binding loopback only";
the mapped port is **not** reachable from the host (gunicorn bound 127.0.0.1
inside the container). This is the fail-closed default.
### 2b. With a credential -> may expose 0.0.0.0
```bash
TOKEN=$(openssl rand -hex 32)
docker run --rm -e CRAWL4AI_API_TOKEN=$TOKEN -p 11235:11235 unclecode/crawl4ai:local-sec &
sleep 8
curl -fsS http://localhost:11235/health # expect 200
curl -fsS http://localhost:11235/schema # expect 401
curl -fsS -H "Authorization: Bearer $TOKEN" http://localhost:11235/schema # expect 200
```
---
## 3. Chromium sandbox (`--no-sandbox`)
Default keeps `--no-sandbox` (works as today). To verify the hardened path:
### Option A — unprivileged user namespace (preferred)
Host: `sysctl -w kernel.unprivileged_userns_clone=1` (Debian/Ubuntu).
```bash
docker run --rm -e CRAWL4AI_API_TOKEN=$TOKEN -e CRAWL4AI_CHROMIUM_SANDBOX=true \
-p 11235:11235 unclecode/crawl4ai:local-sec &
sleep 8
curl -fsS -X POST http://localhost:11235/crawl \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"urls":["https://example.com"]}'
```
Expected: a successful crawl (Chromium started sandboxed). If it fails to launch,
the host lacks userns — keep the default or use Option B.
### Option B — seccomp profile
Provide a Chrome seccomp profile and wire it in compose:
```yaml
security_opt:
- seccomp=./seccomp-chrome.json
```
then set `CRAWL4AI_CHROMIUM_SANDBOX=true` and re-run the crawl above.
If verified, flip the default: remove `--no-sandbox` from `config.yml` and the
`test_no_no_sandbox_flag` xfail in `test_security_default_posture.py` becomes a
normal pass.
---
## 4. Read-only rootfs + tmpfs (compose)
```bash
docker compose up -d # uses read_only: true + tmpfs
sleep 8
# Writes outside tmpfs must fail:
docker compose exec crawl4ai sh -c 'echo x > /app/should_fail' ; echo "exit=$?" # expect non-zero
# Artifact write (tmpfs) must work via the API:
curl -fsS -X POST http://localhost:11235/screenshot \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"url":"https://example.com"}' | grep artifact_id
```
Expected: `/app` write fails (read-only), screenshot returns an `artifact_id`,
and `GET /artifacts/{id}` (with the token) returns the PNG.
---
## 5. Redis is loopback + password-protected, not exposed
```bash
docker compose exec crawl4ai sh -c 'redis-cli -p 6379 ping' # expect: NOAUTH / error
docker compose exec crawl4ai sh -c 'redis-cli -a "$REDIS_PASSWORD" ping' # expect: PONG
# From the host, the redis port must NOT be reachable (no EXPOSE / publish):
nc -z localhost 6379 ; echo "exit=$?" # expect non-zero
```
---
## 6. Browser egress proxy (DNS-rebinding control)
The browser is routed through the localhost pinning proxy. To confirm end to end:
```bash
# A normal public crawl works:
curl -fsS -X POST http://localhost:11235/crawl -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"urls":["https://example.com"]}' | grep '"success": true'
# An internal target is refused up front (and the proxy would 403 a rebind):
curl -s -X POST http://localhost:11235/crawl -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"urls":["http://169.254.169.254/"]}' # expect 400 blocked
```
(For a true rebinding test, point a host at a TTL-0 record that flips public->
internal and confirm the crawl never reaches the internal IP.)
---
## 7. Dashboard / playground (browser, CSP)
Open `http://localhost:11235/dashboard` and `/playground` in a browser (send the
token). Expected today: they load and work; response headers include
`X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY`, but **no** strict
CSP (they still use inline scripts + CDN assets). Extending the strict CSP to
these mounts requires the externalization work noted in MIGRATION.md.
---
## Sign-off checklist
- [ ] §0 offline suite green (1 xfail)
- [ ] §2a loopback-only without a token; §2b exposed + gated with a token
- [ ] §3 Chromium starts sandboxed under userns/seccomp (if pursuing no-sandbox removal)
- [ ] §4 `/app` read-only; artifacts work on tmpfs
- [ ] §5 redis requires auth + not host-reachable
- [ ] §6 public crawl works; internal target blocked
- [ ] §7 dashboard/playground load with baseline headers
+241
View File
@@ -0,0 +1,241 @@
# Crawl4AI Docker Memory & Pool Optimization - Implementation Log
## Critical Issues Identified
### Memory Management
- **Host vs Container**: `psutil.virtual_memory()` reported host memory, not container limits
- **Browser Pooling**: No pool reuse - every endpoint created new browsers
- **Warmup Waste**: Permanent browser sat idle with mismatched config signature
- **Idle Cleanup**: 30min TTL too long, janitor ran every 60s
- **Endpoint Inconsistency**: 75% of endpoints bypassed pool (`/md`, `/html`, `/screenshot`, `/pdf`, `/execute_js`, `/llm`)
### Pool Design Flaws
- **Config Mismatch**: Permanent browser used `config.yml` args, endpoints used empty `BrowserConfig()`
- **Logging Level**: Pool hit markers at DEBUG, invisible with INFO logging
## Implementation Changes
### 1. Container-Aware Memory Detection (`utils.py`)
```python
def get_container_memory_percent() -> float:
# Try cgroup v2 → v1 → fallback to psutil
# Reads /sys/fs/cgroup/memory.{current,max} OR memory/memory.{usage,limit}_in_bytes
```
### 2. Smart Browser Pool (`crawler_pool.py`)
**3-Tier System:**
- **PERMANENT**: Always-ready default browser (never cleaned)
- **HOT_POOL**: Configs used 3+ times (longer TTL)
- **COLD_POOL**: New/rare configs (short TTL)
**Key Functions:**
- `get_crawler(cfg)`: Check permanent → hot → cold → create new
- `init_permanent(cfg)`: Initialize permanent at startup
- `janitor()`: Adaptive cleanup (10s/30s/60s intervals based on memory)
- `_sig(cfg)`: SHA1 hash of config dict for pool keys
**Logging Fix**: Changed `logger.debug()``logger.info()` for pool hits
### 3. Endpoint Unification
**Helper Function** (`server.py`):
```python
def get_default_browser_config() -> BrowserConfig:
return BrowserConfig(
extra_args=config["crawler"]["browser"].get("extra_args", []),
**config["crawler"]["browser"].get("kwargs", {}),
)
```
**Migrated Endpoints:**
- `/html`, `/screenshot`, `/pdf`, `/execute_js` → use `get_default_browser_config()`
- `handle_llm_qa()`, `handle_markdown_request()` → same
**Result**: All endpoints now hit permanent browser pool
### 4. Config Updates (`config.yml`)
- `idle_ttl_sec: 1800``300` (30min → 5min base TTL)
- `port: 11234``11235` (fixed mismatch with Gunicorn)
### 5. Lifespan Fix (`server.py`)
```python
await init_permanent(BrowserConfig(
extra_args=config["crawler"]["browser"].get("extra_args", []),
**config["crawler"]["browser"].get("kwargs", {}),
))
```
Permanent browser now matches endpoint config signatures
## Test Results
### Test 1: Basic Health
- 10 requests to `/health`
- **Result**: 100% success, avg 3ms latency
- **Baseline**: Container starts in ~5s, 270 MB idle
### Test 2: Memory Monitoring
- 20 requests with Docker stats tracking
- **Result**: 100% success, no memory leak (-0.2 MB delta)
- **Baseline**: 269.7 MB container overhead
### Test 3: Pool Validation
- 30 requests to `/html` endpoint
- **Result**: **100% permanent browser hits**, 0 new browsers created
- **Memory**: 287 MB baseline → 396 MB active (+109 MB)
- **Latency**: Avg 4s (includes network to httpbin.org)
### Test 4: Concurrent Load
- Light (10) → Medium (50) → Heavy (100) concurrent
- **Total**: 320 requests
- **Result**: 100% success, **320/320 permanent hits**, 0 new browsers
- **Memory**: 269 MB → peak 1533 MB → final 993 MB
- **Latency**: P99 at 100 concurrent = 34s (expected with single browser)
### Test 5: Pool Stress (Mixed Configs)
- 20 requests with 4 different viewport configs
- **Result**: 4 new browsers, 4 cold hits, **4 promotions to hot**, 8 hot hits
- **Reuse Rate**: 60% (12 pool hits / 20 requests)
- **Memory**: 270 MB → 928 MB peak (+658 MB = ~165 MB per browser)
- **Proves**: Cold → hot promotion at 3 uses working perfectly
### Test 6: Multi-Endpoint
- 10 requests each: `/html`, `/screenshot`, `/pdf`, `/crawl`
- **Result**: 100% success across all 4 endpoints
- **Latency**: 5-8s avg (PDF slowest at 7.2s)
### Test 7: Cleanup Verification
- 20 requests (load spike) → 90s idle
- **Memory**: 269 MB → peak 1107 MB → final 780 MB
- **Recovery**: 327 MB (39%) - partial cleanup
- **Note**: Hot pool browsers persist (by design), janitor working correctly
## Performance Metrics
| Metric | Before | After | Improvement |
|--------|--------|-------|-------------|
| Pool Reuse | 0% | 100% (default config) | ∞ |
| Memory Leak | Unknown | 0 MB/cycle | Stable |
| Browser Reuse | No | Yes | ~3-5s saved per request |
| Idle Memory | 500-700 MB × N | 270-400 MB | 10x reduction |
| Concurrent Capacity | ~20 | 100+ | 5x |
## Key Learnings
1. **Config Signature Matching**: Permanent browser MUST match endpoint default config exactly (SHA1 hash)
2. **Logging Levels**: Pool diagnostics need INFO level, not DEBUG
3. **Memory in Docker**: Must read cgroup files, not host metrics
4. **Janitor Timing**: 60s interval adequate, but TTLs should be short (5min) for cold pool
5. **Hot Promotion**: 3-use threshold works well for production patterns
6. **Memory Per Browser**: ~150-200 MB per Chromium instance with headless + text_mode
## Test Infrastructure
**Location**: `deploy/docker/tests/`
**Dependencies**: `httpx`, `docker` (Python SDK)
**Pattern**: Sequential build - each test adds one capability
**Files**:
- `test_1_basic.py`: Health check + container lifecycle
- `test_2_memory.py`: + Docker stats monitoring
- `test_3_pool.py`: + Log analysis for pool markers
- `test_4_concurrent.py`: + asyncio.Semaphore for concurrency control
- `test_5_pool_stress.py`: + Config variants (viewports)
- `test_6_multi_endpoint.py`: + Multiple endpoint testing
- `test_7_cleanup.py`: + Time-series memory tracking for janitor
**Run Pattern**:
```bash
cd deploy/docker/tests
pip install -r requirements.txt
# Rebuild after code changes:
cd /path/to/repo && docker buildx build -t crawl4ai-local:latest --load .
# Run test:
python test_N_name.py
```
## Architecture Decisions
**Why Permanent Browser?**
- 90% of requests use default config → single browser serves most traffic
- Eliminates 3-5s startup overhead per request
**Why 3-Tier Pool?**
- Permanent: Zero cost for common case
- Hot: Amortized cost for frequent variants
- Cold: Lazy allocation for rare configs
**Why Adaptive Janitor?**
- Memory pressure triggers aggressive cleanup
- Low memory allows longer TTLs for better reuse
**Why Not Close After Each Request?**
- Browser startup: 3-5s overhead
- Pool reuse: <100ms overhead
- Net: 30-50x faster
## Future Optimizations
1. **Request Queuing**: When at capacity, queue instead of reject
2. **Pre-warming**: Predict common configs, pre-create browsers
3. **Metrics Export**: Prometheus metrics for pool efficiency
4. **Config Normalization**: Group similar viewports (e.g., 1920±50 → 1920)
## Critical Code Paths
**Browser Acquisition** (`crawler_pool.py:34-78`):
```
get_crawler(cfg) →
_sig(cfg) →
if sig == DEFAULT_CONFIG_SIG → PERMANENT
elif sig in HOT_POOL → HOT_POOL[sig]
elif sig in COLD_POOL → promote if count >= 3
else → create new in COLD_POOL
```
**Janitor Loop** (`crawler_pool.py:107-146`):
```
while True:
mem% = get_container_memory_percent()
if mem% > 80: interval=10s, cold_ttl=30s
elif mem% > 60: interval=30s, cold_ttl=60s
else: interval=60s, cold_ttl=300s
sleep(interval)
close idle browsers (COLD then HOT)
```
**Endpoint Pattern** (`server.py` example):
```python
@app.post("/html")
async def generate_html(...):
from crawler_pool import get_crawler
crawler = await get_crawler(get_default_browser_config())
results = await crawler.arun(url=body.url, config=cfg)
# No crawler.close() - returned to pool
```
## Debugging Tips
**Check Pool Activity**:
```bash
docker logs crawl4ai-test | grep -E "(🔥|♨️|❄️|🆕|⬆️)"
```
**Verify Config Signature**:
```python
from crawl4ai import BrowserConfig
import json, hashlib
cfg = BrowserConfig(...)
sig = hashlib.sha1(json.dumps(cfg.to_dict(), sort_keys=True).encode()).hexdigest()
print(sig[:8]) # Compare with logs
```
**Monitor Memory**:
```bash
docker stats crawl4ai-test
```
## Known Limitations
- **Mac Docker Stats**: CPU metrics unreliable, memory works
- **PDF Generation**: Slowest endpoint (~7s), no optimization yet
- **Hot Pool Persistence**: May hold memory longer than needed (trade-off for performance)
- **Janitor Lag**: Up to 60s before cleanup triggers in low-memory scenarios
+378
View File
@@ -0,0 +1,378 @@
# Webhook Feature Examples
This document provides examples of how to use the webhook feature for crawl jobs in Crawl4AI.
## Overview
The webhook feature allows you to receive notifications when crawl jobs complete, eliminating the need for polling. Webhooks are sent with exponential backoff retry logic to ensure reliable delivery.
## Configuration
### Global Configuration (config.yml)
You can configure default webhook settings in `config.yml`:
```yaml
webhooks:
enabled: true
default_url: null # Optional: default webhook URL for all jobs
data_in_payload: false # Optional: default behavior for including data
retry:
max_attempts: 5
initial_delay_ms: 1000 # 1s, 2s, 4s, 8s, 16s exponential backoff
max_delay_ms: 32000
timeout_ms: 30000 # 30s timeout per webhook call
headers: # Optional: default headers to include
User-Agent: "Crawl4AI-Webhook/1.0"
```
## API Usage Examples
### Example 1: Basic Webhook (Notification Only)
Send a webhook notification without including the crawl data in the payload.
**Request:**
```bash
curl -X POST http://localhost:11235/crawl/job \
-H "Content-Type: application/json" \
-d '{
"urls": ["https://example.com"],
"webhook_config": {
"webhook_url": "https://myapp.com/webhooks/crawl-complete",
"webhook_data_in_payload": false
}
}'
```
**Response:**
```json
{
"task_id": "crawl_a1b2c3d4"
}
```
**Webhook Payload Received:**
```json
{
"task_id": "crawl_a1b2c3d4",
"task_type": "crawl",
"status": "completed",
"timestamp": "2025-10-21T10:30:00.000000+00:00",
"urls": ["https://example.com"]
}
```
Your webhook handler should then fetch the results:
```bash
curl http://localhost:11235/crawl/job/crawl_a1b2c3d4
```
### Example 2: Webhook with Data Included
Include the full crawl results in the webhook payload.
**Request:**
```bash
curl -X POST http://localhost:11235/crawl/job \
-H "Content-Type: application/json" \
-d '{
"urls": ["https://example.com"],
"webhook_config": {
"webhook_url": "https://myapp.com/webhooks/crawl-complete",
"webhook_data_in_payload": true
}
}'
```
**Webhook Payload Received:**
```json
{
"task_id": "crawl_a1b2c3d4",
"task_type": "crawl",
"status": "completed",
"timestamp": "2025-10-21T10:30:00.000000+00:00",
"urls": ["https://example.com"],
"data": {
"markdown": "...",
"html": "...",
"links": {...},
"metadata": {...}
}
}
```
### Example 3: Webhook with Custom Headers
Include custom headers for authentication or identification.
**Request:**
```bash
curl -X POST http://localhost:11235/crawl/job \
-H "Content-Type: application/json" \
-d '{
"urls": ["https://example.com"],
"webhook_config": {
"webhook_url": "https://myapp.com/webhooks/crawl-complete",
"webhook_data_in_payload": false,
"webhook_headers": {
"X-Webhook-Secret": "my-secret-token",
"X-Service-ID": "crawl4ai-production"
}
}
}'
```
The webhook will be sent with these additional headers plus the default headers from config.
### Example 4: Failure Notification
When a crawl job fails, a webhook is sent with error details.
**Webhook Payload on Failure:**
```json
{
"task_id": "crawl_a1b2c3d4",
"task_type": "crawl",
"status": "failed",
"timestamp": "2025-10-21T10:30:00.000000+00:00",
"urls": ["https://example.com"],
"error": "Connection timeout after 30s"
}
```
### Example 5: Using Global Default Webhook
If you set a `default_url` in config.yml, jobs without webhook_config will use it:
**config.yml:**
```yaml
webhooks:
enabled: true
default_url: "https://myapp.com/webhooks/default"
data_in_payload: false
```
**Request (no webhook_config needed):**
```bash
curl -X POST http://localhost:11235/crawl/job \
-H "Content-Type: application/json" \
-d '{
"urls": ["https://example.com"]
}'
```
The webhook will be sent to the default URL configured in config.yml.
### Example 6: LLM Extraction Job with Webhook
Use webhooks with the LLM extraction endpoint for asynchronous processing.
**Request:**
```bash
curl -X POST http://localhost:11235/llm/job \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/article",
"q": "Extract the article title, author, and publication date",
"schema": "{\"type\": \"object\", \"properties\": {\"title\": {\"type\": \"string\"}, \"author\": {\"type\": \"string\"}, \"date\": {\"type\": \"string\"}}}",
"cache": false,
"provider": "openai/gpt-4o-mini",
"webhook_config": {
"webhook_url": "https://myapp.com/webhooks/llm-complete",
"webhook_data_in_payload": true
}
}'
```
**Response:**
```json
{
"task_id": "llm_1698765432_12345"
}
```
**Webhook Payload Received:**
```json
{
"task_id": "llm_1698765432_12345",
"task_type": "llm_extraction",
"status": "completed",
"timestamp": "2025-10-21T10:30:00.000000+00:00",
"urls": ["https://example.com/article"],
"data": {
"extracted_content": {
"title": "Understanding Web Scraping",
"author": "John Doe",
"date": "2025-10-21"
}
}
}
```
## Webhook Handler Example
Here's a simple Python Flask webhook handler that supports both crawl and LLM extraction jobs:
```python
from flask import Flask, request, jsonify
import requests
app = Flask(__name__)
@app.route('/webhooks/crawl-complete', methods=['POST'])
def handle_crawl_webhook():
payload = request.json
task_id = payload['task_id']
task_type = payload['task_type']
status = payload['status']
if status == 'completed':
# If data not in payload, fetch it
if 'data' not in payload:
# Determine endpoint based on task type
endpoint = 'crawl' if task_type == 'crawl' else 'llm'
response = requests.get(f'http://localhost:11235/{endpoint}/job/{task_id}')
data = response.json()
else:
data = payload['data']
# Process based on task type
if task_type == 'crawl':
print(f"Processing crawl results for {task_id}")
# Handle crawl results
results = data.get('results', [])
for result in results:
print(f" - {result.get('url')}: {len(result.get('markdown', ''))} chars")
elif task_type == 'llm_extraction':
print(f"Processing LLM extraction for {task_id}")
# Handle LLM extraction
# Note: Webhook sends 'extracted_content', API returns 'result'
extracted = data.get('extracted_content', data.get('result', {}))
print(f" - Extracted: {extracted}")
# Your business logic here...
elif status == 'failed':
error = payload.get('error', 'Unknown error')
print(f"{task_type} job {task_id} failed: {error}")
# Handle failure...
return jsonify({"status": "received"}), 200
if __name__ == '__main__':
app.run(port=8080)
```
## Retry Logic
The webhook delivery service uses exponential backoff retry logic:
- **Attempts:** Up to 5 attempts by default
- **Delays:** 1s → 2s → 4s → 8s → 16s
- **Timeout:** 30 seconds per attempt
- **Retry Conditions:**
- Server errors (5xx status codes)
- Network errors
- Timeouts
- **No Retry:**
- Client errors (4xx status codes)
- Successful delivery (2xx status codes)
## Benefits
1. **No Polling Required** - Eliminates constant API calls to check job status
2. **Real-time Notifications** - Immediate notification when jobs complete
3. **Reliable Delivery** - Exponential backoff ensures webhooks are delivered
4. **Flexible** - Choose between notification-only or full data delivery
5. **Secure** - Support for custom headers for authentication
6. **Configurable** - Global defaults or per-job configuration
7. **Universal Support** - Works with both `/crawl/job` and `/llm/job` endpoints
## TypeScript Client Example
```typescript
interface WebhookConfig {
webhook_url: string;
webhook_data_in_payload?: boolean;
webhook_headers?: Record<string, string>;
}
interface CrawlJobRequest {
urls: string[];
browser_config?: Record<string, any>;
crawler_config?: Record<string, any>;
webhook_config?: WebhookConfig;
}
interface LLMJobRequest {
url: string;
q: string;
schema?: string;
cache?: boolean;
provider?: string;
webhook_config?: WebhookConfig;
}
async function createCrawlJob(request: CrawlJobRequest) {
const response = await fetch('http://localhost:11235/crawl/job', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request)
});
const { task_id } = await response.json();
return task_id;
}
async function createLLMJob(request: LLMJobRequest) {
const response = await fetch('http://localhost:11235/llm/job', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request)
});
const { task_id } = await response.json();
return task_id;
}
// Usage - Crawl Job
const crawlTaskId = await createCrawlJob({
urls: ['https://example.com'],
webhook_config: {
webhook_url: 'https://myapp.com/webhooks/crawl-complete',
webhook_data_in_payload: false,
webhook_headers: {
'X-Webhook-Secret': 'my-secret'
}
}
});
// Usage - LLM Extraction Job
const llmTaskId = await createLLMJob({
url: 'https://example.com/article',
q: 'Extract the main points from this article',
provider: 'openai/gpt-4o-mini',
webhook_config: {
webhook_url: 'https://myapp.com/webhooks/llm-complete',
webhook_data_in_payload: true,
webhook_headers: {
'X-Webhook-Secret': 'my-secret'
}
}
});
```
## Monitoring and Debugging
Webhook delivery attempts are logged at INFO level:
- Successful deliveries
- Retry attempts with delays
- Final failures after max attempts
Check the application logs for webhook delivery status:
```bash
docker logs crawl4ai-container | grep -i webhook
```
+1026
View File
File diff suppressed because it is too large Load Diff
+166
View File
@@ -0,0 +1,166 @@
"""
artifacts.py - sandboxed, opaque-ID artifact store.
The screenshot/PDF endpoints used to accept a caller-supplied `output_path` and
write to it. `validate_output_path` was string-only (no realpath / O_NOFOLLOW),
so a symlink or a sibling-prefix name (".../outputs-evil") bypassed it and gave
an arbitrary write -> root RCE (e.g. /etc/cron.d) since /app was writable.
The fix: callers never name a path. The server owns the directory, the names,
and the bytes. Each artifact gets a server-generated opaque id; writes are
O_EXCL|O_NOFOLLOW 0600 into a 0700 dir off /tmp, size-capped and dir-quota'd;
retrieval validates a 32-hex id, lstat()s a non-symlink regular file, and
enforces a TTL. A janitor reaps expired/over-quota files.
"""
from __future__ import annotations
import os
import re
import time
import uuid
from typing import Dict, Tuple
ARTIFACT_DIR = os.environ.get("CRAWL4AI_ARTIFACT_DIR", "/var/lib/crawl4ai/outputs")
MAX_ARTIFACT_BYTES = int(os.environ.get("CRAWL4AI_MAX_ARTIFACT_BYTES", str(50 * 1024 * 1024)))
ARTIFACT_QUOTA_BYTES = int(os.environ.get("CRAWL4AI_ARTIFACT_QUOTA_BYTES", str(2 * 1024 * 1024 * 1024)))
ARTIFACT_TTL_SECONDS = int(os.environ.get("CRAWL4AI_ARTIFACT_TTL_SECONDS", "3600"))
_HEX32 = re.compile(r"^[0-9a-f]{32}$")
_KIND = {
"png": (".png", "image/png"),
"pdf": (".pdf", "application/pdf"),
}
class ArtifactError(Exception):
pass
class ArtifactTooLarge(ArtifactError):
pass
class QuotaExceeded(ArtifactError):
pass
class ArtifactNotFound(ArtifactError):
pass
def init_store() -> None:
"""Create the artifact dir 0700 (idempotent)."""
os.makedirs(ARTIFACT_DIR, mode=0o700, exist_ok=True)
try:
os.chmod(ARTIFACT_DIR, 0o700)
except OSError:
pass
def _dir_size() -> int:
total = 0
try:
with os.scandir(ARTIFACT_DIR) as it:
for entry in it:
try:
st = entry.stat(follow_symlinks=False)
if os.path.stat.S_ISREG(st.st_mode):
total += st.st_size
except OSError:
continue
except FileNotFoundError:
return 0
return total
def write_artifact(kind: str, data: bytes) -> Dict:
"""Write bytes under a server-generated opaque id. Returns metadata."""
ext, mime = _KIND.get(kind, (".bin", "application/octet-stream"))
if len(data) > MAX_ARTIFACT_BYTES:
raise ArtifactTooLarge(f"artifact exceeds {MAX_ARTIFACT_BYTES} bytes")
init_store()
if _dir_size() + len(data) > ARTIFACT_QUOTA_BYTES:
raise QuotaExceeded("artifact storage quota exceeded")
artifact_id = uuid.uuid4().hex # 32 hex chars, unguessable
path = os.path.join(ARTIFACT_DIR, artifact_id + ext)
# O_EXCL: never follow/overwrite an existing entry; O_NOFOLLOW: refuse a
# symlink at the final component. Server-chosen name => no traversal.
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(path, flags, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(data)
return {"artifact_id": artifact_id, "mime": mime, "size": len(data)}
def resolve_artifact(artifact_id: str) -> Tuple[str, str]:
"""Map an opaque id to (path, mime), enforcing hex-id, no-symlink, TTL.
Raises ArtifactNotFound for an invalid id, a non-regular/symlink file, a
missing file, or an expired artifact (so existence is never revealed).
"""
if not isinstance(artifact_id, str) or not _HEX32.match(artifact_id):
raise ArtifactNotFound()
for ext, mime in _KIND.values():
path = os.path.join(ARTIFACT_DIR, artifact_id + ext)
try:
st = os.lstat(path) # do NOT follow symlinks
except FileNotFoundError:
continue
if not os.path.stat.S_ISREG(st.st_mode):
raise ArtifactNotFound() # symlink / special file
if time.time() - st.st_mtime > ARTIFACT_TTL_SECONDS:
try:
os.unlink(path)
except OSError:
pass
raise ArtifactNotFound()
return path, mime
raise ArtifactNotFound()
def janitor() -> int:
"""Reap expired artifacts; if still over quota, reap oldest first."""
init_store()
now = time.time()
reaped = 0
entries = []
try:
with os.scandir(ARTIFACT_DIR) as it:
for entry in it:
try:
st = entry.stat(follow_symlinks=False)
except OSError:
continue
if not os.path.stat.S_ISREG(st.st_mode):
# remove anything that is not a regular file (e.g. a symlink)
try:
os.unlink(entry.path)
reaped += 1
except OSError:
pass
continue
if now - st.st_mtime > ARTIFACT_TTL_SECONDS:
try:
os.unlink(entry.path)
reaped += 1
except OSError:
pass
continue
entries.append((st.st_mtime, st.st_size, entry.path))
except FileNotFoundError:
return reaped
total = sum(sz for _, sz, _ in entries)
if total > ARTIFACT_QUOTA_BYTES:
for _mtime, sz, path in sorted(entries): # oldest first
if total <= ARTIFACT_QUOTA_BYTES:
break
try:
os.unlink(path)
total -= sz
reaped += 1
except OSError:
pass
return reaped
+146
View File
@@ -0,0 +1,146 @@
"""
Authentication primitives for the Crawl4AI Docker server.
This module is PyJWT-only. The previous dual dependency on the GehirnInc `jwt`
package *and* `PyJWT` (both install a top-level `jwt` module) meant the meaning
of `import jwt` depended on install order, and the security tests could exercise
a different code path than production. We now depend on PyJWT exclusively.
Auth is decided in one place: the AuthGateMiddleware (auth_gate.py), which runs
as the outermost ASGI layer and fails closed. The helpers here are what that
gate (and the /token endpoint) call:
* create_access_token - mint an HS256 JWT carrying a scope claim
* decode_token - verify an HS256 JWT (algorithms passed as a LIST,
which kills the substring-match bug and rejects
alg:none / other algorithms)
* constant_time_eq - timing-safe comparison for the static API token
* resolve_secret_key - fail fast when a real secret is required but missing
"""
import hmac
import logging
import os
import secrets as _secrets
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional
import jwt
from fastapi import HTTPException, Request
from pydantic import BaseModel, EmailStr
ACCESS_TOKEN_EXPIRE_MINUTES = 60
ALGORITHM = "HS256"
_ALGORITHMS = [ALGORITHM] # a LIST on purpose: no substring matching, no alg:none
_WEAK_SECRETS = {"mysecret", "secret", "password", "changeme", "test", "12345678"}
_MIN_SECRET_LEN = 32
_log = logging.getLogger("crawl4ai.security")
def resolve_secret_key(*, required: bool) -> str:
"""Resolve and validate SECRET_KEY.
required=True -> fail fast (RuntimeError) if unset/weak/short. Used when a
real auth deployment is in effect; an ephemeral key would
silently invalidate every issued token on restart.
required=False -> auto-generate an ephemeral key (and warn) when unset, so
loopback/dev still works. A set-but-weak key still fails.
"""
key = os.environ.get("SECRET_KEY", "")
if key:
if key.lower() in _WEAK_SECRETS:
raise RuntimeError(
"FATAL: SECRET_KEY is a known weak value. Generate a strong one: "
'python3 -c "import secrets; print(secrets.token_hex(32))"'
)
if len(key) < _MIN_SECRET_LEN:
raise RuntimeError(
f"FATAL: SECRET_KEY must be at least {_MIN_SECRET_LEN} characters. "
'Generate one: python3 -c "import secrets; print(secrets.token_hex(32))"'
)
return key
if required:
raise RuntimeError(
"FATAL: authentication is enabled but SECRET_KEY is not set. "
'Set it: SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")'
)
generated = _secrets.token_hex(32)
_log.warning(
"No SECRET_KEY set. Auto-generated an ephemeral key (changes on restart, "
"invalidating issued tokens). Set SECRET_KEY for any real deployment."
)
return generated
# Module-level key, resolved leniently at import. The server's startup
# _resolve_auth() performs the fail-fast check when a real deployment is
# detected (credential set and/or non-loopback bind).
SECRET_KEY = resolve_secret_key(required=False)
def create_access_token(
data: dict,
*,
scope: str = "data",
expires_delta: Optional[timedelta] = None,
) -> str:
"""Mint an HS256 JWT. `scope` is "data" (normal) or "admin"."""
to_encode = dict(data)
to_encode["scope"] = scope
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode["exp"] = expire
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> Dict:
"""Verify an HS256 JWT and return its claims.
Raises jwt.InvalidTokenError (incl. ExpiredSignatureError) on any failure.
`algorithms` is a list, so alg:none and every non-HS256 algorithm are
rejected outright.
"""
return jwt.decode(token, SECRET_KEY, algorithms=_ALGORITHMS)
def constant_time_eq(a: str, b: str) -> bool:
"""Timing-safe string comparison for the static API token."""
return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
def get_principal(request: Request) -> Optional[Dict]:
"""The principal the AuthGateMiddleware already validated (or None)."""
return getattr(request.state, "principal", None)
def get_token_dependency(config: Dict):
"""Backward-compatible dependency factory.
Auth enforcement now lives in the AuthGateMiddleware (the outermost ASGI
layer); by the time any route dependency runs, the request was already
authenticated by the gate or rejected with 401. This dependency simply
surfaces the validated principal to handlers that declared `_td`.
"""
def _principal(request: Request) -> Optional[Dict]:
return get_principal(request)
return _principal
def require_admin(request: Request) -> Dict:
"""Dependency: require an admin-scope principal (destructive actions)."""
principal = get_principal(request)
if not principal or principal.get("scope") != "admin":
raise HTTPException(status_code=403, detail="Admin scope required")
return principal
class TokenRequest(BaseModel):
email: EmailStr
api_token: Optional[str] = None
+126
View File
@@ -0,0 +1,126 @@
"""
AuthGateMiddleware - the single, fail-closed authentication boundary.
The previous design decided auth in a per-route FastAPI dependency that, when
`jwt_enabled` was false (the default), returned `lambda: None` - so every
`Depends(token_dep)` was decorative and the whole API was open. Static mounts,
the MCP transports and the Prometheus endpoint were never covered at all.
This middleware moves auth to the outermost ASGI layer so it covers EVERY
route, mount and sub-app (HTTP + WebSocket) uniformly, and it fails closed: a
request without a valid credential is rejected before it reaches any handler.
Accepted credentials:
* the static operator API token (constant-time compared) -> admin scope, or
* a valid HS256 JWT minted by this server -> the token's own scope claim.
Public paths (the health check and the token-issuing endpoint) pass through.
Public prefixes (the UI static shells) also pass through - they serve no data.
On failure: HTTP 401 JSON, or WebSocket close 4401.
On success: the validated principal is attached at scope["state"]["principal"]
(readable downstream as request.state.principal) for scope/ownership checks.
"""
from __future__ import annotations
import json
from typing import Callable, Dict, Iterable, Optional
from urllib.parse import parse_qs
import jwt
from auth import constant_time_eq, decode_token
class AuthGateMiddleware:
def __init__(
self,
app,
*,
token_provider: Callable[[], str],
public_paths: Iterable[str] = (),
public_prefixes: Iterable[str] = (),
):
self.app = app
self._token_provider = token_provider
self.public_paths = set(public_paths)
self.public_prefixes = tuple(public_prefixes)
# ─────────────────────────── ASGI entry ───────────────────────────
async def __call__(self, scope, receive, send):
if scope["type"] not in ("http", "websocket"):
await self.app(scope, receive, send)
return
path = scope.get("path", "")
if path in self.public_paths:
await self.app(scope, receive, send)
return
if self.public_prefixes and path.startswith(self.public_prefixes):
await self.app(scope, receive, send)
return
principal = self._authenticate(scope)
if principal is None:
await self._reject(scope, receive, send)
return
# Expose the principal to downstream handlers/dependencies.
state = scope.setdefault("state", {})
state["principal"] = principal
await self.app(scope, receive, send)
# ──────────────────────────── helpers ─────────────────────────────
def _authenticate(self, scope) -> Optional[Dict]:
token = self._extract_token(scope)
if not token:
return None
# 1) static operator token -> admin scope
static_token = self._token_provider() or ""
if static_token and constant_time_eq(token, static_token):
return {"sub": "operator", "scope": "admin", "via": "api_token"}
# 2) HS256 JWT minted by this server
try:
claims = decode_token(token)
except jwt.InvalidTokenError:
return None
claims.setdefault("scope", "data")
return claims
@staticmethod
def _extract_token(scope) -> Optional[str]:
# Authorization: Bearer <token>
for name, value in scope.get("headers", []):
if name == b"authorization":
raw = value.decode("latin-1")
if raw[:7].lower() == "bearer ":
return raw[7:].strip()
return None
# WebSocket clients that cannot set headers may pass ?token=
if scope["type"] == "websocket":
qs = parse_qs(scope.get("query_string", b"").decode("latin-1"))
vals = qs.get("token")
if vals:
return vals[0]
return None
async def _reject(self, scope, receive, send):
if scope["type"] == "websocket":
# Close before accept; 4401 = application "unauthorized".
await send({"type": "websocket.close", "code": 4401})
return
body = json.dumps({"detail": "Authentication required"}).encode()
await send(
{
"type": "http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"www-authenticate", b"Bearer"),
(b"content-length", str(len(body)).encode()),
],
}
)
await send({"type": "http.response.body", "body": body})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
# Application Configuration
app:
title: "Crawl4AI API"
version: "1.0.0"
# Loopback by default. Exposing on 0.0.0.0 requires a credential
# (CRAWL4AI_API_TOKEN); the startup auth guard refuses an open non-loopback
# bind without one. Put a TLS-terminating reverse proxy in front for exposure.
host: "127.0.0.1"
port: 11235
reload: False
workers: 1
timeout_keep_alive: 300
# Default LLM Configuration
llm:
provider: "openai/gpt-4o-mini"
# api_key: sk-... # If you pass the API key directly (not recommended)
# Redis Configuration
# Set task_ttl_seconds to automatically expire task data in Redis.
# This prevents unbounded memory growth from accumulated task results.
# Default: 3600 (1 hour). Set to 0 to disable TTL (not recommended).
# Can be overridden with REDIS_TASK_TTL environment variable.
redis:
host: "localhost"
port: 6379
db: 0
password: ""
task_ttl_seconds: 3600 # TTL for task data (1 hour default)
ssl: False
ssl_cert_reqs: None
ssl_ca_certs: None
ssl_certfile: None
ssl_keyfile: None
# Resource governance (DoS protection). Any limit set to 0 means "unbounded"
# (the pre-hardening behavior), so you can relax or disable each one.
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
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
per_principal: 0 # max concurrent jobs per caller (429 over cap); 0 = unlimited
# Rate Limiting Configuration
rate_limiting:
enabled: True
default_limit: "1000/minute"
trusted_proxies: []
storage_uri: "memory://" # Use "redis://localhost:6379" for production
# Security Configuration
# WARNING: For production deployments, enable security and use proper SECRET_KEY:
# - Set jwt_enabled: true for authentication
# - Set SECRET_KEY environment variable to a secure random value
# - Set CRAWL4AI_HOOKS_ENABLED=true only if you need hooks (RCE risk)
security:
enabled: true
jwt_enabled: false
api_token: "" # When set, /token endpoint requires this secret to issue JWTs
https_redirect: false
trusted_hosts: ["*"]
cors_allow_origins: [] # deny-by-default; list explicit origins to allow CORS
headers:
x_content_type_options: "nosniff"
x_frame_options: "DENY"
content_security_policy: "default-src 'self'"
strict_transport_security: "max-age=63072000; includeSubDomains"
# Crawler Configuration
crawler:
base_config:
simulate_user: true
memory_threshold_percent: 95.0
rate_limiter:
enabled: true
base_delay: [1.0, 2.0]
timeouts:
stream_init: 30.0 # Timeout for stream initialization
batch_process: 300.0 # Timeout for batch processing
pool:
max_pages: 40 # ← GLOBAL_SEM permits
idle_ttl_sec: 300 # ← 30 min janitor cutoff
browser:
kwargs:
headless: true
text_mode: true
extra_args:
# - "--single-process"
# SECURITY: --no-sandbox disables the Chromium renderer sandbox (a renderer
# exploit can then reach the container). It is required only because the
# container runs as non-root without a usable sandbox. To remove it, run
# the container with an unprivileged user namespace
# (host: unprivileged_userns_clone=1) OR a verified seccomp profile
# (compose: security_opt: seccomp=...), then delete this flag and confirm
# Chromium still starts. Tracked as a build-gated hardening item.
- "--no-sandbox"
- "--disable-dev-shm-usage"
- "--disable-gpu"
- "--disable-software-rasterizer"
# --disable-web-security removed for security (enables cross-origin access)
# --allow-insecure-localhost / --ignore-certificate-errors removed: they
# disabled TLS verification for every crawl (MITM / SSRF-to-internal-TLS).
# Set CRAWL4AI_ALLOW_INSECURE_TLS=true only for trusted internal testing.
# Logging Configuration
logging:
level: "INFO"
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# Observability Configuration
observability:
prometheus:
enabled: True
endpoint: "/metrics"
health_check:
endpoint: "/health"
# Webhook Configuration
webhooks:
enabled: true
default_url: null # Optional: default webhook URL for all jobs
data_in_payload: false # Optional: default behavior for including data
retry:
max_attempts: 5
initial_delay_ms: 1000 # 1s, 2s, 4s, 8s, 16s exponential backoff
max_delay_ms: 32000
timeout_ms: 30000 # 30s timeout per webhook call
headers: # Optional: default headers to include
User-Agent: "Crawl4AI-Webhook/1.0"
+220
View File
@@ -0,0 +1,220 @@
# crawler_pool.py - Smart browser pool with tiered management
import asyncio, json, hashlib, time
from contextlib import suppress
from typing import Dict, Optional
from crawl4ai import AsyncWebCrawler, BrowserConfig
from utils import load_config, get_container_memory_percent
import logging
logger = logging.getLogger(__name__)
CONFIG = load_config()
# Pool tiers
PERMANENT: Optional[AsyncWebCrawler] = None # Always-ready default browser
HOT_POOL: Dict[str, AsyncWebCrawler] = {} # Frequent configs
COLD_POOL: Dict[str, AsyncWebCrawler] = {} # Rare configs
LAST_USED: Dict[str, float] = {}
USAGE_COUNT: Dict[str, int] = {}
LOCK = asyncio.Lock()
# Config
MEM_LIMIT = CONFIG.get("crawler", {}).get("memory_threshold_percent", 95.0)
BASE_IDLE_TTL = CONFIG.get("crawler", {}).get("pool", {}).get("idle_ttl_sec", 300)
DEFAULT_CONFIG_SIG = None # Cached sig for default config
def get_pool_snapshot() -> dict:
"""Return a point-in-time snapshot of pool state for monitoring.
This is intentionally lock-free. Under CPython's GIL, reading
``len(dict)``, ``dict.copy()``, and ``x is not None`` are atomic
operations, so the monitor can safely call this without contending
on the pool LOCK that is held during slow browser start/close ops.
The worst case is a slightly stale count, which is acceptable for
dashboard display purposes.
"""
return {
"permanent": PERMANENT,
"permanent_sig": DEFAULT_CONFIG_SIG,
"hot_pool": HOT_POOL.copy(),
"cold_pool": COLD_POOL.copy(),
"last_used": LAST_USED.copy(),
"usage_count": USAGE_COUNT.copy(),
}
def _sig(cfg: BrowserConfig) -> str:
"""Generate config signature."""
payload = json.dumps(cfg.to_dict(), sort_keys=True, separators=(",",":"))
return hashlib.sha1(payload.encode()).hexdigest()
def _is_default_config(sig: str) -> bool:
"""Check if config matches default."""
return sig == DEFAULT_CONFIG_SIG
async def get_crawler(cfg: BrowserConfig) -> AsyncWebCrawler:
"""Get crawler from pool with tiered strategy."""
sig = _sig(cfg)
async with LOCK:
# Check permanent browser for default config
if PERMANENT and _is_default_config(sig):
LAST_USED[sig] = time.time()
USAGE_COUNT[sig] = USAGE_COUNT.get(sig, 0) + 1
if not hasattr(PERMANENT, 'active_requests'):
PERMANENT.active_requests = 0
PERMANENT.active_requests += 1
logger.info("🔥 Using permanent browser")
return PERMANENT
# Check hot pool
if sig in HOT_POOL:
LAST_USED[sig] = time.time()
USAGE_COUNT[sig] = USAGE_COUNT.get(sig, 0) + 1
crawler = HOT_POOL[sig]
if not hasattr(crawler, 'active_requests'):
crawler.active_requests = 0
crawler.active_requests += 1
logger.info(f"♨️ Using hot pool browser (sig={sig[:8]}, active={crawler.active_requests})")
return crawler
# Check cold pool (promote to hot if used 3+ times)
if sig in COLD_POOL:
LAST_USED[sig] = time.time()
USAGE_COUNT[sig] = USAGE_COUNT.get(sig, 0) + 1
crawler = COLD_POOL[sig]
if not hasattr(crawler, 'active_requests'):
crawler.active_requests = 0
crawler.active_requests += 1
if USAGE_COUNT[sig] >= 3:
logger.info(f"⬆️ Promoting to hot pool (sig={sig[:8]}, count={USAGE_COUNT[sig]})")
HOT_POOL[sig] = COLD_POOL.pop(sig)
# Track promotion in monitor
try:
from monitor import get_monitor
await get_monitor().track_janitor_event("promote", sig, {"count": USAGE_COUNT[sig]})
except:
pass
return HOT_POOL[sig]
logger.info(f"❄️ Using cold pool browser (sig={sig[:8]})")
return crawler
# Memory check before creating new
mem_pct = get_container_memory_percent()
if mem_pct >= MEM_LIMIT:
logger.error(f"💥 Memory pressure: {mem_pct:.1f}% >= {MEM_LIMIT}%")
raise MemoryError(f"Memory at {mem_pct:.1f}%, refusing new browser")
# Create new in cold pool
logger.info(f"🆕 Creating new browser in cold pool (sig={sig[:8]}, mem={mem_pct:.1f}%)")
crawler = AsyncWebCrawler(config=cfg, thread_safe=False)
await crawler.start()
crawler.active_requests = 1
COLD_POOL[sig] = crawler
LAST_USED[sig] = time.time()
USAGE_COUNT[sig] = 1
return crawler
async def release_crawler(crawler: AsyncWebCrawler):
"""Decrement active request count for a pooled crawler.
Call this in a finally block after finishing work with a crawler
obtained via get_crawler() so the janitor knows when it's safe
to close idle browsers.
"""
async with LOCK:
if hasattr(crawler, 'active_requests'):
crawler.active_requests = max(0, crawler.active_requests - 1)
async def init_permanent(cfg: BrowserConfig):
"""Initialize permanent default browser."""
global PERMANENT, DEFAULT_CONFIG_SIG
async with LOCK:
if PERMANENT:
return
DEFAULT_CONFIG_SIG = _sig(cfg)
logger.info("🔥 Creating permanent default browser")
PERMANENT = AsyncWebCrawler(config=cfg, thread_safe=False)
await PERMANENT.start()
LAST_USED[DEFAULT_CONFIG_SIG] = time.time()
USAGE_COUNT[DEFAULT_CONFIG_SIG] = 0
async def close_all():
"""Close all browsers."""
async with LOCK:
tasks = []
if PERMANENT:
tasks.append(PERMANENT.close())
tasks.extend([c.close() for c in HOT_POOL.values()])
tasks.extend([c.close() for c in COLD_POOL.values()])
await asyncio.gather(*tasks, return_exceptions=True)
HOT_POOL.clear()
COLD_POOL.clear()
LAST_USED.clear()
USAGE_COUNT.clear()
async def janitor():
"""Adaptive cleanup based on memory pressure."""
while True:
mem_pct = get_container_memory_percent()
# Adaptive intervals and TTLs
if mem_pct > 80:
interval, cold_ttl, hot_ttl = 10, 30, 120
elif mem_pct > 60:
interval, cold_ttl, hot_ttl = 30, 60, 300
else:
interval, cold_ttl, hot_ttl = 60, BASE_IDLE_TTL, BASE_IDLE_TTL * 2
await asyncio.sleep(interval)
now = time.time()
async with LOCK:
# Clean cold pool
for sig in list(COLD_POOL.keys()):
if now - LAST_USED.get(sig, now) > cold_ttl:
crawler = COLD_POOL[sig]
if getattr(crawler, 'active_requests', 0) > 0:
continue # still serving requests, skip
idle_time = now - LAST_USED[sig]
logger.info(f"🧹 Closing cold browser (sig={sig[:8]}, idle={idle_time:.0f}s)")
with suppress(Exception):
await crawler.close()
COLD_POOL.pop(sig, None)
LAST_USED.pop(sig, None)
USAGE_COUNT.pop(sig, None)
# Track in monitor
try:
from monitor import get_monitor
await get_monitor().track_janitor_event("close_cold", sig, {"idle_seconds": int(idle_time), "ttl": cold_ttl})
except:
pass
# Clean hot pool (more conservative)
for sig in list(HOT_POOL.keys()):
if now - LAST_USED.get(sig, now) > hot_ttl:
crawler = HOT_POOL[sig]
if getattr(crawler, 'active_requests', 0) > 0:
continue # still serving requests, skip
idle_time = now - LAST_USED[sig]
logger.info(f"🧹 Closing hot browser (sig={sig[:8]}, idle={idle_time:.0f}s)")
with suppress(Exception):
await crawler.close()
HOT_POOL.pop(sig, None)
LAST_USED.pop(sig, None)
USAGE_COUNT.pop(sig, None)
# Track in monitor
try:
from monitor import get_monitor
await get_monitor().track_janitor_event("close_hot", sig, {"idle_seconds": int(idle_time), "ttl": hot_ttl})
except:
pass
# Log pool stats
if mem_pct > 60:
logger.info(f"📊 Pool: hot={len(HOT_POOL)}, cold={len(COLD_POOL)}, mem={mem_pct:.1f}%")
+209
View File
@@ -0,0 +1,209 @@
"""
egress_broker.py - the single rule and primitive for all outbound traffic.
Every SSRF defense before this was enumerate-badness (a hand-maintained list of
blocked CIDRs) and resolve-then-discard: the validator resolved a hostname,
checked the IP against the list, then threw the IP away and let the real
connection re-resolve (DNS rebinding / TOCTOU). It also missed whole families:
the IPv6 unspecified ::, NAT64 64:ff9b::/96, 6to4 2002::/16, and
host.docker.internal as anything but a literal prefix.
This module replaces the blocklist with one rule:
reject any resolved IP where `not ip.is_global`
evaluated on every transition-embedded IPv4 form as well (v4-mapped, NAT64,
6to4, v4-compatible), and makes the entity that resolves DNS the same entity
that hands back the pinned IP to dial. `resolve_and_pin()` resolves once and
returns the exact IP to connect to; callers must dial that IP (Host/SNI
preserved) so a second, attacker-controlled resolution is never used.
Errors are opaque (`EgressBlocked.reason == "URL blocked"`) so the API never
leaks a resolved internal IP, hostname, or traceback (the old DNS oracle).
"""
from __future__ import annotations
import ipaddress
import os
import socket
from dataclasses import dataclass
from typing import List, Optional
from urllib.parse import urlparse
# Operator escape hatch for trusted internal deployments (off by default).
ALLOW_INTERNAL = os.environ.get("CRAWL4AI_ALLOW_INTERNAL_URLS", "false").lower() == "true"
_NAT64 = ipaddress.ip_network("64:ff9b::/96")
_V4COMPAT = ipaddress.ip_network("::/96")
_6TO4 = ipaddress.ip_network("2002::/16")
# Hostnames we refuse regardless of resolution (belt-and-suspenders; they also
# resolve to non-global addresses and would be caught anyway).
_BLOCKED_HOSTNAMES = {
"localhost", "metadata.google.internal", "metadata",
"kubernetes.default", "kubernetes.default.svc",
}
class EgressBlocked(Exception):
"""Outbound target rejected. Carries an OPAQUE reason - never an IP/host."""
def __init__(self, reason: str = "URL blocked"):
self.reason = reason
super().__init__(reason)
@dataclass
class PinnedTarget:
scheme: str
host: str # original hostname (for Host header / SNI)
port: int
ip: str # the exact IP to dial - do NOT re-resolve `host`
def _embedded_v4_forms(ip: ipaddress._BaseAddress) -> List[ipaddress._BaseAddress]:
"""The address plus any IPv4 embedded in a transition IPv6 form.
Only the well-defined transition ranges are unwrapped, so a normal global
IPv6 is never mis-derived into a bogus (and possibly non-global) IPv4.
"""
forms: List[ipaddress._BaseAddress] = [ip]
if isinstance(ip, ipaddress.IPv6Address):
mapped = ip.ipv4_mapped
if mapped is not None:
forms.append(mapped)
elif ip in _NAT64 or ip in _V4COMPAT:
forms.append(ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF))
elif ip in _6TO4:
forms.append(ipaddress.IPv4Address((int(ip) >> 80) & 0xFFFFFFFF))
return forms
def is_forbidden_ip(ip_str: str) -> bool:
"""True if the IP (or any embedded transition form) is not globally routable."""
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return True
return any(not form.is_global for form in _embedded_v4_forms(ip))
def _resolve(host: str, port: int):
try:
return socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
except socket.gaierror:
raise EgressBlocked()
def assert_host_allowed(host: str, port: int = 0) -> None:
"""Resolve `host` and reject if ANY answer is non-global. Opaque on failure."""
if ALLOW_INTERNAL:
return
if not host:
raise EgressBlocked()
low = host.lower()
if low in _BLOCKED_HOSTNAMES or low.startswith("host.docker.internal"):
raise EgressBlocked()
for *_, sockaddr in _resolve(host, port):
if is_forbidden_ip(sockaddr[0]):
raise EgressBlocked()
def resolve_and_pin(url: str) -> PinnedTarget:
"""Resolve `url` once, reject if any answer is non-global, and pin one IP.
The returned PinnedTarget.ip is the address the caller must dial; resolving
`host` again at connect time would reopen the rebinding hole.
"""
parsed = urlparse(str(url))
scheme = (parsed.scheme or "").lower()
if scheme not in ("http", "https"):
raise EgressBlocked()
host = parsed.hostname
if not host:
raise EgressBlocked()
port = parsed.port or (443 if scheme == "https" else 80)
if ALLOW_INTERNAL:
# Still resolve so we can pin, but skip the global check.
answers = _resolve(host, port)
return PinnedTarget(scheme, host, port, answers[0][4][0])
low = host.lower()
if low in _BLOCKED_HOSTNAMES or low.startswith("host.docker.internal"):
raise EgressBlocked()
answers = _resolve(host, port)
pinned = None
for *_, sockaddr in answers:
if is_forbidden_ip(sockaddr[0]):
# Reject the host outright if ANY of its records is internal.
raise EgressBlocked()
if pinned is None:
pinned = sockaddr[0]
if pinned is None:
raise EgressBlocked()
return PinnedTarget(scheme, host, port, pinned)
def check_redirect(location: str) -> PinnedTarget:
"""Re-validate (and pin) a redirect Location. Same rule as the initial hop."""
return resolve_and_pin(location)
ALLOW_INSECURE_TLS = os.environ.get("CRAWL4AI_ALLOW_INSECURE_TLS", "false").lower() == "true"
# URL of the localhost pinning forward-proxy (egress_proxy.py), set at boot.
# When present, enforce_egress routes the browser through it so Chromium never
# resolves the target itself (closes DNS rebinding on the browser path).
_EGRESS_PROXY_URL: Optional[str] = None
def set_egress_proxy(url: Optional[str]) -> None:
global _EGRESS_PROXY_URL
_EGRESS_PROXY_URL = url
def get_egress_proxy() -> Optional[str]:
return _EGRESS_PROXY_URL
# Chromium flags that would re-route or weaken egress; scrubbed server-side.
_DANGEROUS_BROWSER_ARGS = (
"--proxy-server", "--proxy-pac-url", "--proxy-bypass-list",
"--host-resolver-rules", "--ignore-certificate-errors",
"--allow-insecure-localhost",
)
def enforce_egress(browser_config) -> None:
"""Server-side egress hardening applied to the effective browser config.
R2 already forbids untrusted bodies from setting proxy/extra_args, so this
is defense in depth that also covers server/SDK-built configs:
- TLS verification ON (ignore_https_errors=False) unless the operator
opts into CRAWL4AI_ALLOW_INSECURE_TLS;
- no caller proxy (the key/SSRF redirect vector);
- strip any proxy/TLS-weakening Chromium launch flags.
"""
if browser_config is None:
return
if not ALLOW_INSECURE_TLS and hasattr(browser_config, "ignore_https_errors"):
browser_config.ignore_https_errors = False
# Drop any caller proxy, then route the browser through the pinning proxy so
# Chromium never resolves the target host itself (DNS-rebinding control).
for attr in ("proxy", "proxy_config"):
if getattr(browser_config, attr, None) is not None:
setattr(browser_config, attr, None)
if _EGRESS_PROXY_URL and hasattr(browser_config, "proxy_config"):
try:
from crawl4ai import ProxyConfig
browser_config.proxy_config = ProxyConfig(server=_EGRESS_PROXY_URL)
except Exception:
pass
args = getattr(browser_config, "extra_args", None)
if args:
browser_config.extra_args = [
a for a in args
if not any(str(a).startswith(bad) for bad in _DANGEROUS_BROWSER_ARGS)
]
+212
View File
@@ -0,0 +1,212 @@
"""
egress_proxy.py - localhost pinning forward-proxy for the browser.
context.route() sees URLs, not IPs, so it cannot stop DNS rebinding: Chromium
resolves the target host itself at connect time, and an attacker can answer
"public" to our up-front validation and "169.254.169.254" to the browser.
This proxy is the real control. Chromium is pointed at it (proxy_config), so it
never resolves the target itself - it asks us to CONNECT host:port. We run the
single egress rule (egress_broker.resolve_and_pin: resolve once, reject any
non-global IP, pin one IP), dial the PINNED IP ourselves, and splice raw bytes.
TLS stays end-to-end (we tunnel ciphertext; Chromium verifies the cert/SNI
against the real host - no MITM).
Bound to 127.0.0.1 on an ephemeral port; started at server boot.
"""
from __future__ import annotations
import asyncio
import logging
from urllib.parse import urlsplit
from egress_broker import EgressBlocked, resolve_and_pin
logger = logging.getLogger("crawl4ai.egress")
_CONNECT_OK = b"HTTP/1.1 200 Connection established\r\n\r\n"
_BLOCKED = b"HTTP/1.1 403 Forbidden\r\nContent-Length: 11\r\n\r\nURL blocked"
_BAD = b"HTTP/1.1 400 Bad Request\r\nContent-Length: 11\r\n\r\nBad Request"
_MAX_HEADER_BYTES = 64 * 1024
class PinningProxy:
"""Async HTTP forward-proxy that connects only to pinned, global IPs."""
def __init__(self, host: str = "127.0.0.1", port: int = 0):
self._host = host
self._port = port
self._server: asyncio.AbstractServer | None = None
self.bound_host: str | None = None
self.bound_port: int | None = None
@property
def url(self) -> str | None:
if self.bound_port is None:
return None
return f"http://{self.bound_host}:{self.bound_port}"
async def start(self) -> str:
self._server = await asyncio.start_server(self._handle, self._host, self._port)
sock = self._server.sockets[0]
self.bound_host, self.bound_port = sock.getsockname()[:2]
logger.info("egress pinning proxy listening on %s", self.url)
return self.url
async def stop(self) -> None:
if self._server is not None:
self._server.close()
try:
await self._server.wait_closed()
except Exception:
pass
# ─────────────────────────── connection handling ───────────────────────────
async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
try:
request_line = await asyncio.wait_for(reader.readline(), timeout=30)
if not request_line:
return
parts = request_line.split()
if len(parts) < 3:
await self._reply(writer, _BAD)
return
method = parts[0].decode("latin-1", "replace").upper()
target = parts[1].decode("latin-1", "replace")
if method == "CONNECT":
await self._handle_connect(target, reader, writer)
else:
await self._handle_absolute(method, target, request_line, reader, writer)
except asyncio.TimeoutError:
await self._reply(writer, _BAD)
except Exception as e:
logger.debug("proxy connection error: %s", e)
await self._safe_close(writer)
async def _handle_connect(self, target, client_reader, client_writer):
# target is "host:port"
host, _, port_s = target.rpartition(":")
if not host or not port_s.isdigit():
await self._reply(client_writer, _BAD)
return
try:
pin = resolve_and_pin(f"https://{host}:{port_s}")
except EgressBlocked:
await self._reply(client_writer, _BLOCKED)
return
# Drain the rest of the client's CONNECT headers.
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
)
except Exception:
await self._reply(client_writer, _BLOCKED)
return
client_writer.write(_CONNECT_OK)
await client_writer.drain()
await self._splice(client_reader, client_writer, up_reader, up_writer)
async def _handle_absolute(self, method, target, request_line, client_reader, client_writer):
# Plain HTTP proxying: target is an absolute URI "http://host/path".
sp = urlsplit(target)
if sp.scheme != "http" or not sp.hostname:
await self._reply(client_writer, _BAD)
return
port = sp.port or 80
try:
pin = resolve_and_pin(f"http://{sp.hostname}:{port}")
except EgressBlocked:
await self._reply(client_writer, _BLOCKED)
return
headers = await self._read_headers(client_reader)
path = sp.path or "/"
if sp.query:
path += "?" + sp.query
try:
up_reader, up_writer = await asyncio.wait_for(
asyncio.open_connection(pin.ip, port), 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")
out += b"Host: " + sp.hostname.encode("latin-1")
if sp.port:
out += f":{sp.port}".encode("latin-1")
out += b"\r\n" + headers + b"\r\n"
up_writer.write(out)
await up_writer.drain()
await self._splice(client_reader, client_writer, up_reader, up_writer)
# ─────────────────────────── helpers ───────────────────────────
async def _drain_headers(self, reader):
read = 0
while True:
line = await asyncio.wait_for(reader.readline(), timeout=30)
read += len(line)
if line in (b"\r\n", b"\n", b""):
return
if read > _MAX_HEADER_BYTES:
return
async def _read_headers(self, reader) -> bytes:
buf = b""
while True:
line = await asyncio.wait_for(reader.readline(), timeout=30)
if line in (b"\r\n", b"\n", b""):
break
buf += line
if len(buf) > _MAX_HEADER_BYTES:
break
# strip any proxy-only / connection headers
kept = []
for ln in buf.split(b"\r\n"):
name = ln.split(b":", 1)[0].strip().lower()
if name in (b"proxy-connection", b"proxy-authorization", b"host"):
continue
if ln:
kept.append(ln)
return (b"\r\n".join(kept) + b"\r\n") if kept else b""
async def _splice(self, c_reader, c_writer, u_reader, u_writer):
async def pipe(src, dst):
try:
while True:
data = await src.read(65536)
if not data:
break
dst.write(data)
await dst.drain()
except Exception:
pass
finally:
await self._safe_close(dst)
await asyncio.gather(
pipe(c_reader, u_writer),
pipe(u_reader, c_writer),
)
async def _reply(self, writer, payload: bytes):
try:
writer.write(payload)
await writer.drain()
except Exception:
pass
await self._safe_close(writer)
@staticmethod
async def _safe_close(writer):
try:
if not writer.is_closing():
writer.close()
except Exception:
pass
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# entrypoint.sh - resolve the socket-level egress/auth posture before supervisord.
#
# This is the authoritative socket-level guard (gunicorn binds the socket, not
# Python). It must agree with the in-process _resolve_auth() check in server.py.
set -euo pipefail
# --- Redis password: prefer a mounted secret, else an existing env var. ------
if [[ -z "${REDIS_PASSWORD:-}" && -f /run/secrets/redis_password ]]; then
REDIS_PASSWORD="$(cat /run/secrets/redis_password)"
fi
if [[ -z "${REDIS_PASSWORD:-}" ]]; then
# Generate an ephemeral in-container password so redis is never open even
# if the operator forgot to mount one. (Loopback + requirepass.)
REDIS_PASSWORD="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
echo "entrypoint: no REDIS_PASSWORD provided; generated an ephemeral one." >&2
fi
export REDIS_PASSWORD
# --- API token: prefer a mounted secret, else an existing env var. -----------
if [[ -z "${CRAWL4AI_API_TOKEN:-}" && -f /run/secrets/api_token ]]; then
export CRAWL4AI_API_TOKEN="$(cat /run/secrets/api_token)"
fi
# --- Bind resolution: loopback unless a credential is present. ---------------
PORT="${CRAWL4AI_PORT:-11235}"
if [[ -n "${CRAWL4AI_API_TOKEN:-}" || "${CRAWL4AI_JWT_ENABLED:-false}" == "true" ]]; then
# A credential is configured -> the operator may expose all interfaces.
GUNICORN_BIND="${GUNICORN_BIND:-[::]:${PORT}}"
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
fi
export GUNICORN_BIND
exec supervisord -c supervisord.conf --pidfile /tmp/supervisord.pid
+111
View File
@@ -0,0 +1,111 @@
"""
governor.py - server-enforced resource governance (R5).
The deep-crawl DoS via a client-supplied unbounded max_pages is already closed
upstream: R2 forbids `deep_crawl_strategy` on an untrusted request body, and the
`urls` list is capped at 100 by the request schema. This module adds the two
remaining verifiable chokepoints:
* a request body-size limit (ASGI middleware) so a giant body / inline `raw:`
HTML cannot be buffered and processed in-process -> 413;
* clamp_deep_crawl(): defense in depth for any *trusted* / server-built config
that still carries a deep_crawl strategy with an unbounded page/depth count.
Heavier governance (bounded work queue replacing BackgroundTasks, per-principal
Redis quotas, wall-clock deadlines, stream decoupling) is left for the
integration-tested pass; gunicorn --limit-request-* covers the transport layer.
"""
from __future__ import annotations
import json
import math
DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024 # 10 MiB
DEFAULT_MAX_PAGES = 100
DEFAULT_MAX_DEPTH = 5
class BodySizeLimitMiddleware:
"""Reject HTTP requests whose declared Content-Length exceeds the limit.
(Chunked/unknown-length bodies are additionally bounded at the transport by
gunicorn --limit-request-* in the hardened deployment.)
"""
def __init__(self, app, max_bytes: int = DEFAULT_MAX_BODY_BYTES):
self.app = app
self.max_bytes = max_bytes
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
for name, value in scope.get("headers", []):
if name == b"content-length":
try:
if int(value) > self.max_bytes:
await self._reject(send)
return
except ValueError:
pass
break
await self.app(scope, receive, send)
async def _reject(self, send):
body = json.dumps({"detail": "Request body too large"}).encode()
await send({
"type": "http.response.start",
"status": 413,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode()),
],
})
await send({"type": "http.response.body", "body": body})
def clamp_deep_crawl(crawler_config, *, max_pages: int = DEFAULT_MAX_PAGES,
max_depth: int = DEFAULT_MAX_DEPTH) -> None:
"""Clamp an attached deep-crawl strategy's page/depth budget in place.
Defense in depth: untrusted bodies cannot set deep_crawl_strategy at all
(R2), but a server/base config might, and the library default for max_pages
is infinity.
"""
dc = getattr(crawler_config, "deep_crawl_strategy", None)
if dc is None:
return
mp = getattr(dc, "max_pages", None)
if mp is None or (isinstance(mp, float) and math.isinf(mp)) or mp > max_pages:
try:
dc.max_pages = max_pages
except Exception:
pass
md = getattr(dc, "max_depth", None)
if md is None or md > max_depth:
try:
dc.max_depth = max_depth
except Exception:
pass
def max_body_bytes_from_config(config: dict) -> int:
return int((config.get("limits", {}) or {}).get("max_body_bytes", DEFAULT_MAX_BODY_BYTES))
def _limits(config: dict) -> dict:
return config.get("limits", {}) or {}
def wall_clock_seconds(config: dict) -> float:
"""Per-crawl wall-clock deadline in seconds; 0 (default) => no deadline."""
return float(_limits(config).get("wall_clock_s", 0) or 0)
def job_queue_caps(config: dict) -> dict:
"""Bounded-job-queue settings; 0 => unbounded/unlimited (current behavior)."""
q = _limits(config).get("queue", {}) or {}
return {
"maxsize": int(q.get("maxsize", 1000) or 0),
"workers": int(q.get("workers", 4) or 1),
"per_principal": int(q.get("per_principal", 0) or 0),
}
+244
View File
@@ -0,0 +1,244 @@
"""
Declarative hook registry - the safe replacement for the exec-based hook system.
The old hook_manager.py compiled and exec()'d user-supplied Python at crawl hook
points. Its sandbox was unsound (escapable via __subclasses__ MRO walks, injected
module __globals__, frame inspection), giving unauthenticated RCE when hooks were
enabled. There is no safe way to run attacker Python in-process.
Instead, a request may only choose from a fixed set of declarative ACTIONS whose
parameters are schema-validated scalars. Each action maps to a server-authored
async function that calls exactly one specific Playwright API - no user string
ever reaches an interpreter. This covers the documented hook use cases (block
assets, inject auth cookies/headers, scroll for lazy content, wait).
Power users who genuinely need arbitrary hook code use a self-hosted in-process
build where crawler_strategy.set_hook(...) remains available and trusted.
"""
from __future__ import annotations
import re
from typing import Any, Callable, Dict, List
from pydantic import BaseModel, Field, field_validator
class HookValidationError(ValueError):
"""A declarative hook spec was invalid. The Docker layer maps this to 400."""
_HEADER_NAME_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$")
_ALLOWED_RESOURCE_TYPES = {"image", "stylesheet", "font", "media"}
_MAX_SCROLL_STEPS = 50
_MAX_SCROLL_DELAY_MS = 5000
_MAX_WAIT_MS = 60_000
_MAX_COOKIES = 20
_MAX_HEADERS = 20
# ───────────────────────── per-action parameter schemas ─────────────────────────
class BlockResourcesParams(BaseModel):
resource_types: List[str] = Field(..., min_length=1)
@field_validator("resource_types")
@classmethod
def _check(cls, v):
bad = sorted(set(v) - _ALLOWED_RESOURCE_TYPES)
if bad:
raise ValueError(f"unsupported resource_types {bad}; allowed: {sorted(_ALLOWED_RESOURCE_TYPES)}")
return v
class _Cookie(BaseModel):
name: str = Field(..., min_length=1, max_length=256)
value: str = Field(..., max_length=4096)
domain: str = Field(..., min_length=1, max_length=253)
path: str = "/"
secure: bool = True
httpOnly: bool = False
class AddCookiesParams(BaseModel):
cookies: List[_Cookie] = Field(..., min_length=1, max_length=_MAX_COOKIES)
class SetHeadersParams(BaseModel):
headers: Dict[str, str]
@field_validator("headers")
@classmethod
def _check(cls, v):
if len(v) > _MAX_HEADERS:
raise ValueError(f"too many headers (max {_MAX_HEADERS})")
for name, value in v.items():
if not _HEADER_NAME_RE.match(name):
raise ValueError(f"invalid header name {name!r}")
if any(c in value for c in "\r\n\x00"):
raise ValueError(f"control characters in value for header {name!r}")
return v
class ScrollToBottomParams(BaseModel):
max_steps: int = Field(10, ge=1, le=_MAX_SCROLL_STEPS)
delay_ms: int = Field(500, ge=0, le=_MAX_SCROLL_DELAY_MS)
class WaitForTimeoutParams(BaseModel):
timeout_ms: int = Field(..., ge=0, le=_MAX_WAIT_MS)
# ───────────────────────── server-authored hook factories ─────────────────────────
def _factory_block_resources(p: BlockResourcesParams):
types = set(p.resource_types)
async def hook(page, **kwargs):
context = kwargs.get("context")
async def _route(route):
try:
if route.request.resource_type in types:
await route.abort()
else:
await route.continue_()
except Exception:
# never let a routing decision crash the crawl
await route.continue_()
if context is not None:
await context.route("**/*", _route)
return page
return hook
def _factory_add_cookies(p: AddCookiesParams):
cookies = [c.model_dump() for c in p.cookies]
async def hook(page, **kwargs):
context = kwargs.get("context")
if context is not None:
await context.add_cookies(cookies)
return page
return hook
def _factory_set_headers(p: SetHeadersParams):
headers = dict(p.headers)
async def hook(page, **kwargs):
await page.set_extra_http_headers(headers)
return page
return hook
def _factory_scroll_to_bottom(p: ScrollToBottomParams):
max_steps, delay_ms = p.max_steps, p.delay_ms
async def hook(page, **kwargs):
for _ in range(max_steps):
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
if delay_ms:
await page.wait_for_timeout(delay_ms)
return page
return hook
def _factory_wait_for_timeout(p: WaitForTimeoutParams):
timeout_ms = p.timeout_ms
async def hook(page, **kwargs):
await page.wait_for_timeout(timeout_ms)
return page
return hook
# action name -> (hook_point, params model, factory, human description)
HOOK_REGISTRY: Dict[str, dict] = {
"block_resources": {
"hook_point": "on_page_context_created",
"params_model": BlockResourcesParams,
"factory": _factory_block_resources,
"description": "Abort matching resource types (image/stylesheet/font/media).",
},
"add_cookies": {
"hook_point": "on_page_context_created",
"params_model": AddCookiesParams,
"factory": _factory_add_cookies,
"description": "Add cookies to the browser context before navigation (auth).",
},
"set_headers": {
"hook_point": "before_goto",
"params_model": SetHeadersParams,
"factory": _factory_set_headers,
"description": "Set extra HTTP request headers before navigating.",
},
"scroll_to_bottom": {
"hook_point": "before_retrieve_html",
"params_model": ScrollToBottomParams,
"factory": _factory_scroll_to_bottom,
"description": "Scroll to the page bottom in bounded steps (lazy-load).",
},
"wait_for_timeout": {
"hook_point": "before_retrieve_html",
"params_model": WaitForTimeoutParams,
"factory": _factory_wait_for_timeout,
"description": "Wait a bounded number of milliseconds before retrieving HTML.",
},
}
def build_declarative_hooks(specs: List[Any]) -> Dict[str, Callable]:
"""Validate declarative hook specs and return {hook_point: composed async hook}.
Each spec is an object/dict with `action` and `params`. Multiple specs that
target the same hook point are composed and run in order. Raises
HookValidationError on an unknown action or invalid params.
"""
if not specs:
return {}
if len(specs) > 10:
raise HookValidationError("too many hooks (max 10)")
grouped: Dict[str, List[Callable]] = {}
for spec in specs:
action = spec.get("action") if isinstance(spec, dict) else getattr(spec, "action", None)
raw_params = (spec.get("params", {}) if isinstance(spec, dict) else getattr(spec, "params", {})) or {}
entry = HOOK_REGISTRY.get(action)
if entry is None:
raise HookValidationError(
f"unknown hook action {action!r}; allowed: {sorted(HOOK_REGISTRY)}"
)
try:
params = entry["params_model"](**raw_params)
except Exception as e:
raise HookValidationError(f"invalid params for hook '{action}': {e}")
sub_hook = entry["factory"](params)
grouped.setdefault(entry["hook_point"], []).append(sub_hook)
hooks: Dict[str, Callable] = {}
for hook_point, sub_hooks in grouped.items():
def _compose(sub_hooks):
async def composed(page, **kwargs):
for fn in sub_hooks:
await fn(page, **kwargs)
return page
return composed
hooks[hook_point] = _compose(sub_hooks)
return hooks
def describe_registry() -> dict:
"""Enumerate the available declarative actions for /hooks/info."""
return {
action: {
"hook_point": entry["hook_point"],
"description": entry["description"],
"params_schema": entry["params_model"].model_json_schema(),
}
for action, entry in HOOK_REGISTRY.items()
}
+150
View File
@@ -0,0 +1,150 @@
"""
Job endpoints (enqueue + poll) for long-running LLM extraction and raw crawl.
Relies on the existing Redis task helpers in api.py
"""
from typing import Dict, Optional, Callable
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from pydantic import BaseModel, HttpUrl
from api import (
handle_llm_request,
handle_crawl_job,
handle_task_status,
)
from auth import get_principal
from schemas import WebhookConfig
# ------------- dependency placeholders -------------
_redis = None # will be injected from server.py
_config = None
_token_dep: Callable = lambda: None # dummy until injected
# public router
router = APIRouter()
def _principal_dep(request: Request) -> Optional[Dict]:
"""The principal the AuthGateMiddleware already validated for this request."""
return get_principal(request)
def _owner_of(principal: Optional[Dict]) -> Optional[str]:
return principal.get("sub") if principal else None
def _is_admin(principal: Optional[Dict]) -> bool:
return bool(principal) and principal.get("scope") == "admin"
# === init hook called by server.py =========================================
def init_job_router(redis, config, token_dep) -> APIRouter:
"""Inject shared singletons and return the router for mounting."""
global _redis, _config, _token_dep
_redis, _config, _token_dep = redis, config, token_dep
return router
# ---------- payload models --------------------------------------------------
class LlmJobPayload(BaseModel):
url: HttpUrl
q: str
schema: Optional[str] = None
cache: bool = False
provider: Optional[str] = None
webhook_config: Optional[WebhookConfig] = None
temperature: Optional[float] = None
# base_url removed: server-derived LLM endpoint only (key-exfil vector).
class CrawlJobPayload(BaseModel):
urls: list[HttpUrl]
browser_config: Dict = {}
crawler_config: Dict = {}
webhook_config: Optional[WebhookConfig] = None
# ---------- LLM job ---------------------------------------------------------
@router.post("/llm/job", status_code=202)
async def llm_job_enqueue(
payload: LlmJobPayload,
background_tasks: BackgroundTasks,
request: Request,
_td: Optional[Dict] = Depends(_principal_dep),
):
webhook_config = None
if payload.webhook_config:
from utils import validate_webhook_url
try:
validate_webhook_url(str(payload.webhook_config.webhook_url))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
webhook_config = payload.webhook_config.model_dump(mode='json')
return await handle_llm_request(
_redis,
background_tasks,
request,
str(payload.url),
query=payload.q,
schema=payload.schema,
cache=payload.cache,
config=_config,
provider=payload.provider,
webhook_config=webhook_config,
temperature=payload.temperature,
requester=_owner_of(_td),
is_admin=_is_admin(_td),
)
@router.get("/llm/job/{task_id}")
async def llm_job_status(
request: Request,
task_id: str,
_td: Optional[Dict] = Depends(_principal_dep),
):
return await handle_task_status(
_redis, task_id, base_url=str(request.base_url),
requester=_owner_of(_td), is_admin=_is_admin(_td),
)
# ---------- CRAWL job -------------------------------------------------------
@router.post("/crawl/job", status_code=202)
async def crawl_job_enqueue(
payload: CrawlJobPayload,
background_tasks: BackgroundTasks,
_td: Optional[Dict] = Depends(_principal_dep),
):
webhook_config = None
if payload.webhook_config:
from utils import validate_webhook_url
try:
validate_webhook_url(str(payload.webhook_config.webhook_url))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
webhook_config = payload.webhook_config.model_dump(mode='json')
return await handle_crawl_job(
_redis,
background_tasks,
[str(u) for u in payload.urls],
payload.browser_config,
payload.crawler_config,
config=_config,
webhook_config=webhook_config,
owner=_owner_of(_td),
)
@router.get("/crawl/job/{task_id}")
async def crawl_job_status(
request: Request,
task_id: str,
_td: Optional[Dict] = Depends(_principal_dep),
):
return await handle_task_status(
_redis, task_id, base_url=str(request.base_url),
requester=_owner_of(_td), is_admin=_is_admin(_td),
)
+64
View File
@@ -0,0 +1,64 @@
"""
llm_broker.py - server-side LLM provider resolution.
The credential-exfil gadget (reported by Geo): a request could pass its own
`base_url` (and/or `provider`), and the server would happily send the
configured provider API key to that attacker-controlled endpoint, leaking
*every* provider key the server holds.
The fix: a request may select a provider BY NAME only (and only from an
allowlisted family). The `base_url` and `api_token` are ALWAYS derived
server-side from config/environment and are never taken from the request, so a
key can never be redirected to an attacker host.
"""
from __future__ import annotations
from typing import Dict, Optional
class LLMProviderNotAllowed(ValueError):
"""The requested LLM provider is not in the server's allowlist (-> HTTP 400)."""
def _family(provider: Optional[str]) -> str:
return (provider or "").split("/")[0].lower()
def allowed_provider_families(config: Dict) -> set:
"""Provider families a request may select.
Sourced from config.llm.allowed_providers plus the default provider's
family. Empty set => unrestricted family selection (base_url/key are still
server-only, so the exfil path stays closed); set a non-empty
allowed_providers to lock it down further.
"""
cfg = config.get("llm", {}) or {}
fams = {_family(p) for p in (cfg.get("allowed_providers") or [])}
if cfg.get("provider"):
fams.add(_family(cfg["provider"]))
return {f for f in fams if f}
def resolve_llm(config: Dict, requested_provider: Optional[str] = None) -> Dict:
"""Resolve the LLM call parameters fully server-side.
A request-supplied base_url/api_token is intentionally NOT a parameter here:
callers pass only the provider *name*. Returns {provider, base_url,
api_token, temperature}, all server-derived.
"""
from utils import get_llm_api_key, get_llm_base_url, get_llm_temperature
default = config["llm"]["provider"]
provider = requested_provider or default
fams = allowed_provider_families(config)
if fams and _family(provider) not in fams:
raise LLMProviderNotAllowed("LLM provider not allowed")
return {
"provider": provider,
"base_url": get_llm_base_url(config, provider), # canonical, never from request
"api_token": get_llm_api_key(config, provider), # server credential
"temperature": get_llm_temperature(config, provider),
}
+273
View File
@@ -0,0 +1,273 @@
# deploy/docker/mcp_bridge.py
from __future__ import annotations
import inspect, json, re, anyio
from contextlib import suppress
from typing import Any, Callable, Dict, List, Tuple
import httpx
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from starlette.routing import Route, Mount
from mcp.server.sse import SseServerTransport
import mcp.types as t
from mcp.server.lowlevel.server import Server, NotificationOptions
from mcp.server.models import InitializationOptions
# ── optin decorators ───────────────────────────────────────────
def mcp_resource(name: str | None = None):
def deco(fn):
fn.__mcp_kind__, fn.__mcp_name__ = "resource", name
return fn
return deco
def mcp_template(name: str | None = None):
def deco(fn):
fn.__mcp_kind__, fn.__mcp_name__ = "template", name
return fn
return deco
def mcp_tool(name: str | None = None):
def deco(fn):
fn.__mcp_kind__, fn.__mcp_name__ = "tool", name
return fn
return deco
# ── HTTPproxy helper for FastAPI endpoints ─────────────────────
def _service_auth_headers() -> dict:
"""Authenticate the internal loopback call to our own gated endpoints.
The MCP transport is now behind the AuthGateMiddleware, so by the time a
tool is invoked the MCP client is already authenticated. The loopback HTTP
call this proxy makes must therefore carry a credential too, or the gate
would 401 it. We mint a short-lived, data-scope service token: MCP exposes
only data-plane tools (no admin/monitor actions), so this grants no
privilege escalation. (Requires a shared SECRET_KEY across workers, which
the auth startup check already mandates for any real deployment.)
"""
from auth import create_access_token
token = create_access_token({"sub": "mcp-service"}, scope="data")
return {"Authorization": f"Bearer {token}"}
def _make_http_proxy(base_url: str, route, *, timeout: float | None = None):
method = list(route.methods - {"HEAD", "OPTIONS"})[0]
async def proxy(**kwargs):
# replace `/items/{id}` style params first
path = route.path
for k, v in list(kwargs.items()):
placeholder = "{" + k + "}"
if placeholder in path:
path = path.replace(placeholder, str(v))
kwargs.pop(k)
url = base_url.rstrip("/") + path
headers = _service_auth_headers()
async with httpx.AsyncClient(timeout=timeout) as client:
try:
r = (
await client.get(url, params=kwargs, headers=headers)
if method == "GET"
else await client.request(method, url, json=kwargs, headers=headers)
)
r.raise_for_status()
return r.text if method == "GET" else r.json()
except httpx.HTTPStatusError as e:
# surface FastAPI error details instead of plain 500
raise HTTPException(e.response.status_code, e.response.text)
except httpx.TimeoutException:
raise HTTPException(504, "upstream request timed out")
return proxy
# ── main entry point ────────────────────────────────────────────
def attach_mcp(
app: FastAPI,
*, # keywordonly
base: str = "/mcp",
name: str | None = None,
base_url: str, # eg. "http://127.0.0.1:8020"
timeout: float | None = None, # httpx timeout in seconds; None = no limit
) -> None:
"""Call once after all routes are declared to expose WS+SSE MCP endpoints."""
server_name = name or app.title or "FastAPI-MCP"
mcp = Server(server_name)
# tools: Dict[str, Callable] = {}
tools: Dict[str, Tuple[Callable, Callable]] = {}
resources: Dict[str, Callable] = {}
templates: Dict[str, Callable] = {}
# register decorated FastAPI routes
for route in app.routes:
fn = getattr(route, "endpoint", None)
kind = getattr(fn, "__mcp_kind__", None)
if not kind:
continue
key = fn.__mcp_name__ or re.sub(r"[/{}}]", "_", route.path).strip("_")
# if kind == "tool":
# tools[key] = _make_http_proxy(base_url, route)
if kind == "tool":
proxy = _make_http_proxy(base_url, route, timeout=timeout)
tools[key] = (proxy, fn)
continue
if kind == "resource":
resources[key] = fn
if kind == "template":
templates[key] = fn
# helpers for JSONSchema
def _schema(model: type[BaseModel] | None) -> dict:
return {"type": "object"} if model is None else model.model_json_schema()
def _body_model(fn: Callable) -> type[BaseModel] | None:
for p in inspect.signature(fn).parameters.values():
a = p.annotation
if inspect.isclass(a) and issubclass(a, BaseModel):
return a
return None
# MCP handlers
@mcp.list_tools()
async def _list_tools() -> List[t.Tool]:
out = []
for k, (proxy, orig_fn) in tools.items():
desc = getattr(orig_fn, "__mcp_description__", None) or inspect.getdoc(orig_fn) or ""
schema = getattr(orig_fn, "__mcp_schema__", None) or _schema(_body_model(orig_fn))
out.append(
t.Tool(name=k, description=desc, inputSchema=schema)
)
return out
@mcp.call_tool()
async def _call_tool(name: str, arguments: Dict | None) -> List[t.TextContent]:
if name not in tools:
raise HTTPException(404, "tool not found")
proxy, _ = tools[name]
try:
res = await proxy(**(arguments or {}))
except HTTPException as exc:
# map serverside errors into MCP "text/error" payloads
err = {"error": exc.status_code, "detail": exc.detail}
return [t.TextContent(type = "text", text=json.dumps(err, ensure_ascii=False))]
return [t.TextContent(type = "text", text=json.dumps(res, default=str, ensure_ascii=False))]
@mcp.list_resources()
async def _list_resources() -> List[t.Resource]:
return [
t.Resource(name=k, description=inspect.getdoc(f) or "", mime_type="application/json")
for k, f in resources.items()
]
@mcp.read_resource()
async def _read_resource(name: str) -> List[t.TextContent]:
if name not in resources:
raise HTTPException(404, "resource not found")
res = resources[name]()
return [t.TextContent(type = "text", text=json.dumps(res, default=str, ensure_ascii=False))]
@mcp.list_resource_templates()
async def _list_templates() -> List[t.ResourceTemplate]:
return [
t.ResourceTemplate(
name=k,
description=inspect.getdoc(f) or "",
parameters={
p: {"type": "string"} for p in _path_params(app, f)
},
)
for k, f in templates.items()
]
init_opts = InitializationOptions(
server_name=server_name,
server_version="0.1.0",
capabilities=mcp.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
)
# ── WebSocket transport ────────────────────────────────────
@app.websocket_route(f"{base}/ws")
async def _ws(ws: WebSocket):
await ws.accept()
c2s_send, c2s_recv = anyio.create_memory_object_stream(100)
s2c_send, s2c_recv = anyio.create_memory_object_stream(100)
from pydantic import TypeAdapter
from mcp.types import JSONRPCMessage
adapter = TypeAdapter(JSONRPCMessage)
init_done = anyio.Event()
async def srv_to_ws():
first = True
try:
async for msg in s2c_recv:
await ws.send_json(msg.model_dump())
if first:
init_done.set()
first = False
finally:
# make sure cleanup survives TaskGroup cancellation
with anyio.CancelScope(shield=True):
with suppress(RuntimeError): # idempotent close
await ws.close()
async def ws_to_srv():
try:
# 1st frame is always "initialize"
first = adapter.validate_python(await ws.receive_json())
await c2s_send.send(first)
await init_done.wait() # block until server ready
while True:
data = await ws.receive_json()
await c2s_send.send(adapter.validate_python(data))
except WebSocketDisconnect:
await c2s_send.aclose()
async with anyio.create_task_group() as tg:
tg.start_soon(mcp.run, c2s_recv, s2c_send, init_opts)
tg.start_soon(ws_to_srv)
tg.start_soon(srv_to_ws)
# ── SSE transport (raw ASGI — avoids Starlette middleware conflict) ──
sse = SseServerTransport(f"{base}/messages/")
# Starlette's Route wraps plain async functions in request_response(),
# which calls handler(request) instead of handler(scope, receive, send).
# Using a callable class bypasses this — Route passes classes through
# as raw ASGI apps. See #1594, #1850.
class _MCPSseApp:
async def __call__(self, scope, receive, send):
async with sse.connect_sse(scope, receive, send) as (read_stream, write_stream):
await mcp.run(read_stream, write_stream, init_opts)
app.routes.append(Route(f"{base}/sse", endpoint=_MCPSseApp()))
app.routes.append(Mount(f"{base}/messages", app=sse.handle_post_message))
# ── schema endpoint ───────────────────────────────────────
@app.get(f"{base}/schema")
async def _schema_endpoint():
return JSONResponse({
"tools": [x.model_dump() for x in await _list_tools()],
"resources": [x.model_dump() for x in await _list_resources()],
"resource_templates": [x.model_dump() for x in await _list_templates()],
})
# ── helpers ────────────────────────────────────────────────────
def _route_name(path: str) -> str:
return re.sub(r"[/{}}]", "_", path).strip("_")
def _path_params(app: FastAPI, fn: Callable) -> List[str]:
for r in app.routes:
if r.endpoint is fn:
return list(r.param_convertors.keys())
return []
+391
View File
@@ -0,0 +1,391 @@
# monitor.py - Real-time monitoring stats with Redis persistence
import html
import time
import json
import asyncio
from typing import Dict, List, Optional
from datetime import datetime, timezone
from collections import deque
from redis import asyncio as aioredis
from utils import get_container_memory_percent
import psutil
import logging
logger = logging.getLogger(__name__)
class MonitorStats:
"""Tracks real-time server stats with Redis persistence."""
def __init__(self, redis: aioredis.Redis):
self.redis = redis
self.start_time = time.time()
# In-memory queues (fast reads, Redis backup)
self.active_requests: Dict[str, Dict] = {} # id -> request info
self.completed_requests: deque = deque(maxlen=100) # Last 100
self.janitor_events: deque = deque(maxlen=100)
self.errors: deque = deque(maxlen=100)
# Endpoint stats (persisted in Redis)
self.endpoint_stats: Dict[str, Dict] = {} # endpoint -> {count, total_time, errors, ...}
# Background persistence queue (max 10 pending persist requests)
self._persist_queue: asyncio.Queue = asyncio.Queue(maxsize=10)
self._persist_worker_task: Optional[asyncio.Task] = None
# Timeline data (5min window, 5s resolution = 60 points)
self.memory_timeline: deque = deque(maxlen=60)
self.requests_timeline: deque = deque(maxlen=60)
self.browser_timeline: deque = deque(maxlen=60)
async def track_request_start(self, request_id: str, endpoint: str, url: str, config: Dict = None):
"""Track new request start."""
req_info = {
"id": request_id,
"endpoint": endpoint,
"url": html.escape(url[:100]), # Truncate + escape for XSS prevention
"start_time": time.time(),
"config_sig": config.get("sig", "default") if config else "default",
"mem_start": psutil.Process().memory_info().rss / (1024 * 1024)
}
self.active_requests[request_id] = req_info
# Increment endpoint counter
if endpoint not in self.endpoint_stats:
self.endpoint_stats[endpoint] = {
"count": 0, "total_time": 0, "errors": 0,
"pool_hits": 0, "success": 0
}
self.endpoint_stats[endpoint]["count"] += 1
# Queue persistence (handled by background worker)
try:
self._persist_queue.put_nowait(True)
except asyncio.QueueFull:
logger.warning("Persistence queue full, skipping")
async def track_request_end(self, request_id: str, success: bool, error: str = None,
pool_hit: bool = True, status_code: int = 200):
"""Track request completion."""
if request_id not in self.active_requests:
return
req_info = self.active_requests.pop(request_id)
end_time = time.time()
elapsed = end_time - req_info["start_time"]
mem_end = psutil.Process().memory_info().rss / (1024 * 1024)
mem_delta = mem_end - req_info["mem_start"]
# Update stats
endpoint = req_info["endpoint"]
if endpoint in self.endpoint_stats:
self.endpoint_stats[endpoint]["total_time"] += elapsed
if success:
self.endpoint_stats[endpoint]["success"] += 1
else:
self.endpoint_stats[endpoint]["errors"] += 1
if pool_hit:
self.endpoint_stats[endpoint]["pool_hits"] += 1
# Add to completed queue
completed = {
**req_info,
"end_time": end_time,
"elapsed": round(elapsed, 2),
"mem_delta": round(mem_delta, 1),
"success": success,
"error": error,
"status_code": status_code,
"pool_hit": pool_hit
}
self.completed_requests.append(completed)
# Track errors
if not success and error:
self.errors.append({
"timestamp": end_time,
"endpoint": endpoint,
"url": req_info["url"],
"error": html.escape(str(error)),
"request_id": request_id
})
await self._persist_endpoint_stats()
async def track_janitor_event(self, event_type: str, sig: str, details: Dict):
"""Track janitor cleanup events."""
self.janitor_events.append({
"timestamp": time.time(),
"type": event_type, # "close_cold", "close_hot", "promote"
"sig": sig[:8],
"details": details
})
def _cleanup_old_entries(self, max_age_seconds: int = 300):
"""Remove entries older than max_age_seconds (default 5min)."""
now = time.time()
cutoff = now - max_age_seconds
# Clean completed requests
while self.completed_requests and self.completed_requests[0].get("end_time", 0) < cutoff:
self.completed_requests.popleft()
# Clean janitor events
while self.janitor_events and self.janitor_events[0].get("timestamp", 0) < cutoff:
self.janitor_events.popleft()
# Clean errors
while self.errors and self.errors[0].get("timestamp", 0) < cutoff:
self.errors.popleft()
async def update_timeline(self):
"""Update timeline data points (called every 5s)."""
now = time.time()
mem_pct = get_container_memory_percent()
# Clean old entries (keep last 5 minutes)
self._cleanup_old_entries(max_age_seconds=300)
# Count requests in last 5s
recent_reqs = sum(1 for req in self.completed_requests
if now - req.get("end_time", 0) < 5)
# Browser counts — lock-free snapshot to avoid contending on
# the pool LOCK held during slow browser start/close (issue #1754)
from crawler_pool import get_pool_snapshot
snap = get_pool_snapshot()
browser_count = {
"permanent": 1 if snap["permanent"] else 0,
"hot": len(snap["hot_pool"]),
"cold": len(snap["cold_pool"]),
}
self.memory_timeline.append({"time": now, "value": mem_pct})
self.requests_timeline.append({"time": now, "value": recent_reqs})
self.browser_timeline.append({"time": now, "browsers": browser_count})
async def _persist_endpoint_stats(self):
"""Persist endpoint stats to Redis."""
try:
await self.redis.set(
"monitor:endpoint_stats",
json.dumps(self.endpoint_stats),
ex=86400 # 24h TTL
)
except Exception as e:
logger.warning(f"Failed to persist endpoint stats: {e}")
async def _persistence_worker(self):
"""Background worker to persist stats to Redis."""
while True:
try:
await self._persist_queue.get()
await self._persist_endpoint_stats()
self._persist_queue.task_done()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Persistence worker error: {e}")
def start_persistence_worker(self):
"""Start the background persistence worker."""
if not self._persist_worker_task:
self._persist_worker_task = asyncio.create_task(self._persistence_worker())
logger.info("Started persistence worker")
async def stop_persistence_worker(self):
"""Stop the background persistence worker."""
if self._persist_worker_task:
self._persist_worker_task.cancel()
try:
await self._persist_worker_task
except asyncio.CancelledError:
pass
self._persist_worker_task = None
logger.info("Stopped persistence worker")
async def cleanup(self):
"""Cleanup on shutdown - persist final stats and stop workers."""
logger.info("Monitor cleanup starting...")
try:
# Persist final stats before shutdown
await self._persist_endpoint_stats()
# Stop background worker
await self.stop_persistence_worker()
logger.info("Monitor cleanup completed")
except Exception as e:
logger.error(f"Monitor cleanup error: {e}")
async def load_from_redis(self):
"""Load persisted stats from Redis."""
try:
data = await self.redis.get("monitor:endpoint_stats")
if data:
self.endpoint_stats = json.loads(data)
logger.info("Loaded endpoint stats from Redis")
except Exception as e:
logger.warning(f"Failed to load from Redis: {e}")
async def get_health_summary(self) -> Dict:
"""Get current system health snapshot."""
mem_pct = get_container_memory_percent()
cpu_pct = psutil.cpu_percent(interval=0.1)
# Network I/O (delta since last call)
net = psutil.net_io_counters()
# Pool status — lock-free snapshot (issue #1754)
from crawler_pool import get_pool_snapshot
snap = get_pool_snapshot()
# TODO: Track actual browser process memory instead of estimates
# These are conservative estimates based on typical Chromium usage
permanent_mem = 270 if snap["permanent"] else 0 # Estimate: ~270MB for permanent browser
hot_mem = len(snap["hot_pool"]) * 180 # Estimate: ~180MB per hot pool browser
cold_mem = len(snap["cold_pool"]) * 180 # Estimate: ~180MB per cold pool browser
permanent_active = snap["permanent"] is not None
hot_count = len(snap["hot_pool"])
cold_count = len(snap["cold_pool"])
return {
"container": {
"memory_percent": round(mem_pct, 1),
"cpu_percent": round(cpu_pct, 1),
"network_sent_mb": round(net.bytes_sent / (1024**2), 2),
"network_recv_mb": round(net.bytes_recv / (1024**2), 2),
"uptime_seconds": int(time.time() - self.start_time)
},
"pool": {
"permanent": {"active": permanent_active, "memory_mb": permanent_mem},
"hot": {"count": hot_count, "memory_mb": hot_mem},
"cold": {"count": cold_count, "memory_mb": cold_mem},
"total_memory_mb": permanent_mem + hot_mem + cold_mem
},
"janitor": {
"next_cleanup_estimate": "adaptive", # Would need janitor state
"memory_pressure": "LOW" if mem_pct < 60 else "MEDIUM" if mem_pct < 80 else "HIGH"
}
}
def get_active_requests(self) -> List[Dict]:
"""Get list of currently active requests."""
now = time.time()
return [
{
**req,
"elapsed": round(now - req["start_time"], 1),
"status": "running"
}
for req in self.active_requests.values()
]
def get_completed_requests(self, limit: int = 50, filter_status: str = "all") -> List[Dict]:
"""Get recent completed requests."""
requests = list(self.completed_requests)[-limit:]
if filter_status == "success":
requests = [r for r in requests if r.get("success")]
elif filter_status == "error":
requests = [r for r in requests if not r.get("success")]
return requests
async def get_browser_list(self) -> List[Dict]:
"""Get detailed browser pool information."""
from crawler_pool import get_pool_snapshot
browsers = []
now = time.time()
# Lock-free snapshot — iterates over copies (issue #1754)
snap = get_pool_snapshot()
permanent = snap["permanent"]
permanent_sig = snap["permanent_sig"]
hot_pool = snap["hot_pool"]
cold_pool = snap["cold_pool"]
last_used = snap["last_used"]
usage_count = snap["usage_count"]
if permanent:
browsers.append({
"type": "permanent",
"sig": permanent_sig[:8] if permanent_sig else "unknown",
"age_seconds": int(now - self.start_time),
"last_used_seconds": int(now - last_used.get(permanent_sig, now)),
"memory_mb": 270,
"hits": usage_count.get(permanent_sig, 0),
"killable": False
})
for sig, crawler in hot_pool.items():
browsers.append({
"type": "hot",
"sig": sig[:8],
"age_seconds": int(now - self.start_time), # Approximation
"last_used_seconds": int(now - last_used.get(sig, now)),
"memory_mb": 180, # Estimate
"hits": usage_count.get(sig, 0),
"killable": True
})
for sig, crawler in cold_pool.items():
browsers.append({
"type": "cold",
"sig": sig[:8],
"age_seconds": int(now - self.start_time),
"last_used_seconds": int(now - last_used.get(sig, now)),
"memory_mb": 180,
"hits": usage_count.get(sig, 0),
"killable": True
})
return browsers
def get_endpoint_stats_summary(self) -> Dict[str, Dict]:
"""Get aggregated endpoint statistics."""
summary = {}
for endpoint, stats in self.endpoint_stats.items():
count = stats["count"]
avg_time = (stats["total_time"] / count) if count > 0 else 0
success_rate = (stats["success"] / count * 100) if count > 0 else 0
pool_hit_rate = (stats["pool_hits"] / count * 100) if count > 0 else 0
summary[endpoint] = {
"count": count,
"avg_latency_ms": round(avg_time * 1000, 1),
"success_rate_percent": round(success_rate, 1),
"pool_hit_rate_percent": round(pool_hit_rate, 1),
"errors": stats["errors"]
}
return summary
def get_timeline_data(self, metric: str, window: str = "5m") -> Dict:
"""Get timeline data for charts."""
# For now, only 5m window supported
if metric == "memory":
data = list(self.memory_timeline)
elif metric == "requests":
data = list(self.requests_timeline)
elif metric == "browsers":
data = list(self.browser_timeline)
else:
return {"timestamps": [], "values": []}
return {
"timestamps": [int(d["time"]) for d in data],
"values": [d.get("value", d.get("browsers")) for d in data]
}
def get_janitor_log(self, limit: int = 100) -> List[Dict]:
"""Get recent janitor events."""
return list(self.janitor_events)[-limit:]
def get_errors_log(self, limit: int = 100) -> List[Dict]:
"""Get recent errors."""
return list(self.errors)[-limit:]
# Global instance (initialized in server.py)
monitor_stats: Optional[MonitorStats] = None
def get_monitor() -> MonitorStats:
"""Get global monitor instance."""
if monitor_stats is None:
raise RuntimeError("Monitor not initialized")
return monitor_stats
+409
View File
@@ -0,0 +1,409 @@
# monitor_routes.py - Monitor API endpoints
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, Depends
from pydantic import BaseModel
from typing import Optional
from monitor import get_monitor
from auth import require_admin
import logging
import asyncio
import json
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/monitor", tags=["monitor"])
@router.get("/health")
async def get_health():
"""Get current system health snapshot."""
try:
monitor = get_monitor()
return await monitor.get_health_summary()
except Exception as e:
logger.error(f"Error getting health: {e}")
raise HTTPException(500, str(e))
@router.get("/requests")
async def get_requests(status: str = "all", limit: int = 50):
"""Get active and completed requests.
Args:
status: Filter by 'active', 'completed', 'success', 'error', or 'all'
limit: Max number of completed requests to return (default 50)
"""
# Input validation
if status not in ["all", "active", "completed", "success", "error"]:
raise HTTPException(400, f"Invalid status: {status}. Must be one of: all, active, completed, success, error")
if limit < 1 or limit > 1000:
raise HTTPException(400, f"Invalid limit: {limit}. Must be between 1 and 1000")
try:
monitor = get_monitor()
if status == "active":
return {"active": monitor.get_active_requests(), "completed": []}
elif status == "completed":
return {"active": [], "completed": monitor.get_completed_requests(limit)}
elif status in ["success", "error"]:
return {"active": [], "completed": monitor.get_completed_requests(limit, status)}
else: # "all"
return {
"active": monitor.get_active_requests(),
"completed": monitor.get_completed_requests(limit)
}
except Exception as e:
logger.error(f"Error getting requests: {e}")
raise HTTPException(500, str(e))
@router.get("/browsers")
async def get_browsers():
"""Get detailed browser pool information."""
try:
monitor = get_monitor()
browsers = await monitor.get_browser_list()
# Calculate summary stats
total_browsers = len(browsers)
total_memory = sum(b["memory_mb"] for b in browsers)
# Calculate reuse rate from recent requests
recent = monitor.get_completed_requests(100)
pool_hits = sum(1 for r in recent if r.get("pool_hit", False))
reuse_rate = (pool_hits / len(recent) * 100) if recent else 0
return {
"browsers": browsers,
"summary": {
"total_count": total_browsers,
"total_memory_mb": total_memory,
"reuse_rate_percent": round(reuse_rate, 1)
}
}
except Exception as e:
logger.error(f"Error getting browsers: {e}")
raise HTTPException(500, str(e))
@router.get("/endpoints/stats")
async def get_endpoint_stats():
"""Get aggregated endpoint statistics."""
try:
monitor = get_monitor()
return monitor.get_endpoint_stats_summary()
except Exception as e:
logger.error(f"Error getting endpoint stats: {e}")
raise HTTPException(500, str(e))
@router.get("/timeline")
async def get_timeline(metric: str = "memory", window: str = "5m"):
"""Get timeline data for charts.
Args:
metric: 'memory', 'requests', or 'browsers'
window: Time window (only '5m' supported for now)
"""
# Input validation
if metric not in ["memory", "requests", "browsers"]:
raise HTTPException(400, f"Invalid metric: {metric}. Must be one of: memory, requests, browsers")
if window != "5m":
raise HTTPException(400, f"Invalid window: {window}. Only '5m' is currently supported")
try:
monitor = get_monitor()
return monitor.get_timeline_data(metric, window)
except Exception as e:
logger.error(f"Error getting timeline: {e}")
raise HTTPException(500, str(e))
@router.get("/logs/janitor")
async def get_janitor_log(limit: int = 100):
"""Get recent janitor cleanup events."""
# Input validation
if limit < 1 or limit > 1000:
raise HTTPException(400, f"Invalid limit: {limit}. Must be between 1 and 1000")
try:
monitor = get_monitor()
return {"events": monitor.get_janitor_log(limit)}
except Exception as e:
logger.error(f"Error getting janitor log: {e}")
raise HTTPException(500, str(e))
@router.get("/logs/errors")
async def get_errors_log(limit: int = 100):
"""Get recent errors."""
# Input validation
if limit < 1 or limit > 1000:
raise HTTPException(400, f"Invalid limit: {limit}. Must be between 1 and 1000")
try:
monitor = get_monitor()
return {"errors": monitor.get_errors_log(limit)}
except Exception as e:
logger.error(f"Error getting errors log: {e}")
raise HTTPException(500, str(e))
# ========== Control Actions ==========
class KillBrowserRequest(BaseModel):
sig: str
@router.post("/actions/cleanup", dependencies=[Depends(require_admin)])
async def force_cleanup():
"""Force immediate janitor cleanup (kills idle cold pool browsers)."""
try:
from crawler_pool import COLD_POOL, LAST_USED, USAGE_COUNT, LOCK
import time
from contextlib import suppress
killed_count = 0
now = time.time()
async with LOCK:
for sig in list(COLD_POOL.keys()):
# Kill all cold pool browsers immediately
logger.info(f"🧹 Force cleanup: closing cold browser (sig={sig[:8]})")
with suppress(Exception):
await COLD_POOL[sig].close()
COLD_POOL.pop(sig, None)
LAST_USED.pop(sig, None)
USAGE_COUNT.pop(sig, None)
killed_count += 1
monitor = get_monitor()
await monitor.track_janitor_event("force_cleanup", "manual", {"killed": killed_count})
return {"success": True, "killed_browsers": killed_count}
except Exception as e:
logger.error(f"Error during force cleanup: {e}")
raise HTTPException(500, str(e))
@router.post("/actions/kill_browser", dependencies=[Depends(require_admin)])
async def kill_browser(req: KillBrowserRequest):
"""Kill a specific browser by signature (hot or cold only).
Args:
sig: Browser config signature (first 8 chars)
"""
try:
from crawler_pool import HOT_POOL, COLD_POOL, LAST_USED, USAGE_COUNT, LOCK, DEFAULT_CONFIG_SIG
from contextlib import suppress
# Find full signature matching prefix
target_sig = None
pool_type = None
async with LOCK:
# Check hot pool
for sig in HOT_POOL.keys():
if sig.startswith(req.sig):
target_sig = sig
pool_type = "hot"
break
# Check cold pool
if not target_sig:
for sig in COLD_POOL.keys():
if sig.startswith(req.sig):
target_sig = sig
pool_type = "cold"
break
# Check if trying to kill permanent
if DEFAULT_CONFIG_SIG and DEFAULT_CONFIG_SIG.startswith(req.sig):
raise HTTPException(403, "Cannot kill permanent browser. Use restart instead.")
if not target_sig:
raise HTTPException(404, f"Browser with sig={req.sig} not found")
# Warn if there are active requests (browser might be in use)
monitor = get_monitor()
active_count = len(monitor.get_active_requests())
if active_count > 0:
logger.warning(f"Killing browser {target_sig[:8]} while {active_count} requests are active - may cause failures")
# Kill the browser
if pool_type == "hot":
browser = HOT_POOL.pop(target_sig)
else:
browser = COLD_POOL.pop(target_sig)
with suppress(Exception):
await browser.close()
LAST_USED.pop(target_sig, None)
USAGE_COUNT.pop(target_sig, None)
logger.info(f"🔪 Killed {pool_type} browser (sig={target_sig[:8]})")
monitor = get_monitor()
await monitor.track_janitor_event("kill_browser", target_sig, {"pool": pool_type, "manual": True})
return {"success": True, "killed_sig": target_sig[:8], "pool_type": pool_type}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error killing browser: {e}")
raise HTTPException(500, str(e))
@router.post("/actions/restart_browser", dependencies=[Depends(require_admin)])
async def restart_browser(req: KillBrowserRequest):
"""Restart a browser (kill + recreate). Works for permanent too.
Args:
sig: Browser config signature (first 8 chars), or "permanent"
"""
try:
from crawler_pool import (PERMANENT, HOT_POOL, COLD_POOL, LAST_USED,
USAGE_COUNT, LOCK, DEFAULT_CONFIG_SIG, init_permanent)
from crawl4ai import AsyncWebCrawler, BrowserConfig
from contextlib import suppress
import time
# Handle permanent browser restart
if req.sig == "permanent" or (DEFAULT_CONFIG_SIG and DEFAULT_CONFIG_SIG.startswith(req.sig)):
async with LOCK:
if PERMANENT:
with suppress(Exception):
await PERMANENT.close()
# Reinitialize permanent
from utils import load_config
config = load_config()
await init_permanent(BrowserConfig(
extra_args=config["crawler"]["browser"].get("extra_args", []),
**config["crawler"]["browser"].get("kwargs", {}),
))
logger.info("🔄 Restarted permanent browser")
return {"success": True, "restarted": "permanent"}
# Handle hot/cold browser restart
target_sig = None
pool_type = None
browser_config = None
async with LOCK:
# Find browser
for sig in HOT_POOL.keys():
if sig.startswith(req.sig):
target_sig = sig
pool_type = "hot"
# Would need to reconstruct config (not stored currently)
break
if not target_sig:
for sig in COLD_POOL.keys():
if sig.startswith(req.sig):
target_sig = sig
pool_type = "cold"
break
if not target_sig:
raise HTTPException(404, f"Browser with sig={req.sig} not found")
# Kill existing
if pool_type == "hot":
browser = HOT_POOL.pop(target_sig)
else:
browser = COLD_POOL.pop(target_sig)
with suppress(Exception):
await browser.close()
# Note: We can't easily recreate with same config without storing it
# For now, just kill and let new requests create fresh ones
LAST_USED.pop(target_sig, None)
USAGE_COUNT.pop(target_sig, None)
logger.info(f"🔄 Restarted {pool_type} browser (sig={target_sig[:8]})")
monitor = get_monitor()
await monitor.track_janitor_event("restart_browser", target_sig, {"pool": pool_type})
return {"success": True, "restarted_sig": target_sig[:8], "note": "Browser will be recreated on next request"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error restarting browser: {e}")
raise HTTPException(500, str(e))
@router.post("/stats/reset", dependencies=[Depends(require_admin)])
async def reset_stats():
"""Reset today's endpoint counters."""
try:
monitor = get_monitor()
monitor.endpoint_stats.clear()
await monitor._persist_endpoint_stats()
return {"success": True, "message": "Endpoint stats reset"}
except Exception as e:
logger.error(f"Error resetting stats: {e}")
raise HTTPException(500, str(e))
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time monitoring updates.
Sends updates every 2 seconds with:
- Health stats
- Active/completed requests
- Browser pool status
- Timeline data
"""
# Auth is enforced by the AuthGateMiddleware (outermost ASGI layer), which
# validates the ?token= query param / Authorization header for this
# WebSocket and closes 4401 before we are reached. No bespoke check here.
await websocket.accept()
logger.info("WebSocket client connected")
try:
while True:
try:
# Gather all monitoring data
monitor = get_monitor()
data = {
"timestamp": asyncio.get_event_loop().time(),
"health": await monitor.get_health_summary(),
"requests": {
"active": monitor.get_active_requests(),
"completed": monitor.get_completed_requests(limit=10)
},
"browsers": await monitor.get_browser_list(),
"timeline": {
"memory": monitor.get_timeline_data("memory", "5m"),
"requests": monitor.get_timeline_data("requests", "5m"),
"browsers": monitor.get_timeline_data("browsers", "5m")
},
"janitor": monitor.get_janitor_log(limit=10),
"errors": monitor.get_errors_log(limit=10)
}
# Send update to client
await websocket.send_json(data)
# Wait 2 seconds before next update
await asyncio.sleep(2)
except WebSocketDisconnect:
logger.info("WebSocket client disconnected")
break
except Exception as e:
logger.error(f"WebSocket error: {e}", exc_info=True)
await asyncio.sleep(2) # Continue trying
except Exception as e:
logger.error(f"WebSocket connection error: {e}", exc_info=True)
finally:
logger.info("WebSocket connection closed")
+75
View File
@@ -0,0 +1,75 @@
"""Redis URL helpers for the Docker API server.
The Docker entrypoint protects the in-container Redis with a password. These
helpers keep every Redis client (the app and optional rate-limit storage) using
the same authenticated URL shape.
"""
import os
from urllib.parse import quote, urlsplit, urlunsplit
def redis_auth_from_config(config: dict) -> tuple[str, str]:
"""Return Redis ACL username/password from config and environment."""
rc = config.get("redis", {})
username = os.environ.get("REDIS_USERNAME", rc.get("username", "default")) or "default"
password = os.environ.get("REDIS_PASSWORD", rc.get("password", "")) or ""
return str(username), str(password)
def redis_auth_netloc(username: str, password: str) -> str:
"""Build a URL-safe Redis AUTH fragment for redis-py/limits clients.
Redis 6+ HELLO AUTH requires both username and password when negotiating
RESP3. Supplying the explicit default ACL username avoids clients issuing an
unauthenticated HELLO against a password-protected Redis instance.
"""
if not password:
return ""
return f"{quote(username, safe='')}:{quote(password, safe='')}@"
def build_redis_url(config: dict) -> str:
"""Build Redis URL from config fields and environment variables."""
rc = config.get("redis", {})
host = os.environ.get("REDIS_HOST", rc.get("host", "localhost"))
port = os.environ.get("REDIS_PORT", rc.get("port", 6379))
username, password = redis_auth_from_config(config)
db = rc.get("db", 0)
scheme = "rediss" if rc.get("ssl", False) else "redis"
auth = redis_auth_netloc(username, password)
return f"{scheme}://{auth}{host}:{port}/{db}"
def build_rate_limit_storage_uri(config: dict) -> str:
"""Return a rate-limit storage URI that can auth to protected Redis.
Older/self-managed Docker configs may set rate_limiting.storage_uri to an
unauthenticated Redis URL such as redis://localhost:6379. Since the Docker
entrypoint now always protects the in-container Redis with REDIS_PASSWORD,
SlowAPI/limits would fail before route handlers with Redis HELLO auth errors.
If the storage URI is Redis-like and has no credentials, reuse the configured
Redis ACL credentials. Explicit credentials and non-Redis backends are left
unchanged.
"""
storage_uri = config.get("rate_limiting", {}).get("storage_uri", "memory://")
if not isinstance(storage_uri, str):
return "memory://"
parsed = urlsplit(storage_uri)
if parsed.scheme not in {"redis", "rediss", "redis+sentinel"}:
return storage_uri
if parsed.username or parsed.password:
return storage_uri
username, password = redis_auth_from_config(config)
if not password:
return storage_uri
auth = redis_auth_netloc(username, password)
return urlunsplit((
parsed.scheme,
f"{auth}{parsed.netloc}",
parsed.path,
parsed.query,
parsed.fragment,
))
+16
View File
@@ -0,0 +1,16 @@
fastapi>=0.115.12,<0.137
uvicorn>=0.34.2
gunicorn>=23.0.0
slowapi==0.1.9
prometheus-fastapi-instrumentator>=7.1.0
redis>=5.2.1
dnspython>=2.7.0
email-validator==2.2.0
sse-starlette==2.2.1
pydantic>=2.11
rank-bm25==0.2.2
anyio==4.9.0
PyJWT==2.10.1
mcp>=1.18.0
websockets>=15.0.1
httpx[http2]>=0.27.2
+130
View File
@@ -0,0 +1,130 @@
from typing import Any, List, Optional, Dict
from enum import Enum
from pydantic import BaseModel, Field, HttpUrl, field_validator
from utils import FilterType
class CrawlRequest(BaseModel):
urls: List[str] = Field(min_length=1, max_length=100)
browser_config: Optional[Dict] = Field(default_factory=dict)
crawler_config: Optional[Dict] = Field(default_factory=dict)
crawler_configs: Optional[List[Dict]] = Field(
default=None,
description=(
"List of per-URL CrawlerRunConfig dicts for arun_many(). "
"When provided, each config can include a 'url_matcher' pattern "
"to match against specific URLs. Takes precedence over crawler_config."
),
)
class HookSpec(BaseModel):
"""A single declarative hook: a fixed action plus schema-validated params.
Arbitrary Python (the old `code` map) is no longer accepted - it was an
exec()-based RCE surface. Available actions are enumerated by GET /hooks/info
and validated server-side by hook_registry.py.
"""
action: str = Field(..., description="One of the registered hook actions")
params: Dict[str, Any] = Field(default_factory=dict, description="Action parameters")
class HookConfig(BaseModel):
"""Configuration for declarative hooks."""
hooks: List[HookSpec] = Field(
default_factory=list,
max_length=10,
description="Declarative hook specs (action + params), max 10",
)
timeout: int = Field(
default=30,
ge=1,
le=120,
description="Timeout in seconds for each hook execution",
)
class Config:
json_schema_extra = {
"example": {
"hooks": [
{"action": "block_resources", "params": {"resource_types": ["image", "font"]}},
{"action": "scroll_to_bottom", "params": {"max_steps": 10, "delay_ms": 500}},
],
"timeout": 30,
}
}
class CrawlRequestWithHooks(CrawlRequest):
"""Extended crawl request with hooks support"""
hooks: Optional[HookConfig] = Field(
default=None,
description="Optional user-provided hook functions"
)
class MarkdownRequest(BaseModel):
"""Request body for the /md endpoint."""
url: str = Field(..., description="Absolute http/https URL to fetch")
f: FilterType = Field(FilterType.FIT, description="Contentfilter strategy: fit, raw, bm25, or llm")
q: Optional[str] = Field(None, description="Query string used by BM25/LLM filters")
c: Optional[str] = Field("0", description="Cachebust / revision counter")
provider: Optional[str] = Field(None, description="LLM provider override (e.g., 'anthropic/claude-3-opus')")
temperature: Optional[float] = Field(None, description="LLM temperature override (0.0-2.0)")
# base_url removed: a request-supplied LLM endpoint was a credential-exfil
# vector. The endpoint is derived server-side from the provider name.
class RawCode(BaseModel):
code: str
class HTMLRequest(BaseModel):
url: str
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.
class PDFRequest(BaseModel):
url: str
# output_path removed (see ScreenshotRequest).
class JSEndpointRequest(BaseModel):
url: str
scripts: List[str] = Field(
...,
description="List of separated JavaScript snippets to execute"
)
class WebhookConfig(BaseModel):
"""Configuration for webhook notifications."""
webhook_url: HttpUrl
webhook_data_in_payload: bool = False
webhook_headers: Optional[Dict[str, str]] = None
@field_validator("webhook_headers")
@classmethod
def _validate_headers(cls, v):
# Reject unsafe outbound headers early (422). Mirrors
# webhook.sanitize_webhook_headers; kept inline to avoid an import cycle.
if not v:
return v
from webhook import sanitize_webhook_headers
return sanitize_webhook_headers(v)
class WebhookPayload(BaseModel):
"""Payload sent to webhook endpoints."""
task_id: str
task_type: str # "crawl", "llm_extraction", etc.
status: str # "completed" or "failed"
timestamp: str # ISO 8601 format
urls: List[str]
error: Optional[str] = None
data: Optional[Dict] = None # Included only if webhook_data_in_payload=True
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
[supervisord]
nodaemon=true ; Run supervisord in the foreground
logfile=/dev/null ; Log supervisord output to stdout/stderr
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
user=appuser ; Run redis as our non-root user
autorestart=true
priority=10
stdout_logfile=/dev/stdout ; Redirect redis stdout to container stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr ; Redirect redis stderr to container stderr
stderr_logfile_maxbytes=0
[program:gunicorn]
; Bind is resolved by entrypoint.sh into GUNICORN_BIND: loopback unless a
; credential (CRAWL4AI_API_TOKEN) is present, in which case the operator may
; expose 0.0.0.0. gunicorn is the authoritative socket-level guard; the Python
; _resolve_auth() check is the in-process guard - both must agree.
command=/usr/local/bin/gunicorn --bind "%(ENV_GUNICORN_BIND)s" --workers 1 --threads 4 --timeout 1800 --graceful-timeout 30 --keep-alive 300 --log-level info --limit-request-line 8190 --limit-request-fields 100 --worker-class uvicorn.workers.UvicornWorker server:app
directory=/app ; Working directory for the app
user=appuser ; Run gunicorn as our non-root user
autorestart=true
priority=20
environment=PYTHONUNBUFFERED=1 ; Ensure Python output is sent straight to logs
stdout_logfile=/dev/stdout ; Redirect gunicorn stdout to container stdout
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
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""
Quick WebSocket test - Connect to monitor WebSocket and print updates
"""
import asyncio
import websockets
import json
async def test_websocket():
uri = "ws://localhost:11235/monitor/ws"
print(f"Connecting to {uri}...")
try:
async with websockets.connect(uri) as websocket:
print("✅ Connected!")
# Receive and print 5 updates
for i in range(5):
message = await websocket.recv()
data = json.loads(message)
print(f"\n📊 Update #{i+1}:")
print(f" - Health: CPU {data['health']['container']['cpu_percent']}%, Memory {data['health']['container']['memory_percent']}%")
print(f" - Active Requests: {len(data['requests']['active'])}")
print(f" - Browsers: {len(data['browsers'])}")
except Exception as e:
print(f"❌ Error: {e}")
return 1
print("\n✅ WebSocket test passed!")
return 0
if __name__ == "__main__":
exit(asyncio.run(test_websocket()))
+168
View File
@@ -0,0 +1,168 @@
"""
Shared fixtures for the Crawl4AI Docker server *behavioral* security tests.
Unlike the legacy `test_security_*.py` suites (which grep the source text and
pass even when the running server is wide open), these fixtures boot the real
FastAPI application via Starlette's TestClient and exercise it as a client
would. That makes the tests behavioral: they assert what the server actually
*does*, not what the source happens to contain.
Design notes
------------
* The app is imported once per session. `deploy/docker` is put on `sys.path`
first, because `server.py` does `from crawler_pool import ...` at module top,
before its own `sys.path.append`.
* The TestClient is created *without* the `with` context-manager form on
purpose: that skips the FastAPI lifespan, so no Chromium is launched and no
Redis connection is opened at startup. Authentication is decided by a
dependency/middleware that runs before any route handler, so auth-posture
assertions resolve before the app ever needs a browser or Redis.
* `offline_dns` / `rebinding_dns` monkeypatch name resolution so the SSRF /
egress behavioral tests (R3) run fully offline and can model DNS-rebinding
(resolve-then-reconnect TOCTOU) deterministically.
"""
import importlib
import socket
import sys
from pathlib import Path
import pytest
# deploy/docker (the dir that holds server.py, auth.py, ...) must be importable
# before we import `server`, since server.py imports its siblings by bare name.
DOCKER_DIR = Path(__file__).resolve().parents[1]
if str(DOCKER_DIR) not in sys.path:
sys.path.insert(0, str(DOCKER_DIR))
def pytest_configure(config):
config.addinivalue_line(
"markers",
"posture: the secure-by-default acceptance gate (expected RED until R1-R7 land)",
)
config.addinivalue_line(
"markers", "cve: regression test for a specific reported vulnerability"
)
@pytest.fixture(scope="session")
def server_module():
"""Import the Docker server app once for the whole test session.
Returns the imported `server` module so tests can introspect the loaded
config, the redis-url builder, feature flags, etc.
"""
# Import fresh; if a previous test mutated it we still want the real module.
if "server" in sys.modules:
return sys.modules["server"]
return importlib.import_module("server")
@pytest.fixture
def stock_client(server_module):
"""A TestClient bound to the app as shipped (config.yml defaults).
No lifespan => no real browser, no Redis connect. Auth/headers/routing are
all exercised normally because they sit in front of the route handlers.
"""
from starlette.testclient import TestClient
# raise_server_exceptions=False so an un-gated handler that blows up on a
# missing pool/redis surfaces as a 500 response (which our posture test
# treats as "not 401" => still a finding) instead of bubbling out of the
# test client as an exception.
return TestClient(server_module.app, raise_server_exceptions=False)
@pytest.fixture
def effective_config(server_module):
"""The configuration dict the running app actually loaded."""
return server_module.config
@pytest.fixture
def effective_browser_args(effective_config):
"""The Chromium launch flags the app will pass to every browser."""
return list(effective_config["crawler"]["browser"].get("extra_args", []))
@pytest.fixture
def effective_redis_url(server_module):
"""The Redis URL the app builds from config + environment."""
return server_module._build_redis_url(server_module.config)
# ─────────────────────── offline DNS control ────────────────────────
# These let SSRF/egress tests run without touching the network and let us
# model DNS rebinding precisely.
class _FakeResolver:
"""Drop-in for socket.getaddrinfo with a controllable host->IP map.
Unknown hosts resolve to a stable public-ish default so accidental real
lookups never escape the test process.
"""
DEFAULT_IP = "93.184.216.34" # documentation/example address space
def __init__(self):
self.map = {} # host -> ip (single answer)
def set(self, host, ip):
self.map[host] = ip
def getaddrinfo(self, host, port, *args, **kwargs):
ip = self.map.get(host, self.DEFAULT_IP)
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port or 0))]
@pytest.fixture
def offline_dns(monkeypatch):
"""Patch socket.getaddrinfo with a controllable, offline resolver.
Usage:
offline_dns.set("evil.example", "169.254.169.254")
"""
resolver = _FakeResolver()
monkeypatch.setattr(socket, "getaddrinfo", resolver.getaddrinfo)
return resolver
class _RebindingResolver:
"""Returns a *different* answer on the second+ lookup of the same host.
Models the resolve-then-discard TOCTOU: validation sees a public IP, the
later real connection re-resolves to an internal IP. A correct egress
broker resolves once and pins, so the second (internal) answer is never
dialed.
"""
def __init__(self, host, first_ip, second_ip):
self.host = host
self.first_ip = first_ip
self.second_ip = second_ip
self.calls = 0
def getaddrinfo(self, host, port, *args, **kwargs):
if host == self.host:
self.calls += 1
ip = self.first_ip if self.calls == 1 else self.second_ip
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port or 0))]
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port or 0))]
@pytest.fixture
def rebinding_dns(monkeypatch):
"""Factory that installs a DNS-rebinding resolver.
Usage:
r = rebinding_dns("rebind.example", "93.184.216.34", "169.254.169.254")
... # first lookup public, every later lookup internal
"""
def _install(host, first_ip, second_ip):
resolver = _RebindingResolver(host, first_ip, second_ip)
monkeypatch.setattr(socket, "getaddrinfo", resolver.getaddrinfo)
return resolver
return _install
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
Monitor Dashboard Demo Script
Generates varied activity to showcase all monitoring features for video recording.
"""
import httpx
import asyncio
import time
from datetime import datetime
BASE_URL = "http://localhost:11235"
async def demo_dashboard():
print("🎬 Monitor Dashboard Demo - Starting...\n")
print(f"📊 Dashboard: {BASE_URL}/dashboard")
print("=" * 60)
async with httpx.AsyncClient(timeout=60.0) as client:
# Phase 1: Simple requests (permanent browser)
print("\n🔷 Phase 1: Testing permanent browser pool")
print("-" * 60)
for i in range(5):
print(f" {i+1}/5 Request to /crawl (default config)...")
try:
r = await client.post(
f"{BASE_URL}/crawl",
json={"urls": [f"https://httpbin.org/html?req={i}"], "crawler_config": {}}
)
print(f" ✅ Status: {r.status_code}, Time: {r.elapsed.total_seconds():.2f}s")
except Exception as e:
print(f" ❌ Error: {e}")
await asyncio.sleep(1) # Small delay between requests
# Phase 2: Create variant browsers (different configs)
print("\n🔶 Phase 2: Testing cold→hot pool promotion")
print("-" * 60)
viewports = [
{"width": 1920, "height": 1080},
{"width": 1280, "height": 720},
{"width": 800, "height": 600}
]
for idx, viewport in enumerate(viewports):
print(f" Viewport {viewport['width']}x{viewport['height']}:")
for i in range(4): # 4 requests each to trigger promotion at 3
try:
r = await client.post(
f"{BASE_URL}/crawl",
json={
"urls": [f"https://httpbin.org/json?v={idx}&r={i}"],
"browser_config": {"viewport": viewport},
"crawler_config": {}
}
)
print(f" {i+1}/4 ✅ {r.status_code} - Should see cold→hot after 3 uses")
except Exception as e:
print(f" {i+1}/4 ❌ {e}")
await asyncio.sleep(0.5)
# Phase 3: Concurrent burst (stress pool)
print("\n🔷 Phase 3: Concurrent burst (10 parallel)")
print("-" * 60)
tasks = []
for i in range(10):
tasks.append(
client.post(
f"{BASE_URL}/crawl",
json={"urls": [f"https://httpbin.org/delay/2?burst={i}"], "crawler_config": {}}
)
)
print(" Sending 10 concurrent requests...")
start = time.time()
results = await asyncio.gather(*tasks, return_exceptions=True)
elapsed = time.time() - start
successes = sum(1 for r in results if not isinstance(r, Exception) and r.status_code == 200)
print(f"{successes}/10 succeeded in {elapsed:.2f}s")
# Phase 4: Multi-endpoint coverage
print("\n🔶 Phase 4: Testing multiple endpoints")
print("-" * 60)
endpoints = [
("/md", {"url": "https://httpbin.org/html", "f": "fit", "c": "0"}),
("/screenshot", {"url": "https://httpbin.org/html"}),
("/pdf", {"url": "https://httpbin.org/html"}),
]
for endpoint, payload in endpoints:
print(f" Testing {endpoint}...")
try:
if endpoint == "/md":
r = await client.post(f"{BASE_URL}{endpoint}", json=payload)
else:
r = await client.post(f"{BASE_URL}{endpoint}", json=payload)
print(f"{r.status_code}")
except Exception as e:
print(f"{e}")
await asyncio.sleep(1)
# Phase 5: Intentional error (to populate errors tab)
print("\n🔷 Phase 5: Generating error examples")
print("-" * 60)
print(" Triggering invalid URL error...")
try:
r = await client.post(
f"{BASE_URL}/crawl",
json={"urls": ["invalid://bad-url"], "crawler_config": {}}
)
print(f" Response: {r.status_code}")
except Exception as e:
print(f" ✅ Error captured: {type(e).__name__}")
# Phase 6: Wait for janitor activity
print("\n🔶 Phase 6: Waiting for janitor cleanup...")
print("-" * 60)
print(" Idle for 40s to allow janitor to clean cold pool browsers...")
for i in range(40, 0, -10):
print(f" {i}s remaining... (Check dashboard for cleanup events)")
await asyncio.sleep(10)
# Phase 7: Final stats check
print("\n🔷 Phase 7: Final dashboard state")
print("-" * 60)
r = await client.get(f"{BASE_URL}/monitor/health")
health = r.json()
print(f" Memory: {health['container']['memory_percent']:.1f}%")
print(f" Browsers: Perm={health['pool']['permanent']['active']}, "
f"Hot={health['pool']['hot']['count']}, Cold={health['pool']['cold']['count']}")
r = await client.get(f"{BASE_URL}/monitor/endpoints/stats")
stats = r.json()
print(f"\n Endpoint Stats:")
for endpoint, data in stats.items():
print(f" {endpoint}: {data['count']} req, "
f"{data['avg_latency_ms']:.0f}ms avg, "
f"{data['success_rate_percent']:.1f}% success")
r = await client.get(f"{BASE_URL}/monitor/browsers")
browsers = r.json()
print(f"\n Pool Efficiency:")
print(f" Total browsers: {browsers['summary']['total_count']}")
print(f" Memory usage: {browsers['summary']['total_memory_mb']} MB")
print(f" Reuse rate: {browsers['summary']['reuse_rate_percent']:.1f}%")
print("\n" + "=" * 60)
print("✅ Demo complete! Dashboard is now populated with rich data.")
print(f"\n📹 Recording tip: Refresh {BASE_URL}/dashboard")
print(" You should see:")
print(" • Active & completed requests")
print(" • Browser pool (permanent + hot/cold)")
print(" • Janitor cleanup events")
print(" • Endpoint analytics")
print(" • Memory timeline")
if __name__ == "__main__":
try:
asyncio.run(demo_dashboard())
except KeyboardInterrupt:
print("\n\n⚠️ Demo interrupted by user")
except Exception as e:
print(f"\n\n❌ Demo failed: {e}")
+2
View File
@@ -0,0 +1,2 @@
httpx>=0.25.0
docker>=7.0.0
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
Security Integration Tests for Crawl4AI Docker API.
Tests that security fixes are working correctly against a running server.
Usage:
python run_security_tests.py [base_url]
Example:
python run_security_tests.py http://localhost:11235
"""
import subprocess
import sys
import re
# Colors for terminal output
GREEN = '\033[0;32m'
RED = '\033[0;31m'
YELLOW = '\033[1;33m'
NC = '\033[0m' # No Color
PASSED = 0
FAILED = 0
def run_curl(args: list) -> str:
"""Run curl command and return output."""
try:
result = subprocess.run(
['curl', '-s'] + args,
capture_output=True,
text=True,
timeout=30
)
return result.stdout + result.stderr
except subprocess.TimeoutExpired:
return "TIMEOUT"
except Exception as e:
return str(e)
def test_expect(name: str, expect_pattern: str, curl_args: list) -> bool:
"""Run a test and check if output matches expected pattern."""
global PASSED, FAILED
result = run_curl(curl_args)
if re.search(expect_pattern, result, re.IGNORECASE):
print(f"{GREEN}{NC} {name}")
PASSED += 1
return True
else:
print(f"{RED}{NC} {name}")
print(f" Expected pattern: {expect_pattern}")
print(f" Got: {result[:200]}")
FAILED += 1
return False
def main():
global PASSED, FAILED
base_url = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:11235"
print("=" * 60)
print("Crawl4AI Security Integration Tests")
print(f"Target: {base_url}")
print("=" * 60)
print()
# Check server availability
print("Checking server availability...")
result = run_curl(['-o', '/dev/null', '-w', '%{http_code}', f'{base_url}/health'])
if '200' not in result:
print(f"{RED}ERROR: Server not reachable at {base_url}{NC}")
print("Please start the server first.")
sys.exit(1)
print(f"{GREEN}Server is running{NC}")
print()
# === Part A: Security Tests ===
print("=== Part A: Security Tests ===")
print("(Vulnerabilities must be BLOCKED)")
print()
test_expect(
"A1: Hooks disabled by default (403)",
r"403|disabled|Hooks are disabled",
['-X', 'POST', f'{base_url}/crawl',
'-H', 'Content-Type: application/json',
'-d', '{"urls":["https://example.com"],"hooks":{"code":{"on_page_context_created":"async def hook(page, context, **kwargs): return page"}}}']
)
test_expect(
"A2: file:// blocked on /execute_js (400)",
r"400|must start with",
['-X', 'POST', f'{base_url}/execute_js',
'-H', 'Content-Type: application/json',
'-d', '{"url":"file:///etc/passwd","scripts":["1"]}']
)
test_expect(
"A3: file:// blocked on /screenshot (400)",
r"400|must start with",
['-X', 'POST', f'{base_url}/screenshot',
'-H', 'Content-Type: application/json',
'-d', '{"url":"file:///etc/passwd"}']
)
test_expect(
"A4: file:// blocked on /pdf (400)",
r"400|must start with",
['-X', 'POST', f'{base_url}/pdf',
'-H', 'Content-Type: application/json',
'-d', '{"url":"file:///etc/passwd"}']
)
test_expect(
"A5: file:// blocked on /html (400)",
r"400|must start with",
['-X', 'POST', f'{base_url}/html',
'-H', 'Content-Type: application/json',
'-d', '{"url":"file:///etc/passwd"}']
)
print()
# === Part B: Functionality Tests ===
print("=== Part B: Functionality Tests ===")
print("(Normal operations must WORK)")
print()
test_expect(
"B1: Basic crawl works",
r"success.*true|results",
['-X', 'POST', f'{base_url}/crawl',
'-H', 'Content-Type: application/json',
'-d', '{"urls":["https://example.com"]}']
)
test_expect(
"B2: /md works with https://",
r"success.*true|markdown",
['-X', 'POST', f'{base_url}/md',
'-H', 'Content-Type: application/json',
'-d', '{"url":"https://example.com"}']
)
test_expect(
"B3: Health endpoint works",
r"ok",
[f'{base_url}/health']
)
print()
# === Part C: Edge Cases ===
print("=== Part C: Edge Cases ===")
print("(Malformed input must be REJECTED)")
print()
test_expect(
"C1: javascript: URL rejected (400)",
r"400|must start with",
['-X', 'POST', f'{base_url}/execute_js',
'-H', 'Content-Type: application/json',
'-d', '{"url":"javascript:alert(1)","scripts":["1"]}']
)
test_expect(
"C2: data: URL rejected (400)",
r"400|must start with",
['-X', 'POST', f'{base_url}/execute_js',
'-H', 'Content-Type: application/json',
'-d', '{"url":"data:text/html,<h1>test</h1>","scripts":["1"]}']
)
print()
print("=" * 60)
print("Results")
print("=" * 60)
print(f"Passed: {GREEN}{PASSED}{NC}")
print(f"Failed: {RED}{FAILED}{NC}")
print()
if FAILED > 0:
print(f"{RED}SOME TESTS FAILED{NC}")
sys.exit(1)
else:
print(f"{GREEN}ALL TESTS PASSED{NC}")
sys.exit(0)
if __name__ == '__main__':
main()
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
Test 1: Basic Container Health + Single Endpoint
- Starts container
- Hits /health endpoint 10 times
- Reports success rate and basic latency
"""
import asyncio
import time
import docker
import httpx
# Config
IMAGE = "crawl4ai-local:latest"
CONTAINER_NAME = "crawl4ai-test"
PORT = 11235
REQUESTS = 10
async def test_endpoint(url: str, count: int):
"""Hit endpoint multiple times, return stats."""
results = []
async with httpx.AsyncClient(timeout=30.0) as client:
for i in range(count):
start = time.time()
try:
resp = await client.get(url)
elapsed = (time.time() - start) * 1000 # ms
results.append({
"success": resp.status_code == 200,
"latency_ms": elapsed,
"status": resp.status_code
})
print(f" [{i+1}/{count}] ✓ {resp.status_code} - {elapsed:.0f}ms")
except Exception as e:
results.append({
"success": False,
"latency_ms": None,
"error": str(e)
})
print(f" [{i+1}/{count}] ✗ Error: {e}")
return results
def start_container(client, image: str, name: str, port: int):
"""Start container, return container object."""
# Clean up existing
try:
old = client.containers.get(name)
print(f"🧹 Stopping existing container '{name}'...")
old.stop()
old.remove()
except docker.errors.NotFound:
pass
print(f"🚀 Starting container '{name}' from image '{image}'...")
container = client.containers.run(
image,
name=name,
ports={f"{port}/tcp": port},
detach=True,
shm_size="1g",
environment={"PYTHON_ENV": "production"}
)
# Wait for health
print(f"⏳ Waiting for container to be healthy...")
for _ in range(30): # 30s timeout
time.sleep(1)
container.reload()
if container.status == "running":
try:
# Quick health check
import requests
resp = requests.get(f"http://localhost:{port}/health", timeout=2)
if resp.status_code == 200:
print(f"✅ Container healthy!")
return container
except:
pass
raise TimeoutError("Container failed to start")
def stop_container(container):
"""Stop and remove container."""
print(f"🛑 Stopping container...")
container.stop()
container.remove()
print(f"✅ Container removed")
async def main():
print("="*60)
print("TEST 1: Basic Container Health + Single Endpoint")
print("="*60)
client = docker.from_env()
container = None
try:
# Start container
container = start_container(client, IMAGE, CONTAINER_NAME, PORT)
# Test /health endpoint
print(f"\n📊 Testing /health endpoint ({REQUESTS} requests)...")
url = f"http://localhost:{PORT}/health"
results = await test_endpoint(url, REQUESTS)
# Calculate stats
successes = sum(1 for r in results if r["success"])
success_rate = (successes / len(results)) * 100
latencies = [r["latency_ms"] for r in results if r["latency_ms"] is not None]
avg_latency = sum(latencies) / len(latencies) if latencies else 0
# Print results
print(f"\n{'='*60}")
print(f"RESULTS:")
print(f" Success Rate: {success_rate:.1f}% ({successes}/{len(results)})")
print(f" Avg Latency: {avg_latency:.0f}ms")
if latencies:
print(f" Min Latency: {min(latencies):.0f}ms")
print(f" Max Latency: {max(latencies):.0f}ms")
print(f"{'='*60}")
# Pass/Fail
if success_rate >= 100:
print(f"✅ TEST PASSED")
return 0
else:
print(f"❌ TEST FAILED (expected 100% success rate)")
return 1
except Exception as e:
print(f"\n❌ TEST ERROR: {e}")
return 1
finally:
if container:
stop_container(container)
if __name__ == "__main__":
exit_code = asyncio.run(main())
exit(exit_code)
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
Test 2: Docker Stats Monitoring
- Extends Test 1 with real-time container stats
- Monitors memory % and CPU during requests
- Reports baseline, peak, and final memory
"""
import asyncio
import time
import docker
import httpx
from threading import Thread, Event
# Config
IMAGE = "crawl4ai-local:latest"
CONTAINER_NAME = "crawl4ai-test"
PORT = 11235
REQUESTS = 20 # More requests to see memory usage
# Stats tracking
stats_history = []
stop_monitoring = Event()
def monitor_stats(container):
"""Background thread to collect container stats."""
for stat in container.stats(decode=True, stream=True):
if stop_monitoring.is_set():
break
try:
# Extract memory stats
mem_usage = stat['memory_stats'].get('usage', 0) / (1024 * 1024) # MB
mem_limit = stat['memory_stats'].get('limit', 1) / (1024 * 1024)
mem_percent = (mem_usage / mem_limit * 100) if mem_limit > 0 else 0
# Extract CPU stats (handle missing fields on Mac)
cpu_percent = 0
try:
cpu_delta = stat['cpu_stats']['cpu_usage']['total_usage'] - \
stat['precpu_stats']['cpu_usage']['total_usage']
system_delta = stat['cpu_stats'].get('system_cpu_usage', 0) - \
stat['precpu_stats'].get('system_cpu_usage', 0)
if system_delta > 0:
num_cpus = stat['cpu_stats'].get('online_cpus', 1)
cpu_percent = (cpu_delta / system_delta * num_cpus * 100.0)
except (KeyError, ZeroDivisionError):
pass
stats_history.append({
'timestamp': time.time(),
'memory_mb': mem_usage,
'memory_percent': mem_percent,
'cpu_percent': cpu_percent
})
except Exception as e:
# Skip malformed stats
pass
time.sleep(0.5) # Sample every 500ms
async def test_endpoint(url: str, count: int):
"""Hit endpoint, return stats."""
results = []
async with httpx.AsyncClient(timeout=30.0) as client:
for i in range(count):
start = time.time()
try:
resp = await client.get(url)
elapsed = (time.time() - start) * 1000
results.append({
"success": resp.status_code == 200,
"latency_ms": elapsed,
})
if (i + 1) % 5 == 0: # Print every 5 requests
print(f" [{i+1}/{count}] ✓ {resp.status_code} - {elapsed:.0f}ms")
except Exception as e:
results.append({"success": False, "error": str(e)})
print(f" [{i+1}/{count}] ✗ Error: {e}")
return results
def start_container(client, image: str, name: str, port: int):
"""Start container."""
try:
old = client.containers.get(name)
print(f"🧹 Stopping existing container '{name}'...")
old.stop()
old.remove()
except docker.errors.NotFound:
pass
print(f"🚀 Starting container '{name}'...")
container = client.containers.run(
image,
name=name,
ports={f"{port}/tcp": port},
detach=True,
shm_size="1g",
mem_limit="4g", # Set explicit memory limit
)
print(f"⏳ Waiting for health...")
for _ in range(30):
time.sleep(1)
container.reload()
if container.status == "running":
try:
import requests
resp = requests.get(f"http://localhost:{port}/health", timeout=2)
if resp.status_code == 200:
print(f"✅ Container healthy!")
return container
except:
pass
raise TimeoutError("Container failed to start")
def stop_container(container):
"""Stop container."""
print(f"🛑 Stopping container...")
container.stop()
container.remove()
async def main():
print("="*60)
print("TEST 2: Docker Stats Monitoring")
print("="*60)
client = docker.from_env()
container = None
monitor_thread = None
try:
# Start container
container = start_container(client, IMAGE, CONTAINER_NAME, PORT)
# Start stats monitoring in background
print(f"\n📊 Starting stats monitor...")
stop_monitoring.clear()
stats_history.clear()
monitor_thread = Thread(target=monitor_stats, args=(container,), daemon=True)
monitor_thread.start()
# Wait a bit for baseline
await asyncio.sleep(2)
baseline_mem = stats_history[-1]['memory_mb'] if stats_history else 0
print(f"📏 Baseline memory: {baseline_mem:.1f} MB")
# Test /health endpoint
print(f"\n🔄 Running {REQUESTS} requests to /health...")
url = f"http://localhost:{PORT}/health"
results = await test_endpoint(url, REQUESTS)
# Wait a bit to capture peak
await asyncio.sleep(1)
# Stop monitoring
stop_monitoring.set()
if monitor_thread:
monitor_thread.join(timeout=2)
# Calculate stats
successes = sum(1 for r in results if r.get("success"))
success_rate = (successes / len(results)) * 100
latencies = [r["latency_ms"] for r in results if "latency_ms" in r]
avg_latency = sum(latencies) / len(latencies) if latencies else 0
# Memory stats
memory_samples = [s['memory_mb'] for s in stats_history]
peak_mem = max(memory_samples) if memory_samples else 0
final_mem = memory_samples[-1] if memory_samples else 0
mem_delta = final_mem - baseline_mem
# Print results
print(f"\n{'='*60}")
print(f"RESULTS:")
print(f" Success Rate: {success_rate:.1f}% ({successes}/{len(results)})")
print(f" Avg Latency: {avg_latency:.0f}ms")
print(f"\n Memory Stats:")
print(f" Baseline: {baseline_mem:.1f} MB")
print(f" Peak: {peak_mem:.1f} MB")
print(f" Final: {final_mem:.1f} MB")
print(f" Delta: {mem_delta:+.1f} MB")
print(f"{'='*60}")
# Pass/Fail
if success_rate >= 100 and mem_delta < 100: # No significant memory growth
print(f"✅ TEST PASSED")
return 0
else:
if success_rate < 100:
print(f"❌ TEST FAILED (success rate < 100%)")
if mem_delta >= 100:
print(f"⚠️ WARNING: Memory grew by {mem_delta:.1f} MB")
return 1
except Exception as e:
print(f"\n❌ TEST ERROR: {e}")
return 1
finally:
stop_monitoring.set()
if container:
stop_container(container)
if __name__ == "__main__":
exit_code = asyncio.run(main())
exit(exit_code)
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
Test 3: Pool Validation - Permanent Browser Reuse
- Tests /html endpoint (should use permanent browser)
- Monitors container logs for pool hit markers
- Validates browser reuse rate
- Checks memory after browser creation
"""
import asyncio
import time
import docker
import httpx
from threading import Thread, Event
# Config
IMAGE = "crawl4ai-local:latest"
CONTAINER_NAME = "crawl4ai-test"
PORT = 11235
REQUESTS = 30
# Stats tracking
stats_history = []
stop_monitoring = Event()
def monitor_stats(container):
"""Background stats collector."""
for stat in container.stats(decode=True, stream=True):
if stop_monitoring.is_set():
break
try:
mem_usage = stat['memory_stats'].get('usage', 0) / (1024 * 1024)
stats_history.append({
'timestamp': time.time(),
'memory_mb': mem_usage,
})
except:
pass
time.sleep(0.5)
def count_log_markers(container):
"""Extract pool usage markers from logs."""
logs = container.logs().decode('utf-8')
permanent_hits = logs.count("🔥 Using permanent browser")
hot_hits = logs.count("♨️ Using hot pool browser")
cold_hits = logs.count("❄️ Using cold pool browser")
new_created = logs.count("🆕 Creating new browser")
return {
'permanent_hits': permanent_hits,
'hot_hits': hot_hits,
'cold_hits': cold_hits,
'new_created': new_created,
'total_hits': permanent_hits + hot_hits + cold_hits
}
async def test_endpoint(url: str, count: int):
"""Hit endpoint multiple times."""
results = []
async with httpx.AsyncClient(timeout=60.0) as client:
for i in range(count):
start = time.time()
try:
resp = await client.post(url, json={"url": "https://httpbin.org/html"})
elapsed = (time.time() - start) * 1000
results.append({
"success": resp.status_code == 200,
"latency_ms": elapsed,
})
if (i + 1) % 10 == 0:
print(f" [{i+1}/{count}] ✓ {resp.status_code} - {elapsed:.0f}ms")
except Exception as e:
results.append({"success": False, "error": str(e)})
print(f" [{i+1}/{count}] ✗ Error: {e}")
return results
def start_container(client, image: str, name: str, port: int):
"""Start container."""
try:
old = client.containers.get(name)
print(f"🧹 Stopping existing container...")
old.stop()
old.remove()
except docker.errors.NotFound:
pass
print(f"🚀 Starting container...")
container = client.containers.run(
image,
name=name,
ports={f"{port}/tcp": port},
detach=True,
shm_size="1g",
mem_limit="4g",
)
print(f"⏳ Waiting for health...")
for _ in range(30):
time.sleep(1)
container.reload()
if container.status == "running":
try:
import requests
resp = requests.get(f"http://localhost:{port}/health", timeout=2)
if resp.status_code == 200:
print(f"✅ Container healthy!")
return container
except:
pass
raise TimeoutError("Container failed to start")
def stop_container(container):
"""Stop container."""
print(f"🛑 Stopping container...")
container.stop()
container.remove()
async def main():
print("="*60)
print("TEST 3: Pool Validation - Permanent Browser Reuse")
print("="*60)
client = docker.from_env()
container = None
monitor_thread = None
try:
# Start container
container = start_container(client, IMAGE, CONTAINER_NAME, PORT)
# Wait for permanent browser initialization
print(f"\n⏳ Waiting for permanent browser init (3s)...")
await asyncio.sleep(3)
# Start stats monitoring
print(f"📊 Starting stats monitor...")
stop_monitoring.clear()
stats_history.clear()
monitor_thread = Thread(target=monitor_stats, args=(container,), daemon=True)
monitor_thread.start()
await asyncio.sleep(1)
baseline_mem = stats_history[-1]['memory_mb'] if stats_history else 0
print(f"📏 Baseline (with permanent browser): {baseline_mem:.1f} MB")
# Test /html endpoint (uses permanent browser for default config)
print(f"\n🔄 Running {REQUESTS} requests to /html...")
url = f"http://localhost:{PORT}/html"
results = await test_endpoint(url, REQUESTS)
# Wait a bit
await asyncio.sleep(1)
# Stop monitoring
stop_monitoring.set()
if monitor_thread:
monitor_thread.join(timeout=2)
# Analyze logs for pool markers
print(f"\n📋 Analyzing pool usage...")
pool_stats = count_log_markers(container)
# Calculate request stats
successes = sum(1 for r in results if r.get("success"))
success_rate = (successes / len(results)) * 100
latencies = [r["latency_ms"] for r in results if "latency_ms" in r]
avg_latency = sum(latencies) / len(latencies) if latencies else 0
# Memory stats
memory_samples = [s['memory_mb'] for s in stats_history]
peak_mem = max(memory_samples) if memory_samples else 0
final_mem = memory_samples[-1] if memory_samples else 0
mem_delta = final_mem - baseline_mem
# Calculate reuse rate
total_requests = len(results)
total_pool_hits = pool_stats['total_hits']
reuse_rate = (total_pool_hits / total_requests * 100) if total_requests > 0 else 0
# Print results
print(f"\n{'='*60}")
print(f"RESULTS:")
print(f" Success Rate: {success_rate:.1f}% ({successes}/{len(results)})")
print(f" Avg Latency: {avg_latency:.0f}ms")
print(f"\n Pool Stats:")
print(f" 🔥 Permanent Hits: {pool_stats['permanent_hits']}")
print(f" ♨️ Hot Pool Hits: {pool_stats['hot_hits']}")
print(f" ❄️ Cold Pool Hits: {pool_stats['cold_hits']}")
print(f" 🆕 New Created: {pool_stats['new_created']}")
print(f" 📊 Reuse Rate: {reuse_rate:.1f}%")
print(f"\n Memory Stats:")
print(f" Baseline: {baseline_mem:.1f} MB")
print(f" Peak: {peak_mem:.1f} MB")
print(f" Final: {final_mem:.1f} MB")
print(f" Delta: {mem_delta:+.1f} MB")
print(f"{'='*60}")
# Pass/Fail
passed = True
if success_rate < 100:
print(f"❌ FAIL: Success rate {success_rate:.1f}% < 100%")
passed = False
if reuse_rate < 80:
print(f"❌ FAIL: Reuse rate {reuse_rate:.1f}% < 80% (expected high permanent browser usage)")
passed = False
if pool_stats['permanent_hits'] < (total_requests * 0.8):
print(f"⚠️ WARNING: Only {pool_stats['permanent_hits']} permanent hits out of {total_requests} requests")
if mem_delta > 200:
print(f"⚠️ WARNING: Memory grew by {mem_delta:.1f} MB (possible browser leak)")
if passed:
print(f"✅ TEST PASSED")
return 0
else:
return 1
except Exception as e:
print(f"\n❌ TEST ERROR: {e}")
import traceback
traceback.print_exc()
return 1
finally:
stop_monitoring.set()
if container:
stop_container(container)
if __name__ == "__main__":
exit_code = asyncio.run(main())
exit(exit_code)
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""
Test 4: Concurrent Load Testing
- Tests pool under concurrent load
- Escalates: 10 → 50 → 100 concurrent requests
- Validates latency distribution (P50, P95, P99)
- Monitors memory stability
"""
import asyncio
import time
import docker
import httpx
from threading import Thread, Event
from collections import defaultdict
# Config
IMAGE = "crawl4ai-local:latest"
CONTAINER_NAME = "crawl4ai-test"
PORT = 11235
LOAD_LEVELS = [
{"name": "Light", "concurrent": 10, "requests": 20},
{"name": "Medium", "concurrent": 50, "requests": 100},
{"name": "Heavy", "concurrent": 100, "requests": 200},
]
# Stats
stats_history = []
stop_monitoring = Event()
def monitor_stats(container):
"""Background stats collector."""
for stat in container.stats(decode=True, stream=True):
if stop_monitoring.is_set():
break
try:
mem_usage = stat['memory_stats'].get('usage', 0) / (1024 * 1024)
stats_history.append({'timestamp': time.time(), 'memory_mb': mem_usage})
except:
pass
time.sleep(0.5)
def count_log_markers(container):
"""Extract pool markers."""
logs = container.logs().decode('utf-8')
return {
'permanent': logs.count("🔥 Using permanent browser"),
'hot': logs.count("♨️ Using hot pool browser"),
'cold': logs.count("❄️ Using cold pool browser"),
'new': logs.count("🆕 Creating new browser"),
}
async def hit_endpoint(client, url, payload, semaphore):
"""Single request with concurrency control."""
async with semaphore:
start = time.time()
try:
resp = await client.post(url, json=payload, timeout=60.0)
elapsed = (time.time() - start) * 1000
return {"success": resp.status_code == 200, "latency_ms": elapsed}
except Exception as e:
return {"success": False, "error": str(e)}
async def run_concurrent_test(url, payload, concurrent, total_requests):
"""Run concurrent requests."""
semaphore = asyncio.Semaphore(concurrent)
async with httpx.AsyncClient() as client:
tasks = [hit_endpoint(client, url, payload, semaphore) for _ in range(total_requests)]
results = await asyncio.gather(*tasks)
return results
def calculate_percentiles(latencies):
"""Calculate P50, P95, P99."""
if not latencies:
return 0, 0, 0
sorted_lat = sorted(latencies)
n = len(sorted_lat)
return (
sorted_lat[int(n * 0.50)],
sorted_lat[int(n * 0.95)],
sorted_lat[int(n * 0.99)],
)
def start_container(client, image, name, port):
"""Start container."""
try:
old = client.containers.get(name)
print(f"🧹 Stopping existing container...")
old.stop()
old.remove()
except docker.errors.NotFound:
pass
print(f"🚀 Starting container...")
container = client.containers.run(
image, name=name, ports={f"{port}/tcp": port},
detach=True, shm_size="1g", mem_limit="4g",
)
print(f"⏳ Waiting for health...")
for _ in range(30):
time.sleep(1)
container.reload()
if container.status == "running":
try:
import requests
if requests.get(f"http://localhost:{port}/health", timeout=2).status_code == 200:
print(f"✅ Container healthy!")
return container
except:
pass
raise TimeoutError("Container failed to start")
async def main():
print("="*60)
print("TEST 4: Concurrent Load Testing")
print("="*60)
client = docker.from_env()
container = None
monitor_thread = None
try:
container = start_container(client, IMAGE, CONTAINER_NAME, PORT)
print(f"\n⏳ Waiting for permanent browser init (3s)...")
await asyncio.sleep(3)
# Start monitoring
stop_monitoring.clear()
stats_history.clear()
monitor_thread = Thread(target=monitor_stats, args=(container,), daemon=True)
monitor_thread.start()
await asyncio.sleep(1)
baseline_mem = stats_history[-1]['memory_mb'] if stats_history else 0
print(f"📏 Baseline: {baseline_mem:.1f} MB\n")
url = f"http://localhost:{PORT}/html"
payload = {"url": "https://httpbin.org/html"}
all_results = []
level_stats = []
# Run load levels
for level in LOAD_LEVELS:
print(f"{'='*60}")
print(f"🔄 {level['name']} Load: {level['concurrent']} concurrent, {level['requests']} total")
print(f"{'='*60}")
start_time = time.time()
results = await run_concurrent_test(url, payload, level['concurrent'], level['requests'])
duration = time.time() - start_time
successes = sum(1 for r in results if r.get("success"))
success_rate = (successes / len(results)) * 100
latencies = [r["latency_ms"] for r in results if "latency_ms" in r]
p50, p95, p99 = calculate_percentiles(latencies)
avg_lat = sum(latencies) / len(latencies) if latencies else 0
print(f" Duration: {duration:.1f}s")
print(f" Success: {success_rate:.1f}% ({successes}/{len(results)})")
print(f" Avg Latency: {avg_lat:.0f}ms")
print(f" P50/P95/P99: {p50:.0f}ms / {p95:.0f}ms / {p99:.0f}ms")
level_stats.append({
'name': level['name'],
'concurrent': level['concurrent'],
'success_rate': success_rate,
'avg_latency': avg_lat,
'p50': p50, 'p95': p95, 'p99': p99,
})
all_results.extend(results)
await asyncio.sleep(2) # Cool down between levels
# Stop monitoring
await asyncio.sleep(1)
stop_monitoring.set()
if monitor_thread:
monitor_thread.join(timeout=2)
# Final stats
pool_stats = count_log_markers(container)
memory_samples = [s['memory_mb'] for s in stats_history]
peak_mem = max(memory_samples) if memory_samples else 0
final_mem = memory_samples[-1] if memory_samples else 0
print(f"\n{'='*60}")
print(f"FINAL RESULTS:")
print(f"{'='*60}")
print(f" Total Requests: {len(all_results)}")
print(f"\n Pool Utilization:")
print(f" 🔥 Permanent: {pool_stats['permanent']}")
print(f" ♨️ Hot: {pool_stats['hot']}")
print(f" ❄️ Cold: {pool_stats['cold']}")
print(f" 🆕 New: {pool_stats['new']}")
print(f"\n Memory:")
print(f" Baseline: {baseline_mem:.1f} MB")
print(f" Peak: {peak_mem:.1f} MB")
print(f" Final: {final_mem:.1f} MB")
print(f" Delta: {final_mem - baseline_mem:+.1f} MB")
print(f"{'='*60}")
# Pass/Fail
passed = True
for ls in level_stats:
if ls['success_rate'] < 99:
print(f"❌ FAIL: {ls['name']} success rate {ls['success_rate']:.1f}% < 99%")
passed = False
if ls['p99'] > 10000: # 10s threshold
print(f"⚠️ WARNING: {ls['name']} P99 latency {ls['p99']:.0f}ms very high")
if final_mem - baseline_mem > 300:
print(f"⚠️ WARNING: Memory grew {final_mem - baseline_mem:.1f} MB")
if passed:
print(f"✅ TEST PASSED")
return 0
else:
return 1
except Exception as e:
print(f"\n❌ TEST ERROR: {e}")
import traceback
traceback.print_exc()
return 1
finally:
stop_monitoring.set()
if container:
print(f"🛑 Stopping container...")
container.stop()
container.remove()
if __name__ == "__main__":
exit_code = asyncio.run(main())
exit(exit_code)
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""
Test 5: Pool Stress - Mixed Configs
- Tests hot/cold pool with different browser configs
- Uses different viewports to create config variants
- Validates cold → hot promotion after 3 uses
- Monitors pool tier distribution
"""
import asyncio
import time
import docker
import httpx
from threading import Thread, Event
import random
# Config
IMAGE = "crawl4ai-local:latest"
CONTAINER_NAME = "crawl4ai-test"
PORT = 11235
REQUESTS_PER_CONFIG = 5 # 5 requests per config variant
# Different viewport configs to test pool tiers
VIEWPORT_CONFIGS = [
None, # Default (permanent browser)
{"width": 1920, "height": 1080}, # Desktop
{"width": 1024, "height": 768}, # Tablet
{"width": 375, "height": 667}, # Mobile
]
# Stats
stats_history = []
stop_monitoring = Event()
def monitor_stats(container):
"""Background stats collector."""
for stat in container.stats(decode=True, stream=True):
if stop_monitoring.is_set():
break
try:
mem_usage = stat['memory_stats'].get('usage', 0) / (1024 * 1024)
stats_history.append({'timestamp': time.time(), 'memory_mb': mem_usage})
except:
pass
time.sleep(0.5)
def analyze_pool_logs(container):
"""Extract detailed pool stats from logs."""
logs = container.logs().decode('utf-8')
permanent = logs.count("🔥 Using permanent browser")
hot = logs.count("♨️ Using hot pool browser")
cold = logs.count("❄️ Using cold pool browser")
new = logs.count("🆕 Creating new browser")
promotions = logs.count("⬆️ Promoting to hot pool")
return {
'permanent': permanent,
'hot': hot,
'cold': cold,
'new': new,
'promotions': promotions,
'total': permanent + hot + cold
}
async def crawl_with_viewport(client, url, viewport):
"""Single request with specific viewport."""
payload = {
"urls": ["https://httpbin.org/html"],
"browser_config": {},
"crawler_config": {}
}
# Add viewport if specified
if viewport:
payload["browser_config"] = {
"type": "BrowserConfig",
"params": {
"viewport": {"type": "dict", "value": viewport},
"headless": True,
"text_mode": True,
"extra_args": [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-software-rasterizer",
"--disable-web-security",
"--allow-insecure-localhost",
"--ignore-certificate-errors"
]
}
}
start = time.time()
try:
resp = await client.post(url, json=payload, timeout=60.0)
elapsed = (time.time() - start) * 1000
return {"success": resp.status_code == 200, "latency_ms": elapsed, "viewport": viewport}
except Exception as e:
return {"success": False, "error": str(e), "viewport": viewport}
def start_container(client, image, name, port):
"""Start container."""
try:
old = client.containers.get(name)
print(f"🧹 Stopping existing container...")
old.stop()
old.remove()
except docker.errors.NotFound:
pass
print(f"🚀 Starting container...")
container = client.containers.run(
image, name=name, ports={f"{port}/tcp": port},
detach=True, shm_size="1g", mem_limit="4g",
)
print(f"⏳ Waiting for health...")
for _ in range(30):
time.sleep(1)
container.reload()
if container.status == "running":
try:
import requests
if requests.get(f"http://localhost:{port}/health", timeout=2).status_code == 200:
print(f"✅ Container healthy!")
return container
except:
pass
raise TimeoutError("Container failed to start")
async def main():
print("="*60)
print("TEST 5: Pool Stress - Mixed Configs")
print("="*60)
client = docker.from_env()
container = None
monitor_thread = None
try:
container = start_container(client, IMAGE, CONTAINER_NAME, PORT)
print(f"\n⏳ Waiting for permanent browser init (3s)...")
await asyncio.sleep(3)
# Start monitoring
stop_monitoring.clear()
stats_history.clear()
monitor_thread = Thread(target=monitor_stats, args=(container,), daemon=True)
monitor_thread.start()
await asyncio.sleep(1)
baseline_mem = stats_history[-1]['memory_mb'] if stats_history else 0
print(f"📏 Baseline: {baseline_mem:.1f} MB\n")
url = f"http://localhost:{PORT}/crawl"
print(f"Testing {len(VIEWPORT_CONFIGS)} different configs:")
for i, vp in enumerate(VIEWPORT_CONFIGS):
vp_str = "Default" if vp is None else f"{vp['width']}x{vp['height']}"
print(f" {i+1}. {vp_str}")
print()
# Run requests: repeat each config REQUESTS_PER_CONFIG times
all_results = []
config_sequence = []
for _ in range(REQUESTS_PER_CONFIG):
for viewport in VIEWPORT_CONFIGS:
config_sequence.append(viewport)
# Shuffle to mix configs
random.shuffle(config_sequence)
print(f"🔄 Running {len(config_sequence)} requests with mixed configs...")
async with httpx.AsyncClient() as http_client:
for i, viewport in enumerate(config_sequence):
result = await crawl_with_viewport(http_client, url, viewport)
all_results.append(result)
if (i + 1) % 5 == 0:
vp_str = "default" if result['viewport'] is None else f"{result['viewport']['width']}x{result['viewport']['height']}"
status = "" if result.get('success') else ""
lat = f"{result.get('latency_ms', 0):.0f}ms" if 'latency_ms' in result else "error"
print(f" [{i+1}/{len(config_sequence)}] {status} {vp_str} - {lat}")
# Stop monitoring
await asyncio.sleep(2)
stop_monitoring.set()
if monitor_thread:
monitor_thread.join(timeout=2)
# Analyze results
pool_stats = analyze_pool_logs(container)
successes = sum(1 for r in all_results if r.get("success"))
success_rate = (successes / len(all_results)) * 100
latencies = [r["latency_ms"] for r in all_results if "latency_ms" in r]
avg_lat = sum(latencies) / len(latencies) if latencies else 0
memory_samples = [s['memory_mb'] for s in stats_history]
peak_mem = max(memory_samples) if memory_samples else 0
final_mem = memory_samples[-1] if memory_samples else 0
print(f"\n{'='*60}")
print(f"RESULTS:")
print(f"{'='*60}")
print(f" Requests: {len(all_results)}")
print(f" Success Rate: {success_rate:.1f}% ({successes}/{len(all_results)})")
print(f" Avg Latency: {avg_lat:.0f}ms")
print(f"\n Pool Statistics:")
print(f" 🔥 Permanent: {pool_stats['permanent']}")
print(f" ♨️ Hot: {pool_stats['hot']}")
print(f" ❄️ Cold: {pool_stats['cold']}")
print(f" 🆕 New: {pool_stats['new']}")
print(f" ⬆️ Promotions: {pool_stats['promotions']}")
print(f" 📊 Reuse: {(pool_stats['total'] / len(all_results) * 100):.1f}%")
print(f"\n Memory:")
print(f" Baseline: {baseline_mem:.1f} MB")
print(f" Peak: {peak_mem:.1f} MB")
print(f" Final: {final_mem:.1f} MB")
print(f" Delta: {final_mem - baseline_mem:+.1f} MB")
print(f"{'='*60}")
# Pass/Fail
passed = True
if success_rate < 99:
print(f"❌ FAIL: Success rate {success_rate:.1f}% < 99%")
passed = False
# Should see promotions since we repeat each config 5 times
if pool_stats['promotions'] < (len(VIEWPORT_CONFIGS) - 1): # -1 for default
print(f"⚠️ WARNING: Only {pool_stats['promotions']} promotions (expected ~{len(VIEWPORT_CONFIGS)-1})")
# Should have created some browsers for different configs
if pool_stats['new'] == 0:
print(f"⚠️ NOTE: No new browsers created (all used default?)")
if pool_stats['permanent'] == len(all_results):
print(f"⚠️ NOTE: All requests used permanent browser (configs not varying enough?)")
if final_mem - baseline_mem > 500:
print(f"⚠️ WARNING: Memory grew {final_mem - baseline_mem:.1f} MB")
if passed:
print(f"✅ TEST PASSED")
return 0
else:
return 1
except Exception as e:
print(f"\n❌ TEST ERROR: {e}")
import traceback
traceback.print_exc()
return 1
finally:
stop_monitoring.set()
if container:
print(f"🛑 Stopping container...")
container.stop()
container.remove()
if __name__ == "__main__":
exit_code = asyncio.run(main())
exit(exit_code)
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""
Test 6: Multi-Endpoint Testing
- Tests multiple endpoints together: /html, /screenshot, /pdf, /crawl
- Validates each endpoint works correctly
- Monitors success rates per endpoint
"""
import asyncio
import time
import docker
import httpx
from threading import Thread, Event
# Config
IMAGE = "crawl4ai-local:latest"
CONTAINER_NAME = "crawl4ai-test"
PORT = 11235
REQUESTS_PER_ENDPOINT = 10
# Stats
stats_history = []
stop_monitoring = Event()
def monitor_stats(container):
"""Background stats collector."""
for stat in container.stats(decode=True, stream=True):
if stop_monitoring.is_set():
break
try:
mem_usage = stat['memory_stats'].get('usage', 0) / (1024 * 1024)
stats_history.append({'timestamp': time.time(), 'memory_mb': mem_usage})
except:
pass
time.sleep(0.5)
async def test_html(client, base_url, count):
"""Test /html endpoint."""
url = f"{base_url}/html"
results = []
for _ in range(count):
start = time.time()
try:
resp = await client.post(url, json={"url": "https://httpbin.org/html"}, timeout=30.0)
elapsed = (time.time() - start) * 1000
results.append({"success": resp.status_code == 200, "latency_ms": elapsed})
except Exception as e:
results.append({"success": False, "error": str(e)})
return results
async def test_screenshot(client, base_url, count):
"""Test /screenshot endpoint."""
url = f"{base_url}/screenshot"
results = []
for _ in range(count):
start = time.time()
try:
resp = await client.post(url, json={"url": "https://httpbin.org/html"}, timeout=30.0)
elapsed = (time.time() - start) * 1000
results.append({"success": resp.status_code == 200, "latency_ms": elapsed})
except Exception as e:
results.append({"success": False, "error": str(e)})
return results
async def test_pdf(client, base_url, count):
"""Test /pdf endpoint."""
url = f"{base_url}/pdf"
results = []
for _ in range(count):
start = time.time()
try:
resp = await client.post(url, json={"url": "https://httpbin.org/html"}, timeout=30.0)
elapsed = (time.time() - start) * 1000
results.append({"success": resp.status_code == 200, "latency_ms": elapsed})
except Exception as e:
results.append({"success": False, "error": str(e)})
return results
async def test_crawl(client, base_url, count):
"""Test /crawl endpoint."""
url = f"{base_url}/crawl"
results = []
payload = {
"urls": ["https://httpbin.org/html"],
"browser_config": {},
"crawler_config": {}
}
for _ in range(count):
start = time.time()
try:
resp = await client.post(url, json=payload, timeout=30.0)
elapsed = (time.time() - start) * 1000
results.append({"success": resp.status_code == 200, "latency_ms": elapsed})
except Exception as e:
results.append({"success": False, "error": str(e)})
return results
def start_container(client, image, name, port):
"""Start container."""
try:
old = client.containers.get(name)
print(f"🧹 Stopping existing container...")
old.stop()
old.remove()
except docker.errors.NotFound:
pass
print(f"🚀 Starting container...")
container = client.containers.run(
image, name=name, ports={f"{port}/tcp": port},
detach=True, shm_size="1g", mem_limit="4g",
)
print(f"⏳ Waiting for health...")
for _ in range(30):
time.sleep(1)
container.reload()
if container.status == "running":
try:
import requests
if requests.get(f"http://localhost:{port}/health", timeout=2).status_code == 200:
print(f"✅ Container healthy!")
return container
except:
pass
raise TimeoutError("Container failed to start")
async def main():
print("="*60)
print("TEST 6: Multi-Endpoint Testing")
print("="*60)
client = docker.from_env()
container = None
monitor_thread = None
try:
container = start_container(client, IMAGE, CONTAINER_NAME, PORT)
print(f"\n⏳ Waiting for permanent browser init (3s)...")
await asyncio.sleep(3)
# Start monitoring
stop_monitoring.clear()
stats_history.clear()
monitor_thread = Thread(target=monitor_stats, args=(container,), daemon=True)
monitor_thread.start()
await asyncio.sleep(1)
baseline_mem = stats_history[-1]['memory_mb'] if stats_history else 0
print(f"📏 Baseline: {baseline_mem:.1f} MB\n")
base_url = f"http://localhost:{PORT}"
# Test each endpoint
endpoints = {
"/html": test_html,
"/screenshot": test_screenshot,
"/pdf": test_pdf,
"/crawl": test_crawl,
}
all_endpoint_stats = {}
async with httpx.AsyncClient() as http_client:
for endpoint_name, test_func in endpoints.items():
print(f"🔄 Testing {endpoint_name} ({REQUESTS_PER_ENDPOINT} requests)...")
results = await test_func(http_client, base_url, REQUESTS_PER_ENDPOINT)
successes = sum(1 for r in results if r.get("success"))
success_rate = (successes / len(results)) * 100
latencies = [r["latency_ms"] for r in results if "latency_ms" in r]
avg_lat = sum(latencies) / len(latencies) if latencies else 0
all_endpoint_stats[endpoint_name] = {
'success_rate': success_rate,
'avg_latency': avg_lat,
'total': len(results),
'successes': successes
}
print(f" ✓ Success: {success_rate:.1f}% ({successes}/{len(results)}), Avg: {avg_lat:.0f}ms")
# Stop monitoring
await asyncio.sleep(1)
stop_monitoring.set()
if monitor_thread:
monitor_thread.join(timeout=2)
# Final stats
memory_samples = [s['memory_mb'] for s in stats_history]
peak_mem = max(memory_samples) if memory_samples else 0
final_mem = memory_samples[-1] if memory_samples else 0
print(f"\n{'='*60}")
print(f"RESULTS:")
print(f"{'='*60}")
for endpoint, stats in all_endpoint_stats.items():
print(f" {endpoint:12} Success: {stats['success_rate']:5.1f}% Avg: {stats['avg_latency']:6.0f}ms")
print(f"\n Memory:")
print(f" Baseline: {baseline_mem:.1f} MB")
print(f" Peak: {peak_mem:.1f} MB")
print(f" Final: {final_mem:.1f} MB")
print(f" Delta: {final_mem - baseline_mem:+.1f} MB")
print(f"{'='*60}")
# Pass/Fail
passed = True
for endpoint, stats in all_endpoint_stats.items():
if stats['success_rate'] < 100:
print(f"❌ FAIL: {endpoint} success rate {stats['success_rate']:.1f}% < 100%")
passed = False
if passed:
print(f"✅ TEST PASSED")
return 0
else:
return 1
except Exception as e:
print(f"\n❌ TEST ERROR: {e}")
import traceback
traceback.print_exc()
return 1
finally:
stop_monitoring.set()
if container:
print(f"🛑 Stopping container...")
container.stop()
container.remove()
if __name__ == "__main__":
exit_code = asyncio.run(main())
exit(exit_code)
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""
Test 7: Cleanup Verification (Janitor)
- Creates load spike then goes idle
- Verifies memory returns to near baseline
- Tests janitor cleanup of idle browsers
- Monitors memory recovery time
"""
import asyncio
import time
import docker
import httpx
from threading import Thread, Event
# Config
IMAGE = "crawl4ai-local:latest"
CONTAINER_NAME = "crawl4ai-test"
PORT = 11235
SPIKE_REQUESTS = 20 # Create some browsers
IDLE_TIME = 90 # Wait 90s for janitor (runs every 60s)
# Stats
stats_history = []
stop_monitoring = Event()
def monitor_stats(container):
"""Background stats collector."""
for stat in container.stats(decode=True, stream=True):
if stop_monitoring.is_set():
break
try:
mem_usage = stat['memory_stats'].get('usage', 0) / (1024 * 1024)
stats_history.append({'timestamp': time.time(), 'memory_mb': mem_usage})
except:
pass
time.sleep(1) # Sample every 1s for this test
def start_container(client, image, name, port):
"""Start container."""
try:
old = client.containers.get(name)
print(f"🧹 Stopping existing container...")
old.stop()
old.remove()
except docker.errors.NotFound:
pass
print(f"🚀 Starting container...")
container = client.containers.run(
image, name=name, ports={f"{port}/tcp": port},
detach=True, shm_size="1g", mem_limit="4g",
)
print(f"⏳ Waiting for health...")
for _ in range(30):
time.sleep(1)
container.reload()
if container.status == "running":
try:
import requests
if requests.get(f"http://localhost:{port}/health", timeout=2).status_code == 200:
print(f"✅ Container healthy!")
return container
except:
pass
raise TimeoutError("Container failed to start")
async def main():
print("="*60)
print("TEST 7: Cleanup Verification (Janitor)")
print("="*60)
client = docker.from_env()
container = None
monitor_thread = None
try:
container = start_container(client, IMAGE, CONTAINER_NAME, PORT)
print(f"\n⏳ Waiting for permanent browser init (3s)...")
await asyncio.sleep(3)
# Start monitoring
stop_monitoring.clear()
stats_history.clear()
monitor_thread = Thread(target=monitor_stats, args=(container,), daemon=True)
monitor_thread.start()
await asyncio.sleep(2)
baseline_mem = stats_history[-1]['memory_mb'] if stats_history else 0
print(f"📏 Baseline: {baseline_mem:.1f} MB\n")
# Create load spike with different configs to populate pool
print(f"🔥 Creating load spike ({SPIKE_REQUESTS} requests with varied configs)...")
url = f"http://localhost:{PORT}/crawl"
viewports = [
{"width": 1920, "height": 1080},
{"width": 1024, "height": 768},
{"width": 375, "height": 667},
]
async with httpx.AsyncClient(timeout=60.0) as http_client:
tasks = []
for i in range(SPIKE_REQUESTS):
vp = viewports[i % len(viewports)]
payload = {
"urls": ["https://httpbin.org/html"],
"browser_config": {
"type": "BrowserConfig",
"params": {
"viewport": {"type": "dict", "value": vp},
"headless": True,
"text_mode": True,
"extra_args": [
"--no-sandbox", "--disable-dev-shm-usage",
"--disable-gpu", "--disable-software-rasterizer",
"--disable-web-security", "--allow-insecure-localhost",
"--ignore-certificate-errors"
]
}
},
"crawler_config": {}
}
tasks.append(http_client.post(url, json=payload))
results = await asyncio.gather(*tasks, return_exceptions=True)
successes = sum(1 for r in results if hasattr(r, 'status_code') and r.status_code == 200)
print(f" ✓ Spike completed: {successes}/{len(results)} successful")
# Measure peak
await asyncio.sleep(2)
peak_mem = max([s['memory_mb'] for s in stats_history]) if stats_history else baseline_mem
print(f" 📊 Peak memory: {peak_mem:.1f} MB (+{peak_mem - baseline_mem:.1f} MB)")
# Now go idle and wait for janitor
print(f"\n⏸️ Going idle for {IDLE_TIME}s (janitor cleanup)...")
print(f" (Janitor runs every 60s, checking for idle browsers)")
for elapsed in range(0, IDLE_TIME, 10):
await asyncio.sleep(10)
current_mem = stats_history[-1]['memory_mb'] if stats_history else 0
print(f" [{elapsed+10:3d}s] Memory: {current_mem:.1f} MB")
# Stop monitoring
stop_monitoring.set()
if monitor_thread:
monitor_thread.join(timeout=2)
# Analyze memory recovery
final_mem = stats_history[-1]['memory_mb'] if stats_history else 0
recovery_mb = peak_mem - final_mem
recovery_pct = (recovery_mb / (peak_mem - baseline_mem) * 100) if (peak_mem - baseline_mem) > 0 else 0
print(f"\n{'='*60}")
print(f"RESULTS:")
print(f"{'='*60}")
print(f" Memory Journey:")
print(f" Baseline: {baseline_mem:.1f} MB")
print(f" Peak: {peak_mem:.1f} MB (+{peak_mem - baseline_mem:.1f} MB)")
print(f" Final: {final_mem:.1f} MB (+{final_mem - baseline_mem:.1f} MB)")
print(f" Recovered: {recovery_mb:.1f} MB ({recovery_pct:.1f}%)")
print(f"{'='*60}")
# Pass/Fail
passed = True
# Should have created some memory pressure
if peak_mem - baseline_mem < 100:
print(f"⚠️ WARNING: Peak increase only {peak_mem - baseline_mem:.1f} MB (expected more browsers)")
# Should recover most memory (within 100MB of baseline)
if final_mem - baseline_mem > 100:
print(f"⚠️ WARNING: Memory didn't recover well (still +{final_mem - baseline_mem:.1f} MB above baseline)")
else:
print(f"✅ Good memory recovery!")
# Baseline + 50MB tolerance
if final_mem - baseline_mem < 50:
print(f"✅ Excellent cleanup (within 50MB of baseline)")
print(f"✅ TEST PASSED")
return 0
except Exception as e:
print(f"\n❌ TEST ERROR: {e}")
import traceback
traceback.print_exc()
return 1
finally:
stop_monitoring.set()
if container:
print(f"🛑 Stopping container...")
container.stop()
container.remove()
if __name__ == "__main__":
exit_code = asyncio.run(main())
exit(exit_code)
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Quick test to generate monitor dashboard activity"""
import httpx
import asyncio
async def test_dashboard():
async with httpx.AsyncClient(timeout=30.0) as client:
print("📊 Generating dashboard activity...")
# Test 1: Simple crawl
print("\n1️⃣ Running simple crawl...")
r1 = await client.post(
"http://localhost:11235/crawl",
json={"urls": ["https://httpbin.org/html"], "crawler_config": {}}
)
print(f" Status: {r1.status_code}")
# Test 2: Multiple URLs
print("\n2️⃣ Running multi-URL crawl...")
r2 = await client.post(
"http://localhost:11235/crawl",
json={
"urls": [
"https://httpbin.org/html",
"https://httpbin.org/json"
],
"crawler_config": {}
}
)
print(f" Status: {r2.status_code}")
# Test 3: Check monitor health
print("\n3️⃣ Checking monitor health...")
r3 = await client.get("http://localhost:11235/monitor/health")
health = r3.json()
print(f" Memory: {health['container']['memory_percent']}%")
print(f" Browsers: {health['pool']['permanent']['active']}")
# Test 4: Check requests
print("\n4️⃣ Checking request log...")
r4 = await client.get("http://localhost:11235/monitor/requests")
reqs = r4.json()
print(f" Active: {len(reqs['active'])}")
print(f" Completed: {len(reqs['completed'])}")
# Test 5: Check endpoint stats
print("\n5️⃣ Checking endpoint stats...")
r5 = await client.get("http://localhost:11235/monitor/endpoints/stats")
stats = r5.json()
for endpoint, data in stats.items():
print(f" {endpoint}: {data['count']} requests, {data['avg_latency_ms']}ms avg")
print("\n✅ Dashboard should now show activity!")
print(f"\n🌐 Open: http://localhost:11235/dashboard")
if __name__ == "__main__":
asyncio.run(test_dashboard())
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""
Adversarial security tests for the 4 vulns reported 2026-04-13.
Self-contained: copies validation logic to avoid import issues with dns.resolver etc.
Vuln 1: Arbitrary File Write via /screenshot + /pdf output_path
Vuln 2: Monitor Auth Bypass (structural source check)
Vuln 3: Stored XSS in Monitor Dashboard (server-side + client-side)
Vuln 4: SSRF via Webhook URL
"""
import os
import sys
import unittest
import ipaddress
import socket
from urllib.parse import urlparse
# ============================================================================
# Local copies of security utilities (to avoid dns.resolver import in utils.py)
# ============================================================================
ALLOWED_OUTPUT_DIR = os.environ.get("CRAWL4AI_OUTPUT_DIR", "/tmp/crawl4ai-outputs")
def validate_output_path(user_path):
safe_path = os.path.normpath(user_path).lstrip(os.sep)
abs_path = os.path.abspath(os.path.join(ALLOWED_OUTPUT_DIR, safe_path))
abs_allowed = os.path.abspath(ALLOWED_OUTPUT_DIR) + os.sep
if not abs_path.startswith(abs_allowed):
raise ValueError(f"output_path must resolve within {ALLOWED_OUTPUT_DIR}")
return abs_path
_BLOCKED_NETWORKS = [
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("100.64.0.0/10"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.0.0.0/24"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("198.18.0.0/15"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
_BLOCKED_HOSTNAMES = {
"localhost", "metadata.google.internal", "metadata",
"kubernetes.default", "kubernetes.default.svc",
}
def validate_webhook_url(url):
parsed = urlparse(str(url))
hostname = parsed.hostname
if not hostname:
raise ValueError("Webhook URL must have a valid hostname")
hostname_lower = hostname.lower()
if hostname_lower in _BLOCKED_HOSTNAMES:
raise ValueError(f"Webhook URL hostname '{hostname}' is blocked")
if hostname_lower.startswith("host.docker.internal"):
raise ValueError(f"Webhook URL hostname '{hostname}' is blocked")
try:
resolved = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise ValueError(f"Cannot resolve webhook hostname '{hostname}'")
for _, _, _, _, sockaddr in resolved:
ip = ipaddress.ip_address(sockaddr[0])
for network in _BLOCKED_NETWORKS:
if ip in network:
raise ValueError(f"Webhook URL resolves to blocked address: {ip}")
DEPLOY_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
# ============================================================================
# VULN 1: Arbitrary File Write - Path Traversal
# ============================================================================
class TestOutputPathRemoved(unittest.TestCase):
"""output_path is gone; 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.
"""
def test_screenshot_request_has_no_output_path(self):
sys.path.insert(0, DEPLOY_DIR)
from schemas import ScreenshotRequest
self.assertNotIn("output_path", ScreenshotRequest.model_fields)
def test_pdf_request_has_no_output_path(self):
sys.path.insert(0, DEPLOY_DIR)
from schemas import PDFRequest
self.assertNotIn("output_path", PDFRequest.model_fields)
def test_validate_output_path_deleted(self):
sys.path.insert(0, DEPLOY_DIR)
import utils
self.assertFalse(
hasattr(utils, "validate_output_path"),
"validate_output_path must be deleted (caller paths no longer accepted)",
)
class TestPydanticPathValidator(unittest.TestCase):
"""output_path (and its traversal validator) are 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.
"""
def test_no_output_path_field_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)
def test_traversal_validator_removed(self):
# No reject_traversal validator should remain registered on the models.
sys.path.insert(0, DEPLOY_DIR)
from schemas import ScreenshotRequest, PDFRequest
for model in (ScreenshotRequest, PDFRequest):
names = set(getattr(model, "__pydantic_decorators__").field_validators)
self.assertNotIn("reject_traversal", names)
# ============================================================================
# VULN 2: Monitor Auth Bypass (structural check)
# ============================================================================
class TestMonitorAuthStructural(unittest.TestCase):
def test_monitor_router_has_auth(self):
with open(os.path.join(DEPLOY_DIR, "server.py")) as f:
source = f.read()
# Find the line with monitor_router
for line in source.splitlines():
if "monitor_router" in line and "include_router" in line:
self.assertIn("dependencies=", line,
"Monitor router must have dependencies=[Depends(token_dep)]")
return
self.fail("Could not find monitor_router include_router line")
def test_websocket_auth_enforced_by_gate(self):
"""The monitor WS is gated by the AuthGateMiddleware (behavioral).
Auth moved out of the route's bespoke, timing-unsafe env-token check
and into the outermost ASGI gate, which closes an unauthenticated
WebSocket before it is accepted.
"""
import sys
sys.path.insert(0, DEPLOY_DIR)
import server
from starlette.testclient import TestClient
client = TestClient(server.app)
# Unauthenticated connect must be rejected by the gate.
with self.assertRaises(Exception):
with client.websocket_connect("/monitor/ws") as ws:
ws.receive_text()
# The old in-route check must be gone (it read a different secret than
# the REST path and compared with a timing-unsafe `!=`).
with open(os.path.join(DEPLOY_DIR, "monitor_routes.py")) as f:
source = f.read()
self.assertNotIn('os.environ.get("CRAWL4AI_API_TOKEN"', source,
"bespoke WS token check must be removed; the gate owns WS auth")
# ============================================================================
# VULN 3: Stored XSS (server-side + client-side)
# ============================================================================
class TestXSSPrevention(unittest.TestCase):
def test_html_escape_blocks_script_tags(self):
import html
payload = '<script>alert(document.cookie)</script>'
escaped = html.escape(payload)
self.assertNotIn("<script>", escaped)
self.assertIn("&lt;script&gt;", escaped)
def test_html_escape_blocks_img_tag(self):
import html
payload = '"><img src=x onerror=alert(1)>'
escaped = html.escape(payload)
self.assertNotIn("<img", escaped)
self.assertIn("&lt;img", escaped)
def test_monitor_py_uses_html_escape(self):
with open(os.path.join(DEPLOY_DIR, "monitor.py")) as f:
source = f.read()
self.assertIn("import html", source)
self.assertIn("html.escape(url", source)
self.assertIn("html.escape(str(error)", source)
def test_index_html_has_escape_function(self):
html_path = os.path.join(DEPLOY_DIR, "static", "monitor", "index.html")
with open(html_path) as f:
source = f.read()
self.assertIn("function escapeHtml(", source)
def test_index_html_no_raw_url_injection(self):
html_path = os.path.join(DEPLOY_DIR, "static", "monitor", "index.html")
with open(html_path) as f:
source = f.read()
self.assertNotIn("${req.url}", source, "Raw ${req.url} found")
self.assertNotIn("${err.url}", source, "Raw ${err.url} found")
self.assertNotIn("${err.error}", source, "Raw ${err.error} found")
# ============================================================================
# VULN 4: SSRF via Webhook URL
# ============================================================================
class TestSSRFBlocked(unittest.TestCase):
def test_localhost_ip(self):
with self.assertRaises(ValueError):
validate_webhook_url("http://127.0.0.1:9999/steal")
def test_localhost_hostname(self):
with self.assertRaises(ValueError):
validate_webhook_url("http://localhost:9999/steal")
def test_loopback_127_x(self):
with self.assertRaises(ValueError):
validate_webhook_url("http://127.0.0.2:9999/steal")
def test_10_network(self):
with self.assertRaises(ValueError):
validate_webhook_url("http://10.0.0.1:8080/admin")
def test_172_16_network(self):
with self.assertRaises(ValueError):
validate_webhook_url("http://172.16.0.1:8080/admin")
def test_192_168_network(self):
with self.assertRaises(ValueError):
validate_webhook_url("http://192.168.1.1:8080/admin")
def test_aws_metadata_ip(self):
with self.assertRaises(ValueError):
validate_webhook_url("http://169.254.169.254/latest/meta-data/")
def test_gcp_metadata_hostname(self):
with self.assertRaises(ValueError):
validate_webhook_url("http://metadata.google.internal/computeMetadata/v1/")
def test_docker_internal(self):
with self.assertRaises(ValueError):
validate_webhook_url("http://host.docker.internal:9998/steal")
def test_kubernetes_default(self):
with self.assertRaises(ValueError):
validate_webhook_url("http://kubernetes.default/api/v1/secrets")
def test_empty_hostname(self):
with self.assertRaises(ValueError):
validate_webhook_url("http:///steal")
def test_valid_external_url(self):
validate_webhook_url("https://webhook.site/test-uuid")
validate_webhook_url("https://hooks.slack.com/services/T00/B00/xxx")
def test_valid_external_with_port(self):
"""External URL with port -- may fail DNS in CI, that's expected."""
try:
validate_webhook_url("https://google.com:443/webhook")
except ValueError as e:
if "Cannot resolve" in str(e):
self.skipTest("DNS resolution unavailable in this environment")
raise
class TestWebhookSourceChecks(unittest.TestCase):
def test_redirects_not_auto_followed(self):
# Redirects are followed manually and re-validated per hop, never
# auto-followed (which would skip the SSRF re-check).
with open(os.path.join(DEPLOY_DIR, "webhook.py")) as f:
source = f.read()
self.assertIn("allow_redirects=False", source)
self.assertIn("check_redirect", source) # each hop re-validated
def test_webhook_validates_at_send_time(self):
# Validation happens at send time via the egress broker (resolve_and_pin
# rejects non-global targets); the connection is also pinned to that IP.
with open(os.path.join(DEPLOY_DIR, "webhook.py")) as f:
source = f.read()
self.assertIn("resolve_and_pin", source)
def test_job_validates_webhook(self):
with open(os.path.join(DEPLOY_DIR, "job.py")) as f:
source = f.read()
self.assertIn("validate_webhook_url", source)
if __name__ == "__main__":
print("=" * 70)
print("Crawl4AI Security Tests - 2026-04-13 Vulnerabilities")
print("=" * 70)
print()
unittest.main(verbosity=2)
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""
Adversarial security tests for Batch 2 vulns reported 2026-04-14 (by111/August829).
Self-contained tests that verify fixes at the code/source level.
B2-V1: /execute_js disabled by default + SSRF block
B2-V2: Hardcoded JWT secret removed
B2-V3: eval() in /config/dump replaced with JSON
B2-V4: Hook sandbox __builtins__ escape fixed
"""
import os
import sys
import ast
import unittest
import builtins
import types
from unittest import mock
DEPLOY_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
# ============================================================================
# B2-V2: Hardcoded JWT Secret
# ============================================================================
class TestJWTSecretHardened(unittest.TestCase):
"""Verify the hardcoded 'mysecret' default is gone from auth.py."""
def test_no_mysecret_as_default(self):
"""auth.py must not use 'mysecret' as a fallback default for SECRET_KEY."""
with open(os.path.join(DEPLOY_DIR, "auth.py")) as f:
source = f.read()
# The old dangerous pattern: os.environ.get("SECRET_KEY", "mysecret")
self.assertNotIn('get("SECRET_KEY", "mysecret")', source,
"auth.py must not use 'mysecret' as env var default")
def test_weak_secret_validation_exists(self):
"""auth.py must reject weak and too-short SECRET_KEY values (behavioral)."""
import importlib
import sys
sys.path.insert(0, DEPLOY_DIR)
auth = importlib.import_module("auth")
# A known-weak secret must be rejected.
with mock.patch.dict(os.environ, {"SECRET_KEY": "mysecret"}):
with self.assertRaises(RuntimeError):
auth.resolve_secret_key(required=True)
# A too-short secret (< 32 chars) must be rejected.
with mock.patch.dict(os.environ, {"SECRET_KEY": "short"}):
with self.assertRaises(RuntimeError):
auth.resolve_secret_key(required=True)
# A strong secret must be accepted and returned unchanged.
strong = "a" * 32
with mock.patch.dict(os.environ, {"SECRET_KEY": strong}):
self.assertEqual(auth.resolve_secret_key(required=True), strong)
def test_mysecret_in_weak_list(self):
"""'mysecret' must be in the weak secrets blocklist."""
with open(os.path.join(DEPLOY_DIR, "auth.py")) as f:
source = f.read()
# Parse the source to find _WEAK_SECRETS set
self.assertIn("mysecret", source,
"'mysecret' must be listed in _WEAK_SECRETS blocklist")
def test_auto_generation_exists(self):
"""auth.py must auto-generate key when none is set."""
with open(os.path.join(DEPLOY_DIR, "auth.py")) as f:
source = f.read()
self.assertIn("token_hex", source,
"auth.py must use secrets.token_hex for auto-generation")
# ============================================================================
# B2-V3: eval() removed from /config/dump
# ============================================================================
class TestConfigDumpNoEval(unittest.TestCase):
"""Verify eval() is completely removed from the /config/dump path."""
def test_no_safe_eval_config(self):
"""_safe_eval_config function must be removed from server.py."""
with open(os.path.join(DEPLOY_DIR, "server.py")) as f:
source = f.read()
self.assertNotIn("def _safe_eval_config", source,
"_safe_eval_config must be deleted (replaced with JSON input)")
def test_config_from_json_exists(self):
"""_config_from_json function must exist."""
with open(os.path.join(DEPLOY_DIR, "server.py")) as f:
source = f.read()
self.assertIn("def _config_from_json", source,
"_config_from_json must replace _safe_eval_config")
def test_config_dump_has_auth(self):
"""config_dump endpoint must require authentication."""
with open(os.path.join(DEPLOY_DIR, "server.py")) as f:
source = f.read()
# Find the config_dump function and check it has token_dep
idx = source.index("config_dump")
# Look backwards for the decorator/function definition area
nearby = source[max(0, idx-200):idx+200]
self.assertIn("token_dep", nearby,
"/config/dump must require token_dep authentication")
def test_no_eval_in_config_path(self):
"""No eval() call should exist in the config dump code path."""
with open(os.path.join(DEPLOY_DIR, "server.py")) as f:
source = f.read()
# The old allowlist constants should be gone
self.assertNotIn("_SAFE_CONFIG_ALLOWED_NAMES", source,
"Old eval allowlist constants should be removed")
self.assertNotIn("_SAFE_CONFIG_ALLOWED_ATTRS", source,
"Old eval allowlist constants should be removed")
# ============================================================================
# B2-V1: /execute_js disabled by default
# ============================================================================
class TestExecuteJsDisabled(unittest.TestCase):
"""Verify /execute_js is disabled by default with proper guards."""
def test_execute_js_flag_exists(self):
"""EXECUTE_JS_ENABLED flag must exist in server.py."""
with open(os.path.join(DEPLOY_DIR, "server.py")) as f:
source = f.read()
self.assertIn("EXECUTE_JS_ENABLED", source)
def test_execute_js_disabled_by_default(self):
"""EXECUTE_JS_ENABLED must default to false."""
with open(os.path.join(DEPLOY_DIR, "server.py")) as f:
source = f.read()
# Find the line that sets EXECUTE_JS_ENABLED
for line in source.splitlines():
if "EXECUTE_JS_ENABLED" in line and "os.environ" in line:
self.assertIn('"false"', line,
"EXECUTE_JS_ENABLED must default to 'false'")
return
self.fail("Could not find EXECUTE_JS_ENABLED env var line")
def test_execute_js_checks_flag(self):
"""execute_js endpoint must check EXECUTE_JS_ENABLED."""
with open(os.path.join(DEPLOY_DIR, "server.py")) as f:
source = f.read()
idx = source.index("async def execute_js")
func_body = source[idx:idx+3000]
self.assertIn("EXECUTE_JS_ENABLED", func_body,
"execute_js must check EXECUTE_JS_ENABLED flag")
def test_execute_js_has_ssrf_check(self):
"""execute_js must validate URL against SSRF blocklist."""
with open(os.path.join(DEPLOY_DIR, "server.py")) as f:
source = f.read()
idx = source.index("async def execute_js")
func_body = source[idx:idx+3000]
self.assertIn("validate_webhook_url", func_body,
"execute_js must validate URL against SSRF blocklist")
def test_disable_web_security_removed_from_defaults(self):
"""--disable-web-security must not be in default browser args."""
with open(os.path.join(DEPLOY_DIR, "utils.py")) as f:
source = f.read()
# Find the DEFAULT_CONFIG extra_args
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and node.value == "--disable-web-security":
self.fail("--disable-web-security must not be in DEFAULT_CONFIG extra_args")
def test_disable_web_security_removed_from_config_yml(self):
"""--disable-web-security must not be active in config.yml."""
with open(os.path.join(DEPLOY_DIR, "config.yml")) as f:
for line in f:
stripped = line.strip()
if stripped == '- "--disable-web-security"':
self.fail("--disable-web-security must not be an active entry in config.yml")
# ============================================================================
# B2-V4: exec-based hook system removed (replaced by declarative registry)
# ============================================================================
class TestHookExecPathRemoved(unittest.TestCase):
"""The exec()/compile() hook system is gone; hooks are now declarative.
No sandbox survives attacker Python in-process, so hook_manager.py was
deleted and replaced by hook_registry.py (a fixed set of server-authored
actions selected by name with schema-validated scalar params).
"""
def test_hook_manager_module_deleted(self):
self.assertFalse(
os.path.exists(os.path.join(DEPLOY_DIR, "hook_manager.py")),
"hook_manager.py (the exec-based hook system) must be deleted",
)
def test_no_exec_compile_eval_in_docker_layer(self):
"""The RCE primitives must be absent from the docker server code.
Uses AST so it flags only bare-builtin calls (exec/eval/compile), not
attribute calls like re.compile(...) or the words in comments/docstrings.
"""
import ast
import glob
offenders = []
for path in glob.glob(os.path.join(DEPLOY_DIR, "*.py")):
with open(path) as f:
tree = ast.parse(f.read(), filename=path)
for node in ast.walk(tree):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id in {"exec", "eval", "compile"}
):
offenders.append((os.path.basename(path), node.func.id, node.lineno))
self.assertEqual(offenders, [], f"bare exec/eval/compile call found: {offenders}")
def test_registry_rejects_unknown_action(self):
sys.path.insert(0, DEPLOY_DIR)
from hook_registry import build_declarative_hooks, HookValidationError
with self.assertRaises(HookValidationError):
build_declarative_hooks([{"action": "run_python", "params": {"code": "x"}}])
def test_registry_rejects_bad_params(self):
sys.path.insert(0, DEPLOY_DIR)
from hook_registry import build_declarative_hooks, HookValidationError
with self.assertRaises(HookValidationError):
build_declarative_hooks([{"action": "block_resources", "params": {"resource_types": ["script"]}}])
def test_registry_builds_valid_hooks(self):
sys.path.insert(0, DEPLOY_DIR)
from hook_registry import build_declarative_hooks
hooks = build_declarative_hooks([
{"action": "block_resources", "params": {"resource_types": ["image"]}},
{"action": "scroll_to_bottom", "params": {"max_steps": 5}},
])
# block_resources -> on_page_context_created, scroll -> before_retrieve_html
self.assertIn("on_page_context_created", hooks)
self.assertIn("before_retrieve_html", hooks)
for fn in hooks.values():
self.assertTrue(callable(fn))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,108 @@
"""
R4 artifact-store behavioral tests: the server owns the directory, names and
bytes. Writes are O_EXCL|O_NOFOLLOW 0600; retrieval requires a 32-hex id,
refuses symlinks/non-regular files, and enforces a TTL.
"""
import os
import time
import pytest
@pytest.fixture
def store(tmp_path, monkeypatch):
"""Point the artifact store at an isolated temp dir and reload it."""
monkeypatch.setenv("CRAWL4AI_ARTIFACT_DIR", str(tmp_path / "art"))
monkeypatch.setenv("CRAWL4AI_MAX_ARTIFACT_BYTES", "1024")
monkeypatch.setenv("CRAWL4AI_ARTIFACT_QUOTA_BYTES", "4096")
monkeypatch.setenv("CRAWL4AI_ARTIFACT_TTL_SECONDS", "3600")
import importlib
import artifacts
importlib.reload(artifacts)
artifacts.init_store()
yield artifacts
importlib.reload(artifacts) # restore defaults for other tests
pytestmark = pytest.mark.posture
class TestWrite:
def test_write_returns_hex_id_and_meta(self, store):
meta = store.write_artifact("png", b"\x89PNG data")
assert len(meta["artifact_id"]) == 32 and all(c in "0123456789abcdef" for c in meta["artifact_id"])
assert meta["mime"] == "image/png" and meta["size"] == len(b"\x89PNG data")
def test_dir_is_0700_and_file_0600(self, store):
meta = store.write_artifact("pdf", b"%PDF-1.4")
assert oct(os.stat(store.ARTIFACT_DIR).st_mode)[-3:] == "700"
path, _ = store.resolve_artifact(meta["artifact_id"])
assert oct(os.stat(path).st_mode)[-3:] == "600"
def test_oversize_rejected(self, store):
with pytest.raises(store.ArtifactTooLarge):
store.write_artifact("png", b"x" * 2048) # cap is 1024
def test_quota_enforced(self, store):
for _ in range(4):
store.write_artifact("png", b"x" * 1000)
with pytest.raises(store.QuotaExceeded):
store.write_artifact("png", b"x" * 1000) # would exceed 4096
class TestResolve:
def test_roundtrip(self, store):
meta = store.write_artifact("png", b"hello")
path, mime = store.resolve_artifact(meta["artifact_id"])
assert mime == "image/png"
with open(path, "rb") as f:
assert f.read() == b"hello"
@pytest.mark.parametrize("bad", ["../etc/passwd", "..", "g" * 32, "abc", "/etc/passwd", "a" * 31])
def test_non_hex_or_traversal_404(self, store, bad):
with pytest.raises(store.ArtifactNotFound):
store.resolve_artifact(bad)
def test_symlink_not_followed(self, store):
# Plant a symlink named like a valid artifact pointing at a secret.
secret = os.path.join(store.ARTIFACT_DIR, "secret.txt")
with open(secret, "wb") as f:
f.write(b"TOPSECRET")
fake_id = "a" * 32
link = os.path.join(store.ARTIFACT_DIR, fake_id + ".png")
os.symlink(secret, link)
with pytest.raises(store.ArtifactNotFound):
store.resolve_artifact(fake_id) # lstat sees a symlink -> refuse
def test_ttl_expired_404_and_reaped(self, store):
meta = store.write_artifact("png", b"old")
path, _ = store.resolve_artifact(meta["artifact_id"])
old = time.time() - 7200 # 2h ago, TTL is 1h
os.utime(path, (old, old))
with pytest.raises(store.ArtifactNotFound):
store.resolve_artifact(meta["artifact_id"])
assert not os.path.exists(path) # resolve reaped it
class TestWriteIsExclusiveAndNoFollow:
def test_write_uses_oexcl_nofollow(self, store):
import inspect
src = inspect.getsource(store.write_artifact)
assert "O_EXCL" in src and "O_NOFOLLOW" in src
class TestJanitor:
def test_janitor_removes_symlinks_and_expired(self, store):
meta = store.write_artifact("png", b"keep")
# expired regular file
old_meta = store.write_artifact("pdf", b"old")
op, _ = store.resolve_artifact(old_meta["artifact_id"])
past = time.time() - 7200
os.utime(op, (past, past))
# a planted symlink
os.symlink("/etc/passwd", os.path.join(store.ARTIFACT_DIR, "deadbeef" * 4 + ".png"))
reaped = store.janitor()
assert reaped >= 2
# the fresh one survives
store.resolve_artifact(meta["artifact_id"])
+116
View File
@@ -0,0 +1,116 @@
"""
Behavioral authorization tests for R1 (beyond the blanket default-deny gate):
* admin scope - monitor destructive actions require an admin principal;
a valid data-scope token is allowed past the gate but
rejected (403) by require_admin.
* MCP no-laundering - the MCP tool proxy authenticates its internal loopback
call (it no longer relies on the endpoints being open).
* job ownership - a task records its owner; a different requester gets 404
(not 403), and an admin can read any task.
These exercise the running app, not its source text.
"""
import pytest
pytestmark = pytest.mark.posture
from auth import create_access_token # noqa: E402
def _bearer(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
# ───────────────────────────── admin scope ─────────────────────────────
class TestAdminScope:
def test_data_scope_cannot_reset_stats(self, stock_client):
data_tok = create_access_token({"sub": "user@x.com"}, scope="data")
r = stock_client.post("/monitor/stats/reset", headers=_bearer(data_tok))
assert r.status_code == 403, f"data-scope reached admin action: {r.status_code}"
def test_data_scope_cannot_kill_browser(self, stock_client):
data_tok = create_access_token({"sub": "user@x.com"}, scope="data")
r = stock_client.post(
"/monitor/actions/kill_browser", json={"sig": "abc"}, headers=_bearer(data_tok)
)
assert r.status_code == 403
def test_admin_scope_passes_require_admin(self, stock_client):
admin_tok = create_access_token({"sub": "ops@x.com"}, scope="admin")
r = stock_client.post("/monitor/stats/reset", headers=_bearer(admin_tok))
# Past require_admin: not a 401/403. (500 acceptable here: no monitor
# singleton without a lifespan — the point is authz let it through.)
assert r.status_code not in (401, 403), f"admin blocked: {r.status_code}"
def test_unauthenticated_admin_action_is_401(self, stock_client):
r = stock_client.post("/monitor/stats/reset")
assert r.status_code == 401
# ────────────────────────── MCP no-laundering ──────────────────────────
class TestMcpNoLaundering:
def test_mcp_proxy_attaches_service_credential(self):
"""The internal loopback proxy must carry a valid, data-scope token."""
import mcp_bridge
from auth import decode_token
headers = mcp_bridge._service_auth_headers()
assert "Authorization" in headers
scheme, _, token = headers["Authorization"].partition(" ")
assert scheme == "Bearer" and token
claims = decode_token(token)
assert claims["scope"] == "data", "MCP service token must not be admin-scoped"
def test_mcp_base_url_is_loopback(self, server_module):
"""The MCP proxy must target loopback, never the 0.0.0.0 bind address."""
import inspect
src = inspect.getsource(server_module)
assert "http://127.0.0.1:" in src
assert 'base_url=f"http://{config' not in src
# ─────────────────────────── job ownership ─────────────────────────────
class _FakeRedis:
"""Minimal async Redis stub holding one task hash."""
def __init__(self, task):
# Real redis returns bytes keys/values; decode_redis_hash expects that.
self._task = {k.encode(): str(v).encode() for k, v in task.items()}
async def hgetall(self, key):
return dict(self._task)
async def delete(self, key):
self._task = {}
@pytest.mark.asyncio
class TestJobOwnership:
async def _status(self, task, **kw):
import api
redis = _FakeRedis(task)
return await api.handle_task_status(
redis, "crawl_abc", base_url="http://t/", keep=True, **kw
)
async def test_owner_can_read_own_task(self):
task = {"status": "completed", "created_at": "2026-01-01T00:00:00",
"url": "[]", "result": "{}", "owner": "alice@x.com"}
resp = await self._status(task, requester="alice@x.com", is_admin=False)
assert resp.status_code == 200
async def test_other_requester_gets_404_not_403(self):
from fastapi import HTTPException
task = {"status": "completed", "created_at": "2026-01-01T00:00:00",
"url": "[]", "result": "{}", "owner": "alice@x.com"}
with pytest.raises(HTTPException) as exc:
await self._status(task, requester="mallory@x.com", is_admin=False)
assert exc.value.status_code == 404 # not 403: don't reveal existence
async def test_admin_can_read_any_task(self):
task = {"status": "completed", "created_at": "2026-01-01T00:00:00",
"url": "[]", "result": "{}", "owner": "alice@x.com"}
resp = await self._status(task, requester="ops@x.com", is_admin=True)
assert resp.status_code == 200
@@ -0,0 +1,124 @@
"""
R7 container/deployment posture - static parse of the deployment artifacts.
These assert the hardening is declared in the Dockerfile / docker-compose.yml /
supervisord.conf. The RUNTIME acceptance gate (the hardened image builds,
Chromium starts without --no-sandbox under userns/seccomp, the read-only rootfs
+ tmpfs work, redis truly requires auth) needs an actual `docker build` + boot
and is tracked as build-gated; it is out of scope for this offline suite.
"""
import os
import re
import pytest
pytestmark = pytest.mark.posture
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
DOCKER_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
def _read(path):
with open(path) as f:
return f.read()
@pytest.fixture(scope="module")
def dockerfile():
return _read(os.path.join(REPO_ROOT, "Dockerfile"))
@pytest.fixture(scope="module")
def compose():
return _read(os.path.join(REPO_ROOT, "docker-compose.yml"))
@pytest.fixture(scope="module")
def supervisord():
return _read(os.path.join(DOCKER_DIR, "supervisord.conf"))
class TestDockerfile:
def test_no_redis_expose(self, dockerfile):
# No active EXPOSE 6379 line (commented references are fine).
for line in dockerfile.splitlines():
stripped = line.strip()
if stripped.startswith("#"):
continue
assert not re.match(r"EXPOSE\s+.*\b6379\b", stripped), \
"redis port 6379 must not be EXPOSEd"
def test_app_dir_root_owned_readonly(self, dockerfile):
assert "chown -R root:root ${APP_HOME}" in dockerfile
assert "chmod -R a-w ${APP_HOME}" in dockerfile
def test_artifact_dir_created_0700(self, dockerfile):
assert "/var/lib/crawl4ai/outputs" in dockerfile
assert "chmod 700 /var/lib/crawl4ai/outputs" in dockerfile
def test_runs_as_non_root(self, dockerfile):
assert re.search(r"^USER\s+appuser", dockerfile, re.MULTILINE)
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_gunicorn_bind_is_env_driven(self, supervisord):
# entrypoint.sh resolves GUNICORN_BIND (loopback unless a credential).
assert "ENV_GUNICORN_BIND" in supervisord
def test_gunicorn_request_limits(self, supervisord):
assert "--limit-request-line" in supervisord
class TestCompose:
def test_cap_drop_all(self, compose):
assert "cap_drop" in compose and "ALL" in compose
def test_no_new_privileges(self, compose):
assert "no-new-privileges:true" in compose
def test_read_only_rootfs(self, compose):
assert re.search(r"read_only:\s*true", compose)
def test_no_host_dev_shm_bind(self, compose):
assert "/dev/shm:/dev/shm" not in compose
assert "shm_size" in compose
def test_pids_limit(self, compose):
assert "pids_limit" in compose
def test_read_only_runtime_tmpfs_are_appuser_owned(self, compose):
assert "/var/lib/redis:uid=999,gid=999,mode=0700" in compose
assert "/var/lib/crawl4ai/outputs:uid=999,gid=999,mode=0700" in compose
assert "/home/appuser/.crawl4ai:uid=999,gid=999,mode=0700" in compose
assert "/home/appuser/.gunicorn:uid=999,gid=999,mode=0700" in compose
def test_playwright_cache_is_not_shadowed(self, compose):
assert "/home/appuser/.cache\n" not in compose
assert "/home/appuser/.cache/url_seeder:uid=999,gid=999,mode=0700" in compose
class TestEntrypoint:
def test_entrypoint_exists_and_resolves_bind(self):
src = _read(os.path.join(DOCKER_DIR, "entrypoint.sh"))
assert "GUNICORN_BIND" in src and "REDIS_PASSWORD" in src
assert "127.0.0.1" in src # loopback default when no credential
class TestSandboxOptOut:
def test_default_keeps_no_sandbox(self, server_module, monkeypatch):
monkeypatch.setattr(server_module, "CHROMIUM_SANDBOX", False)
assert "--no-sandbox" in server_module._browser_extra_args()
def test_opt_in_drops_no_sandbox(self, server_module, monkeypatch):
# CRAWL4AI_CHROMIUM_SANDBOX=true -> run the renderer sandboxed.
monkeypatch.setattr(server_module, "CHROMIUM_SANDBOX", True)
assert "--no-sandbox" not in server_module._browser_extra_args()
# other flags are preserved
assert "--disable-dev-shm-usage" in server_module._browser_extra_args()
@@ -0,0 +1,254 @@
"""
HEADLINE SECURITY POSTURE GATE — test_security_default_posture
This is the acceptance gate for the Docker-server security hardening
(root causes R1-R7). It boots the server *with the shipped config.yml* and
asserts the secure-by-default end state:
- every mutating + read endpoint, static mount, and MCP transport requires
auth out of the box (R1); only the health check and UI shell pages
(dashboard/playground) are public,
- the token endpoint will not freely mint credentials when no api_token /
secret is configured (R1),
- strong security headers + a strict CSP are present on every response even
when `security.enabled` is false (R6),
- the effective browser launch flags carry no `--no-sandbox` and no
TLS-verification-disabling flags (R3/R7),
- Redis requires a password and security is on by default (R7),
- dangerous request->code features are off by default (R2).
IT IS EXPECTED TO BE RED TODAY. Each failing assertion names one gap the
hardening must close. As R1-R7 land, methods flip green; when the whole class
is green, the default Docker deploy is safe and CI can gate on it.
Run: pytest deploy/docker/tests/test_security_default_posture.py -v
"""
import pytest
from redis_config import build_rate_limit_storage_uri, build_redis_url
pytestmark = pytest.mark.posture
HEALTH = "/health"
# (method, path, json-body-or-None) for every endpoint that must NOT be
# reachable without authentication. Bodies are intentionally minimal/invalid so
# that in the *current* (open) state the request fails fast at validation
# rather than launching a real crawl — the assertion (== 401) still fails,
# which is the RED signal we want, with no browser/network side effects.
PROTECTED_ENDPOINTS = [
# mutating / action endpoints
("post", "/crawl", {}),
("post", "/crawl/stream", {}),
("post", "/md", {}),
("post", "/html", {}),
("post", "/screenshot", {}),
("post", "/pdf", {}),
("post", "/execute_js", {}),
("post", "/config/dump", {}),
("post", "/crawl/job", {}),
("post", "/llm/job", {}),
# monitor mutating actions (must additionally require *admin* scope; here we
# only assert they are not anonymously reachable). Pools are empty without a
# lifespan, so probing these performs no destructive action.
("post", "/monitor/actions/cleanup", {}),
("post", "/monitor/actions/kill_browser", {}),
("post", "/monitor/actions/restart_browser", {}),
("post", "/monitor/stats/reset", {}),
# read / info endpoints that currently have NO auth dependency at all
("get", "/schema", None),
("get", "/hooks/info", None),
("get", "/ask", None),
("get", "/metrics", None),
("get", "/llm/example.com", None), # no `q` -> fast 400 today, must be 401
("get", "/monitor/health", None),
("get", "/monitor/requests", None),
("get", "/monitor/browsers", None),
("get", "/monitor/endpoints/stats", None),
("get", "/monitor/timeline", None),
# MCP transport (must be gated; today it is open AND launders credentials)
("get", "/mcp/schema", None),
]
# UI shell pages that are intentionally public — they serve only static
# HTML/CSS/JS and contain no data. All data routes behind them remain gated.
PUBLIC_UI_PATHS = ["/dashboard/", "/playground/"]
def _call(client, method, path, body):
fn = getattr(client, method)
if body is not None:
return fn(path, json=body)
return fn(path)
class TestDefaultDeployIsSafe:
"""The shipped Docker image must be secure with an empty/default config."""
# ───────────────────────── R1: authentication ─────────────────────────
def test_health_check_is_public(self, stock_client):
"""The health endpoint is the one intentionally public route."""
r = stock_client.get(HEALTH)
assert r.status_code == 200
@pytest.mark.parametrize("path", PUBLIC_UI_PATHS, ids=PUBLIC_UI_PATHS)
def test_ui_shell_is_public(self, stock_client, path):
"""UI shells serve static HTML only — no data — so they load without auth."""
r = stock_client.get(path)
assert r.status_code == 200, (
f"GET {path} returned {r.status_code}; expected 200. "
f"UI shell pages must be publicly accessible."
)
@pytest.mark.parametrize(
"method,path,body",
PROTECTED_ENDPOINTS,
ids=[f"{m.upper()} {p}" for m, p, _ in PROTECTED_ENDPOINTS],
)
def test_endpoint_requires_auth_by_default(self, stock_client, method, path, body):
"""Without a credential, every non-health route must refuse (401)."""
r = _call(stock_client, method, path, body)
assert r.status_code == 401, (
f"{method.upper()} {path} returned {r.status_code} unauthenticated; "
f"expected 401. Endpoint is reachable without a credential."
)
def test_token_endpoint_does_not_freely_mint(self, stock_client, server_module, monkeypatch):
"""With no api_token configured, /token must not hand a JWT to anyone.
Force the email-domain MX check to pass so the test exercises the real
vulnerability (anonymous token minting) rather than passing for the
wrong reason when offline.
"""
monkeypatch.setattr(server_module, "verify_email_domain", lambda email: True)
r = stock_client.post("/token", json={"email": "attacker@example.com"})
if r.status_code == 200:
assert "access_token" not in r.json(), (
"/token minted a credential for an anonymous caller with no "
"api_token configured — anyone can self-issue a valid JWT."
)
# ─────────────────────── R6: headers / CSP / framing ──────────────────
def test_security_headers_present_by_default(self, stock_client):
"""Baseline security headers must be emitted even when security.enabled is false."""
r = stock_client.get(HEALTH)
h = {k.lower(): v for k, v in r.headers.items()}
assert h.get("x-content-type-options") == "nosniff"
assert h.get("x-frame-options", "").upper() == "DENY"
assert "content-security-policy" in h, "no CSP header on a default response"
def test_csp_is_strict(self, stock_client):
"""CSP must lock script execution to self and forbid inline/unsafe + framing."""
r = stock_client.get(HEALTH)
csp = r.headers.get("content-security-policy", "")
assert "script-src 'self'" in csp, f"CSP missing strict script-src: {csp!r}"
assert "frame-ancestors 'none'" in csp, f"CSP allows framing: {csp!r}"
assert "unsafe-inline" not in csp, f"CSP permits unsafe-inline: {csp!r}"
# ─────────────────── R3 / R7: browser & TLS launch flags ───────────────
@pytest.mark.xfail(
reason="build-gated: --no-sandbox stays until the container runs with an "
"unprivileged userns or a verified seccomp profile and a docker build "
"confirms Chromium still starts. Tracked, not yet done.",
strict=False,
)
def test_no_no_sandbox_flag(self, effective_browser_args):
"""Chromium must not run with --no-sandbox by default (renderer escape)."""
assert "--no-sandbox" not in effective_browser_args, (
"default config launches Chromium with --no-sandbox"
)
def test_tls_verification_not_disabled(self, effective_browser_args):
"""TLS verification must not be disabled by default."""
assert "--ignore-certificate-errors" not in effective_browser_args
assert "--allow-insecure-localhost" not in effective_browser_args
# ───────────────────────── R7: deploy posture ─────────────────────────
def test_security_enabled_by_default(self, effective_config):
"""The security middleware block must be on out of the box."""
assert effective_config["security"]["enabled"] is True
def test_redis_is_not_network_exposed(self, effective_redis_url, effective_config):
"""Redis must not be open on the network: loopback host or a password.
The container runs redis loopback-only with --requirepass (supervisord)
and no EXPOSE (asserted in the container-posture suite); a password also
satisfies this for an external redis.
"""
rc = effective_config.get("redis", {})
host = str(rc.get("host", "localhost")).lower()
pw = rc.get("password", "") or ""
loopback = host in ("localhost", "127.0.0.1", "::1")
assert loopback or pw, (
"redis is neither loopback-bound nor password-protected"
)
def test_redis_url_includes_acl_username_and_encoded_password(self, monkeypatch):
"""Redis URLs must authenticate during HELLO when Redis is password-protected."""
monkeypatch.setenv("REDIS_PASSWORD", "p@ss/w:rd")
cfg = {
"redis": {
"host": "localhost",
"port": 6379,
"db": 0,
}
}
url = build_redis_url(cfg)
assert url.startswith("redis://default:")
assert "p%40ss%2Fw%3Ard" in url
assert url.endswith("@localhost:6379/0")
def test_rate_limit_redis_storage_reuses_redis_password(self, monkeypatch):
"""SlowAPI must not connect to protected Redis without credentials."""
monkeypatch.setenv("REDIS_PASSWORD", "abc123")
cfg = {
"redis": {"host": "localhost", "port": 6379, "db": 0},
"rate_limiting": {"storage_uri": "redis://localhost:6379/0"},
}
url = build_rate_limit_storage_uri(cfg)
assert url.startswith("redis://default:")
assert "abc123" in url
assert url.endswith("@localhost:6379/0")
def test_rate_limit_storage_preserves_explicit_auth(self, monkeypatch):
"""Do not rewrite operator-provided rate-limit Redis credentials."""
monkeypatch.setenv("REDIS_PASSWORD", "container-secret")
storage_uri = "redis://" + "alice" + ":" + "custom" + "@redis:6379/2"
cfg = {
"redis": {"host": "localhost", "port": 6379, "db": 0},
"rate_limiting": {"storage_uri": storage_uri},
}
assert build_rate_limit_storage_uri(cfg) == storage_uri
def test_rate_limit_storage_keeps_memory_backend(self, monkeypatch):
"""Non-Redis rate-limit backends must not be rewritten."""
monkeypatch.setenv("REDIS_PASSWORD", "abc123")
cfg = {
"redis": {"host": "localhost", "port": 6379, "db": 0},
"rate_limiting": {"storage_uri": "memory://"},
}
assert build_rate_limit_storage_uri(cfg) == "memory://"
def test_trusted_hosts_not_wildcard_when_exposed(self, effective_config):
"""A wildcard trusted_hosts on a non-loopback bind silently disables the host guard."""
host = effective_config["app"]["host"]
trusted = effective_config["security"]["trusted_hosts"]
exposed = host not in ("127.0.0.1", "localhost", "::1")
assert not (exposed and trusted == ["*"]), (
f"app binds {host} but trusted_hosts is {trusted} (host guard disabled)"
)
# ───────────────────── R2: dangerous features off by default ───────────
def test_hooks_disabled_by_default(self, server_module):
"""User-supplied hook code (exec path) must be off unless explicitly enabled."""
assert server_module.HOOKS_ENABLED is False
def test_execute_js_disabled_by_default(self, server_module):
"""Arbitrary JS execution endpoint must be off unless explicitly enabled."""
assert server_module.EXECUTE_JS_ENABLED is False
@@ -0,0 +1,100 @@
"""Path-traversal regression tests for crawler file downloads.
Y4tacker reported an arbitrary-file-write -> RCE: a download filename taken
from an attacker-controlled Content-Disposition header (HTTP strategy) or the
browser's suggested filename (Playwright strategy) was joined to the downloads
dir with no confinement, so an absolute path or ``../`` sequence escaped it.
Both call sites now route through ``_safe_download_filepath``, which reduces
the name to a bare basename and re-checks containment. These tests exercise
that helper directly (offline, no network/browser).
"""
import os
import tempfile
import pytest
from crawl4ai.async_crawler_strategy import _safe_download_filepath, _nofollow_opener
pytestmark = pytest.mark.posture
@pytest.fixture
def downloads_dir():
with tempfile.TemporaryDirectory() as d:
yield d
class TestDownloadPathConfinement:
def test_plain_filename_kept(self, downloads_dir):
p = _safe_download_filepath(downloads_dir, "report.pdf")
assert p == os.path.join(os.path.realpath(downloads_dir), "report.pdf")
@pytest.mark.parametrize(
"evil",
[
"/etc/cron.d/evil",
"/tmp/pwned_absolute.sh",
"../../../.bashrc",
"../../.ssh/authorized_keys",
"../../../../etc/passwd",
"..",
".",
"",
"foo/../../bar",
],
)
def test_escape_attempts_confined(self, downloads_dir, evil):
"""No input may produce a path outside the downloads root."""
root = os.path.realpath(downloads_dir)
p = _safe_download_filepath(downloads_dir, evil)
assert os.path.commonpath([root, p]) == root
# The resolved file sits directly inside the root (basename only).
assert os.path.dirname(p) == root
def test_absolute_path_does_not_win(self, downloads_dir):
p = _safe_download_filepath(downloads_dir, "/etc/cron.d/evil")
assert not p.startswith("/etc/")
assert p.startswith(os.path.realpath(downloads_dir))
def test_symlink_escape_rejected(self, downloads_dir):
"""If the basename is an existing symlink in the root pointing out,
realpath resolves outside the root and the write must be rejected."""
root = os.path.realpath(downloads_dir)
outside_dir = tempfile.mkdtemp()
outside_file = os.path.join(outside_dir, "target")
link = os.path.join(root, "evil")
os.symlink(outside_file, link)
# filename "evil" -> root/evil is a symlink to outside -> rejected.
with pytest.raises(ValueError):
_safe_download_filepath(downloads_dir, "evil")
class TestNoFollowOpener:
"""The write must refuse to follow a symlink swapped in after path
confinement (the TOCTOU window between realpath-check and open)."""
def test_open_refuses_symlink_target(self, downloads_dir):
import os
outside = tempfile.mkdtemp()
outside_file = os.path.join(outside, "secret")
with open(outside_file, "w") as f:
f.write("original")
# Attacker swaps the confined target for a symlink pointing outside.
target = os.path.join(downloads_dir, "report.pdf")
os.symlink(outside_file, target)
with pytest.raises(OSError): # ELOOP from O_NOFOLLOW
with open(target, "wb", opener=_nofollow_opener) as f:
f.write(b"pwned")
# The outside file was not overwritten through the symlink.
with open(outside_file) as f:
assert f.read() == "original"
def test_open_writes_normal_file(self, downloads_dir):
import os
target = os.path.join(downloads_dir, "ok.bin")
with open(target, "wb", opener=_nofollow_opener) as f:
f.write(b"data")
with open(target, "rb") as f:
assert f.read() == b"data"
@@ -0,0 +1,132 @@
"""
R3 browser egress-proxy tests (real loopback sockets, fully offline).
The pinning proxy is what actually stops DNS rebinding on the browser path:
Chromium is pointed at it, so it asks the proxy to CONNECT host:port; the proxy
resolves-and-pins (egress_broker.resolve_and_pin) and dials only the pinned,
global IP. We drive it with a raw asyncio client + a fake upstream, and stub
resolve_and_pin so a "public" host pins to the loopback upstream while an
"internal" host is refused. (The not-is_global rule itself is covered in
test_security_ssrf_egress.py.)
"""
import asyncio
import pytest
import egress_proxy
from egress_broker import EgressBlocked, PinnedTarget
from egress_proxy import PinningProxy
pytestmark = pytest.mark.posture
async def _fake_upstream():
async def handle(reader, writer):
await reader.read(65536)
writer.write(b"UPSTREAM-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 TestPinningProxy:
async def test_connect_to_global_host_tunnels(self, monkeypatch):
up, up_port = await _fake_upstream()
# Pin "good.example" to the loopback upstream (stand-in for a global IP).
def fake_pin(url):
return PinnedTarget("https", "good.example", up_port, "127.0.0.1")
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(f"CONNECT good.example:{up_port} HTTP/1.1\r\n\r\n".encode())
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)
assert b"UPSTREAM-OK" in body
w.close()
finally:
await proxy.stop()
up.close()
async def test_connect_to_internal_host_blocked(self, monkeypatch):
def fake_pin(url):
raise EgressBlocked()
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 evil.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()
async def test_proxy_dials_pinned_ip_not_requested_host(self, monkeypatch):
# resolve_and_pin returns a pinned ip distinct from the CONNECT host;
# assert the proxy dials the pinned ip.
dialed = {}
up, up_port = await _fake_upstream()
def fake_pin(url):
return PinnedTarget("https", "rebind.example", up_port, "127.0.0.1")
monkeypatch.setattr(egress_proxy, "resolve_and_pin", fake_pin)
real_open = asyncio.open_connection
async def spy_open(host, port, *a, **k):
dialed["host"], dialed["port"] = host, port
return await real_open(host, port, *a, **k)
# patch only the name the proxy module uses
monkeypatch.setattr(egress_proxy.asyncio, "open_connection", spy_open)
proxy = PinningProxy()
await proxy.start()
try:
r, w = await real_open(proxy.bound_host, proxy.bound_port)
w.write(f"CONNECT rebind.example:{up_port} HTTP/1.1\r\n\r\n".encode())
await w.drain()
await asyncio.wait_for(r.readline(), timeout=5)
assert dialed.get("host") == "127.0.0.1" # the pinned ip
w.close()
finally:
await proxy.stop()
up.close()
async def test_malformed_connect_400(self):
proxy = PinningProxy()
await proxy.start()
try:
r, w = await asyncio.open_connection(proxy.bound_host, proxy.bound_port)
w.write(b"CONNECT not-a-host-port HTTP/1.1\r\n\r\n")
await w.drain()
status = await asyncio.wait_for(r.readline(), timeout=5)
assert b"400" in status
w.close()
finally:
await proxy.stop()
class TestEnforceEgressWiring:
def test_enforce_egress_sets_proxy(self, monkeypatch):
import egress_broker
from crawl4ai import BrowserConfig
monkeypatch.setattr(egress_broker, "_EGRESS_PROXY_URL", "http://127.0.0.1:9999")
b = BrowserConfig()
egress_broker.enforce_egress(b)
assert b.proxy_config is not None
assert b.proxy_config.server == "http://127.0.0.1:9999"
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""
Unit tests for security fixes.
These tests verify the security fixes at the code level without needing a running server.
"""
import sys
import os
# Add parent directory to path to import modules
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import unittest
class TestURLValidation(unittest.TestCase):
"""Test URL scheme validation helper."""
def setUp(self):
"""Set up test fixtures."""
self.ALLOWED_URL_SCHEMES = ("http://", "https://")
self.ALLOWED_URL_SCHEMES_WITH_RAW = ("http://", "https://", "raw:", "raw://")
def validate_url_scheme(self, url: str, allow_raw: bool = False) -> bool:
"""Local version of validate_url_scheme for testing."""
allowed = self.ALLOWED_URL_SCHEMES_WITH_RAW if allow_raw else self.ALLOWED_URL_SCHEMES
return url.startswith(allowed)
# === SECURITY TESTS: These URLs must be BLOCKED ===
def test_file_url_blocked(self):
"""file:// URLs must be blocked (LFI vulnerability)."""
self.assertFalse(self.validate_url_scheme("file:///etc/passwd"))
self.assertFalse(self.validate_url_scheme("file:///etc/passwd", allow_raw=True))
def test_file_url_blocked_windows(self):
"""file:// URLs with Windows paths must be blocked."""
self.assertFalse(self.validate_url_scheme("file:///C:/Windows/System32/config/sam"))
def test_javascript_url_blocked(self):
"""javascript: URLs must be blocked (XSS)."""
self.assertFalse(self.validate_url_scheme("javascript:alert(1)"))
def test_data_url_blocked(self):
"""data: URLs must be blocked."""
self.assertFalse(self.validate_url_scheme("data:text/html,<script>alert(1)</script>"))
def test_ftp_url_blocked(self):
"""ftp: URLs must be blocked."""
self.assertFalse(self.validate_url_scheme("ftp://example.com/file"))
def test_empty_url_blocked(self):
"""Empty URLs must be blocked."""
self.assertFalse(self.validate_url_scheme(""))
def test_relative_url_blocked(self):
"""Relative URLs must be blocked."""
self.assertFalse(self.validate_url_scheme("/etc/passwd"))
self.assertFalse(self.validate_url_scheme("../../../etc/passwd"))
# === FUNCTIONALITY TESTS: These URLs must be ALLOWED ===
def test_http_url_allowed(self):
"""http:// URLs must be allowed."""
self.assertTrue(self.validate_url_scheme("http://example.com"))
self.assertTrue(self.validate_url_scheme("http://localhost:8080"))
def test_https_url_allowed(self):
"""https:// URLs must be allowed."""
self.assertTrue(self.validate_url_scheme("https://example.com"))
self.assertTrue(self.validate_url_scheme("https://example.com/path?query=1"))
def test_raw_url_allowed_when_enabled(self):
"""raw: URLs must be allowed when allow_raw=True."""
self.assertTrue(self.validate_url_scheme("raw:<html></html>", allow_raw=True))
self.assertTrue(self.validate_url_scheme("raw://<html></html>", allow_raw=True))
def test_raw_url_blocked_when_disabled(self):
"""raw: URLs must be blocked when allow_raw=False."""
self.assertFalse(self.validate_url_scheme("raw:<html></html>", allow_raw=False))
self.assertFalse(self.validate_url_scheme("raw://<html></html>", allow_raw=False))
class TestHookBuiltins(unittest.TestCase):
"""Hooks are declarative now - there is no builtins sandbox to harden.
The exec()-based hook system was removed (no in-process sandbox survives
attacker Python). Hooks are a fixed registry of server-authored actions, so
the relevant guarantee is simply that no action runs arbitrary code.
"""
def test_registry_actions_are_fixed_and_codeless(self):
from hook_registry import HOOK_REGISTRY
# No action accepts/executes raw code.
for action in HOOK_REGISTRY:
self.assertNotIn("code", action.lower())
self.assertNotIn("eval", action.lower())
self.assertNotIn("exec", action.lower())
# The documented safe actions are present.
self.assertTrue({"block_resources", "add_cookies", "set_headers",
"scroll_to_bottom", "wait_for_timeout"}.issubset(HOOK_REGISTRY))
class TestHooksEnabled(unittest.TestCase):
"""Test HOOKS_ENABLED environment variable logic."""
def test_hooks_disabled_by_default(self):
"""Hooks must be disabled by default."""
original = os.environ.pop("CRAWL4AI_HOOKS_ENABLED", None)
try:
hooks_enabled = os.environ.get("CRAWL4AI_HOOKS_ENABLED", "false").lower() == "true"
self.assertFalse(hooks_enabled)
finally:
if original is not None:
os.environ["CRAWL4AI_HOOKS_ENABLED"] = original
def test_hooks_enabled_when_true(self):
"""Hooks must be enabled when CRAWL4AI_HOOKS_ENABLED=true."""
original = os.environ.get("CRAWL4AI_HOOKS_ENABLED")
try:
os.environ["CRAWL4AI_HOOKS_ENABLED"] = "true"
hooks_enabled = os.environ.get("CRAWL4AI_HOOKS_ENABLED", "false").lower() == "true"
self.assertTrue(hooks_enabled)
finally:
if original is not None:
os.environ["CRAWL4AI_HOOKS_ENABLED"] = original
else:
os.environ.pop("CRAWL4AI_HOOKS_ENABLED", None)
def test_hooks_disabled_when_false(self):
"""Hooks must be disabled when CRAWL4AI_HOOKS_ENABLED=false."""
original = os.environ.get("CRAWL4AI_HOOKS_ENABLED")
try:
os.environ["CRAWL4AI_HOOKS_ENABLED"] = "false"
hooks_enabled = os.environ.get("CRAWL4AI_HOOKS_ENABLED", "false").lower() == "true"
self.assertFalse(hooks_enabled)
finally:
if original is not None:
os.environ["CRAWL4AI_HOOKS_ENABLED"] = original
else:
os.environ.pop("CRAWL4AI_HOOKS_ENABLED", None)
class TestComputedFieldExpressionDisabled(unittest.TestCase):
"""Test that computed field 'expression' key is completely disabled.
eval() on untrusted input is fundamentally unsafe - no sandbox survives.
The expression path is now disabled; only 'function' key works.
"""
def test_expression_returns_default(self):
"""expression key must return default value, not evaluate."""
import logging
# Import the actual class
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
from crawl4ai.extraction_strategy import JsonCssExtractionStrategy
schema = {
"baseSelector": "div",
"fields": [
{"name": "price", "selector": "span", "type": "text"},
{
"name": "computed",
"type": "computed",
"expression": "price * 2",
"default": "DISABLED",
},
],
}
strategy = JsonCssExtractionStrategy(schema)
result = strategy._compute_field({"price": 100}, schema["fields"][1])
self.assertEqual(result, "DISABLED")
def test_expression_does_not_execute_code(self):
"""expression must NEVER execute - even harmless code."""
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
from crawl4ai.extraction_strategy import JsonCssExtractionStrategy
schema = {
"baseSelector": "div",
"fields": [{"name": "x", "selector": "span", "type": "text"}],
}
strategy = JsonCssExtractionStrategy(schema)
# These should all return default, never execute
dangerous_expressions = [
"__import__('os').system('id')",
"open('/etc/passwd').read()",
"().__class__.__bases__[0].__subclasses__()",
]
for expr in dangerous_expressions:
field = {"name": "test", "type": "computed", "expression": expr, "default": None}
result = strategy._compute_field({}, field)
self.assertIsNone(result, f"Expression should not execute: {expr}")
def test_function_key_still_works(self):
"""function key with Python callable must still work."""
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
from crawl4ai.extraction_strategy import JsonCssExtractionStrategy
schema = {
"baseSelector": "div",
"fields": [{"name": "price", "selector": "span", "type": "text"}],
}
strategy = JsonCssExtractionStrategy(schema)
field = {
"name": "computed",
"type": "computed",
"function": lambda item: item["price"] * 2,
}
result = strategy._compute_field({"price": 100}, field)
self.assertEqual(result, 200)
class TestDeserializationAllowlist(unittest.TestCase):
"""Test that the deserialization allowlist blocks non-allowlisted types."""
def setUp(self):
self.allowed_types = {
"BrowserConfig", "CrawlerRunConfig", "HTTPCrawlerConfig",
"LLMConfig", "ProxyConfig", "GeolocationConfig",
"SeedingConfig", "VirtualScrollConfig", "LinkPreviewConfig",
"JsonCssExtractionStrategy", "JsonXPathExtractionStrategy",
"JsonLxmlExtractionStrategy", "LLMExtractionStrategy",
"CosineStrategy", "RegexExtractionStrategy",
"DefaultMarkdownGenerator",
"PruningContentFilter", "BM25ContentFilter", "LLMContentFilter",
"LXMLWebScrapingStrategy",
"RegexChunking",
"BFSDeepCrawlStrategy", "DFSDeepCrawlStrategy", "BestFirstCrawlingStrategy",
"FilterChain", "URLPatternFilter", "DomainFilter",
"ContentTypeFilter", "URLFilter", "SEOFilter", "ContentRelevanceFilter",
"KeywordRelevanceScorer", "URLScorer", "CompositeScorer",
"DomainAuthorityScorer", "FreshnessScorer", "PathDepthScorer",
"CacheMode", "MatchMode", "DisplayMode",
"MemoryAdaptiveDispatcher", "SemaphoreDispatcher",
"DefaultTableExtraction", "NoTableExtraction", "LLMTableExtraction",
"RoundRobinProxyStrategy",
}
def test_arbitrary_class_not_in_allowlist(self):
self.assertNotIn("AsyncWebCrawler", self.allowed_types)
def test_crawler_hub_not_in_allowlist(self):
self.assertNotIn("CrawlerHub", self.allowed_types)
def test_browser_profiler_not_in_allowlist(self):
self.assertNotIn("BrowserProfiler", self.allowed_types)
def test_docker_client_not_in_allowlist(self):
self.assertNotIn("Crawl4aiDockerClient", self.allowed_types)
def test_allowlist_has_core_config_types(self):
required = {"BrowserConfig", "CrawlerRunConfig", "LLMConfig", "ProxyConfig"}
self.assertTrue(required.issubset(self.allowed_types))
def test_allowlist_has_extraction_strategies(self):
required = {
"JsonCssExtractionStrategy", "LLMExtractionStrategy",
"RegexExtractionStrategy",
}
self.assertTrue(required.issubset(self.allowed_types))
def test_allowlist_has_enums(self):
required = {"CacheMode", "MatchMode", "DisplayMode"}
self.assertTrue(required.issubset(self.allowed_types))
if __name__ == '__main__':
print("=" * 60)
print("Crawl4AI Security Fixes - Unit Tests")
print("=" * 60)
print()
unittest.main(verbosity=2)
@@ -0,0 +1,124 @@
"""
R6 headers / CSP / CORS behavioral tests.
Security headers are emitted unconditionally (independent of security.enabled).
The strict CSP applies to the API / error surface; the still-inline dashboard /
playground keep the baseline headers but not the strict CSP until they are
externalized (CSP-compat refactor - tracked separately). CORS is deny-by-default.
"""
import pytest
pytestmark = pytest.mark.posture
class TestBaselineHeaders:
def test_present_on_api_response(self, stock_client):
r = stock_client.get("/health")
h = {k.lower(): v for k, v in r.headers.items()}
assert h.get("x-content-type-options") == "nosniff"
assert h.get("x-frame-options", "").upper() == "DENY"
assert h.get("referrer-policy") == "no-referrer"
assert h.get("cross-origin-opener-policy") == "same-origin"
def test_present_even_with_security_disabled(self, stock_client, server_module, monkeypatch):
# Headers must be emitted independent of the security.enabled flag (the
# old middleware only added them when enabled). Force it off and verify.
monkeypatch.setitem(server_module.config["security"], "enabled", False)
r = stock_client.get("/health")
h = {k.lower() for k in r.headers}
assert "content-security-policy" in h
assert "x-content-type-options" in h
def test_baseline_on_dashboard_mount(self, stock_client, server_module):
from auth import create_access_token
h = {"Authorization": f"Bearer {create_access_token({'sub': 'u@x.com'}, scope='admin')}"}
r = stock_client.get("/dashboard/", headers=h)
hh = {k.lower(): v for k, v in r.headers.items()}
assert hh.get("x-content-type-options") == "nosniff"
assert hh.get("x-frame-options", "").upper() == "DENY"
class TestStrictCsp:
def test_api_csp_is_strict(self, stock_client):
csp = stock_client.get("/health").headers.get("content-security-policy", "")
assert "script-src 'self'" in csp
assert "frame-ancestors 'none'" in csp
assert "default-src 'none'" in csp
assert "unsafe-inline" not in csp
def test_ui_mount_not_given_strict_csp(self, stock_client, server_module):
# The inline-script UI must not get the strict CSP yet (it would break).
from auth import create_access_token
h = {"Authorization": f"Bearer {create_access_token({'sub': 'u@x.com'}, scope='admin')}"}
csp = stock_client.get("/playground/", headers=h).headers.get("content-security-policy")
assert csp is None
class TestCorsDenyByDefault:
def test_no_cors_allow_origin_by_default(self, stock_client):
r = stock_client.get("/health", headers={"Origin": "https://evil.example"})
assert "access-control-allow-origin" not in {k.lower() for k in r.headers}
class TestErrorSanitization:
def test_5xx_is_generic_with_correlation_id(self, stock_client, server_module):
from auth import create_access_token
h = {"Authorization": f"Bearer {create_access_token({'sub': 'u@x.com'}, scope='admin')}"}
# /monitor/stats/reset has no monitor singleton without a lifespan -> the
# handler raises -> our central handler returns a sanitized 500.
r = stock_client.post("/monitor/stats/reset", headers=h)
assert r.status_code >= 500
body = r.json()
assert body.get("error") == "Internal server error"
assert "correlation_id" in body
# No internal detail (exception text / paths) leaked.
assert "Traceback" not in r.text and "/home/" not in r.text
def test_4xx_detail_preserved(self, stock_client):
# /token is public; with no api_token configured it raises a 4xx, which
# the central handler passes through with its developer-facing detail.
r = stock_client.post("/token", json={"email": "x@y.com"})
assert 400 <= r.status_code < 500
assert "detail" in r.json() and r.json()["detail"]
class TestWebhookHeaderSanitization:
def test_crlf_in_value_rejected(self):
from webhook import sanitize_webhook_headers
with pytest.raises(ValueError):
sanitize_webhook_headers({"X-Foo": "bar\r\nInjected: 1"})
def test_hop_by_hop_denied(self):
from webhook import sanitize_webhook_headers
for bad in ("Host", "Content-Length", "Transfer-Encoding", "Authorization"):
with pytest.raises(ValueError):
sanitize_webhook_headers({bad: "x"})
def test_bad_name_rejected(self):
from webhook import sanitize_webhook_headers
with pytest.raises(ValueError):
sanitize_webhook_headers({"X Foo": "bar"})
def test_good_headers_pass(self):
from webhook import sanitize_webhook_headers
assert sanitize_webhook_headers({"X-Trace-Id": "abc123"}) == {"X-Trace-Id": "abc123"}
def test_schema_validator_rejects_early(self):
from schemas import WebhookConfig
import pydantic
with pytest.raises(pydantic.ValidationError):
WebhookConfig(webhook_url="https://example.com/cb",
webhook_headers={"Host": "evil"})
class TestCRLFSafeLogging:
def test_crlf_stripped_from_log_message(self):
import logging
from utils import CRLFSafeFilter
rec = logging.LogRecord("t", logging.INFO, __file__, 1,
"url=http://x/\r\nINJECTED admin login", None, None)
CRLFSafeFilter().filter(rec)
msg = rec.getMessage()
assert "\r" not in msg and "\n" not in msg
assert "INJECTED" in msg # content kept, just de-fanged
@@ -0,0 +1,78 @@
"""
R3 LLM-broker tests: a request selects a provider BY NAME only; base_url and
api_token are always server-derived, so the configured provider key can never
be redirected to an attacker endpoint (the reported credential-exfil gadget).
"""
import pytest
from llm_broker import resolve_llm, allowed_provider_families, LLMProviderNotAllowed
pytestmark = pytest.mark.posture
CONF = {"llm": {"provider": "openai/gpt-4o-mini"}}
class TestProviderAllowlist:
def test_default_provider_allowed(self):
out = resolve_llm(CONF, None)
assert out["provider"] == "openai/gpt-4o-mini"
def test_same_family_allowed(self):
out = resolve_llm(CONF, "openai/gpt-4o")
assert out["provider"] == "openai/gpt-4o"
def test_other_family_rejected(self):
with pytest.raises(LLMProviderNotAllowed):
resolve_llm(CONF, "anthropic/claude-3-opus")
def test_explicit_allowlist_widens(self):
conf = {"llm": {"provider": "openai/gpt-4o-mini",
"allowed_providers": ["anthropic/claude-3-opus"]}}
assert resolve_llm(conf, "anthropic/claude-3-opus")["provider"].startswith("anthropic/")
class TestBaseUrlIsServerOnly:
def test_resolve_has_no_request_base_url_param(self):
import inspect
params = list(inspect.signature(resolve_llm).parameters)
# only (config, requested_provider) - no way to inject base_url/api_token
assert params == ["config", "requested_provider"]
def test_base_url_is_canonical_from_env(self, monkeypatch):
monkeypatch.setenv("OPENAI_BASE_URL", "https://api.openai.example")
out = resolve_llm(CONF, "openai/gpt-4o-mini")
assert out["base_url"] == "https://api.openai.example"
def test_no_base_url_when_unset(self, monkeypatch):
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("LLM_BASE_URL", raising=False)
out = resolve_llm(CONF, "openai/gpt-4o-mini")
assert out["base_url"] is None
class TestRequestSurfaceRemoved:
def test_markdown_request_has_no_base_url(self):
from schemas import MarkdownRequest
assert "base_url" not in MarkdownRequest.model_fields
def test_llm_job_payload_has_no_base_url(self):
from job import LlmJobPayload
assert "base_url" not in LlmJobPayload.model_fields
def test_llm_endpoint_has_no_base_url_query(self, server_module):
import inspect
assert "base_url" not in inspect.signature(server_module.llm_endpoint).parameters
def test_md_endpoint_rejects_disallowed_provider(self, stock_client, server_module):
from auth import create_access_token
h = {"Authorization": f"Bearer {create_access_token({'sub': 'u@x.com'})}"}
# LLM filter + a provider outside the allowed family -> 400 (not a 500,
# and no LLM call to an attacker endpoint).
r = stock_client.post(
"/md",
json={"url": "https://example.com", "f": "llm", "provider": "attacker/model",
"base_url": "http://169.254.169.254"},
headers=h,
)
assert r.status_code == 400, r.status_code
@@ -0,0 +1,175 @@
"""
R5 resource-governance tests: request body-size limit (413) and the
defense-in-depth deep-crawl clamp. (The client-driven unbounded-crawl critical
is closed upstream by R2 forbidding deep_crawl_strategy on untrusted bodies.)
"""
import pytest
pytestmark = pytest.mark.posture
class TestBodySizeLimit:
def test_oversize_content_length_413(self, stock_client, server_module):
from auth import create_access_token
h = {
"Authorization": f"Bearer {create_access_token({'sub': 'u@x.com'})}",
"Content-Length": str(50 * 1024 * 1024), # 50 MiB, over the 10 MiB cap
"Content-Type": "application/json",
}
# TestClient won't actually send 50MiB; the declared Content-Length is
# what the middleware checks.
r = stock_client.post("/crawl", data=b"{}", headers=h)
assert r.status_code == 413, r.status_code
def test_normal_body_not_blocked_by_size(self, stock_client, server_module):
from auth import create_access_token
h = {"Authorization": f"Bearer {create_access_token({'sub': 'u@x.com'})}"}
# Small body: must pass the size gate (then rejected for missing urls, etc).
r = stock_client.post("/crawl", json={}, headers=h)
assert r.status_code != 413
class TestDeepCrawlClamp:
def test_infinite_max_pages_clamped(self):
from governor import clamp_deep_crawl, DEFAULT_MAX_PAGES
from crawl4ai.deep_crawling import BFSDeepCrawlStrategy
from crawl4ai import CrawlerRunConfig
strat = BFSDeepCrawlStrategy(max_depth=99) # max_pages defaults to infinity
cfg = CrawlerRunConfig(deep_crawl_strategy=strat)
clamp_deep_crawl(cfg)
assert cfg.deep_crawl_strategy.max_pages <= DEFAULT_MAX_PAGES
assert cfg.deep_crawl_strategy.max_depth <= 5
def test_no_deep_crawl_is_noop(self):
from governor import clamp_deep_crawl
from crawl4ai import CrawlerRunConfig
cfg = CrawlerRunConfig()
clamp_deep_crawl(cfg) # must not raise
assert cfg.deep_crawl_strategy is None
class TestUntrustedDeepCrawlStillForbidden:
def test_request_cannot_set_deep_crawl(self):
# The clamp is defense in depth; the primary control is R2 rejecting it.
from crawl4ai.async_configs import CrawlerRunConfig, Provenance, UntrustedConfigError
with pytest.raises(UntrustedConfigError):
CrawlerRunConfig.load(
{"deep_crawl_strategy": {"type": "BFSDeepCrawlStrategy", "params": {"max_depth": 9}}},
provenance=Provenance.UNTRUSTED,
)
class TestConfigCaps:
def test_job_queue_caps_defaults(self):
from governor import job_queue_caps
caps = job_queue_caps({})
assert caps == {"maxsize": 1000, "workers": 4, "per_principal": 0}
def test_zero_means_unbounded(self):
from governor import job_queue_caps, wall_clock_seconds
caps = job_queue_caps({"limits": {"queue": {"maxsize": 0, "per_principal": 0}}})
assert caps["maxsize"] == 0 and caps["per_principal"] == 0
assert wall_clock_seconds({}) == 0 # no deadline by default
def test_wall_clock_configurable(self):
from governor import wall_clock_seconds
assert wall_clock_seconds({"limits": {"wall_clock_s": 30}}) == 30
@pytest.mark.asyncio
class TestWorkQueue:
async def test_queue_full_without_drain(self):
import asyncio
from work_queue import WorkQueue, QueueFull
q = WorkQueue(maxsize=1, workers=1)
q._q = asyncio.Queue(maxsize=1) # bypass workers so nothing drains
async def noop():
pass
q.submit(noop)
with pytest.raises(QueueFull):
q.submit(noop)
async def test_unbounded_never_full(self):
import asyncio
from work_queue import WorkQueue
q = WorkQueue(maxsize=0, workers=1)
q._q = asyncio.Queue(maxsize=0)
async def noop():
pass
for _ in range(200):
q.submit(noop) # maxsize 0 => unbounded, never raises
async def test_per_principal_quota(self):
import asyncio
from work_queue import WorkQueue, QuotaExceeded
q = WorkQueue(maxsize=0, workers=1, per_principal=1)
q._q = asyncio.Queue(maxsize=0)
async def noop():
pass
q.submit(noop, principal="alice")
with pytest.raises(QuotaExceeded):
q.submit(noop, principal="alice")
q.submit(noop, principal="bob") # different caller is fine
async def test_runs_job_and_releases_counter(self):
import asyncio
from work_queue import WorkQueue
q = WorkQueue(maxsize=10, workers=1, per_principal=5)
await q.start()
try:
done = asyncio.Event()
async def job():
done.set()
q.submit(job, principal="p")
await asyncio.wait_for(done.wait(), timeout=2)
await asyncio.sleep(0.05) # allow the finally/release to run
assert q._counts.get("p", 0) == 0
finally:
await q.stop()
class TestEnqueueMapping:
def test_enqueue_maps_queue_full_to_503(self):
import asyncio
import api
import work_queue
from fastapi import HTTPException
q = work_queue.WorkQueue(maxsize=1, workers=1)
q._q = asyncio.Queue(maxsize=1)
work_queue.set_job_queue(q)
async def noop():
pass
try:
api._enqueue_job(None, noop) # fills the queue
with pytest.raises(HTTPException) as exc:
api._enqueue_job(None, noop)
assert exc.value.status_code == 503
assert exc.value.headers.get("Retry-After")
finally:
work_queue.set_job_queue(None)
def test_enqueue_maps_quota_to_429(self):
import asyncio
import api
import work_queue
from fastapi import HTTPException
q = work_queue.WorkQueue(maxsize=0, workers=1, per_principal=1)
q._q = asyncio.Queue(maxsize=0)
work_queue.set_job_queue(q)
async def noop():
pass
try:
api._enqueue_job(None, noop, principal="a")
with pytest.raises(HTTPException) as exc:
api._enqueue_job(None, noop, principal="a")
assert exc.value.status_code == 429
finally:
work_queue.set_job_queue(None)
@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""
Adversarial tests for SSRF protection on crawl/md/llm URL entry points.
Reported by secsys_codex (2026-04-18).
Tests that validate_url_destination() blocks internal IPs on all crawl paths,
and that CRAWL4AI_ALLOW_INTERNAL_URLS=true bypasses the check.
"""
import os
import sys
import unittest
import ipaddress
import socket
from urllib.parse import urlparse
DEPLOY_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
# Local copy of validation logic for self-contained testing
_BLOCKED_NETWORKS = [
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("100.64.0.0/10"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.0.0.0/24"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("198.18.0.0/15"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
_BLOCKED_HOSTNAMES = {
"localhost", "metadata.google.internal", "metadata",
"kubernetes.default", "kubernetes.default.svc",
}
def _expand_ip_candidates(ip):
"""Mirror of utils.py: unwrap IPv4 from IPv4-mapped/compat IPv6."""
candidates = [ip]
if isinstance(ip, ipaddress.IPv6Address):
if ip.ipv4_mapped is not None:
candidates.append(ip.ipv4_mapped)
else:
as_int = int(ip)
if 0 < as_int < 2**32:
candidates.append(ipaddress.IPv4Address(as_int))
return candidates
def _validate_webhook_url(url):
parsed = urlparse(str(url))
hostname = parsed.hostname
if not hostname:
raise ValueError("URL must have a valid hostname")
if hostname.lower() in _BLOCKED_HOSTNAMES:
raise ValueError(f"Hostname '{hostname}' is blocked")
if hostname.lower().startswith("host.docker.internal"):
raise ValueError(f"Hostname '{hostname}' is blocked")
try:
resolved = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise ValueError(f"Cannot resolve hostname '{hostname}'")
for _, _, _, _, sockaddr in resolved:
ip = ipaddress.ip_address(sockaddr[0])
for candidate in _expand_ip_candidates(ip):
for network in _BLOCKED_NETWORKS:
if candidate in network:
raise ValueError(f"URL resolves to blocked address: {ip}")
def validate_url_destination(url, allow_internal=False):
"""Simulates the actual validate_url_destination from utils.py."""
if allow_internal:
return
if str(url).startswith(("raw:", "raw://")):
return
_validate_webhook_url(url)
# ============================================================================
# SSRF attacks on crawl URLs
# ============================================================================
class TestCrawlURLSSRF(unittest.TestCase):
"""Test SSRF protection on URLs that go to crawler.arun()."""
def test_localhost_blocked(self):
with self.assertRaises(ValueError):
validate_url_destination("http://127.0.0.1:8080/admin")
def test_localhost_name_blocked(self):
with self.assertRaises(ValueError):
validate_url_destination("http://localhost:8080/secret")
def test_10_network_blocked(self):
with self.assertRaises(ValueError):
validate_url_destination("http://10.0.0.1/internal")
def test_172_16_blocked(self):
with self.assertRaises(ValueError):
validate_url_destination("http://172.16.0.1/dashboard")
def test_192_168_blocked(self):
with self.assertRaises(ValueError):
validate_url_destination("http://192.168.1.1/router")
def test_aws_metadata(self):
with self.assertRaises(ValueError):
validate_url_destination("http://169.254.169.254/latest/meta-data/")
def test_gcp_metadata(self):
with self.assertRaises(ValueError):
validate_url_destination("http://metadata.google.internal/computeMetadata/v1/")
def test_docker_internal(self):
with self.assertRaises(ValueError):
validate_url_destination("http://host.docker.internal:3000/api")
def test_kubernetes(self):
with self.assertRaises(ValueError):
validate_url_destination("http://kubernetes.default/api/v1/secrets")
# -- Must allow: external URLs --
def test_external_url_allowed(self):
validate_url_destination("https://example.com")
validate_url_destination("https://www.google.com")
# -- raw: URLs bypass (no network fetch) --
def test_raw_url_bypasses(self):
validate_url_destination("raw:<html><body>hello</body></html>")
validate_url_destination("raw://<html>test</html>")
# -- ALLOW_INTERNAL_URLS opt-out --
def test_allow_internal_bypasses(self):
"""When opted in, internal URLs should pass."""
validate_url_destination("http://127.0.0.1:8080", allow_internal=True)
validate_url_destination("http://10.0.0.1/internal", allow_internal=True)
validate_url_destination("http://169.254.169.254/meta", allow_internal=True)
# ============================================================================
# IPv6-mapped IPv4 bypass (caught in internal security review)
# Prior code only checked blocked networks directly; ::ffff:127.0.0.1
# parses as ::ffff:7f00:1 which is not in 127.0.0.0/8, bypassing the guard.
# ============================================================================
class TestIPv6MappedBypass(unittest.TestCase):
"""Test the IPv4-mapped IPv6 SSRF bypass class."""
def test_mapped_loopback_blocked(self):
"""http://[::ffff:127.0.0.1]/ must be blocked (maps to 127.0.0.1)."""
with self.assertRaises(ValueError):
validate_url_destination("http://[::ffff:127.0.0.1]/admin")
def test_mapped_private_10_blocked(self):
"""::ffff:10.0.0.1 must be blocked (maps to RFC 1918)."""
with self.assertRaises(ValueError):
validate_url_destination("http://[::ffff:10.0.0.1]/")
def test_mapped_aws_metadata_blocked(self):
"""::ffff:169.254.169.254 must be blocked (maps to cloud metadata)."""
with self.assertRaises(ValueError):
validate_url_destination("http://[::ffff:169.254.169.254]/latest/meta-data/")
def test_mapped_192_168_blocked(self):
"""::ffff:192.168.1.1 must be blocked."""
with self.assertRaises(ValueError):
validate_url_destination("http://[::ffff:192.168.1.1]/")
def test_ipv4_compatible_loopback_blocked(self):
"""IPv4-compatible IPv6 (deprecated but still exists): ::127.0.0.1."""
with self.assertRaises(ValueError):
validate_url_destination("http://[::127.0.0.1]/")
def test_regular_ipv6_loopback_still_blocked(self):
"""::1 must remain blocked (IPv6 loopback)."""
with self.assertRaises(ValueError):
validate_url_destination("http://[::1]:8080/")
# ============================================================================
# Source-level verification
# ============================================================================
class TestSSRFSourceCoverage(unittest.TestCase):
"""Verify all URL entry points have SSRF validation."""
def test_server_validate_url_scheme_calls_destination(self):
"""validate_url_scheme must also call validate_url_destination."""
with open(os.path.join(DEPLOY_DIR, "server.py")) as f:
source = f.read()
# Find validate_url_scheme function body
idx = source.index("def validate_url_scheme")
func_end = source.index("\ndef ", idx + 1) if "\ndef " in source[idx+1:] else idx + 500
func_body = source[idx:func_end]
self.assertIn("validate_url_destination", func_body,
"validate_url_scheme must call validate_url_destination")
def test_api_py_has_destination_validation(self):
"""api.py must call validate_url_destination for all URL entry points."""
with open(os.path.join(DEPLOY_DIR, "api.py")) as f:
source = f.read()
self.assertIn("validate_url_destination", source,
"api.py must import and use validate_url_destination")
# Count occurrences -- should have at least 4 (one per entry point)
count = source.count("validate_url_destination")
self.assertGreaterEqual(count, 5, # 1 import + 4 call sites
f"api.py should call validate_url_destination at all URL entry points (found {count})")
def test_every_crawl_handler_validates_destination(self):
"""Per-handler check: each crawl handler body must validate seed URL
destinations (via the shared _normalize_and_validate_seeds helper). A
bare occurrence count missed the streaming handler (it had zero calls
while the count was still satisfied)."""
with open(os.path.join(DEPLOY_DIR, "api.py")) as f:
source = f.read()
handlers = [
"handle_crawl_request",
"handle_stream_crawl_request",
]
for handler in handlers:
idx = source.index(f"async def {handler}")
nxt = source.find("\nasync def ", idx + 1)
body = source[idx: nxt if nxt != -1 else len(source)]
self.assertIn("_normalize_and_validate_seeds(", body,
f"{handler} must validate its seed URLs via _normalize_and_validate_seeds")
def test_default_browser_config_pins_egress(self):
"""get_default_browser_config must apply enforce_egress so /html,
/screenshot, /pdf, /execute_js fetch through the pinning proxy
(DNS-rebinding / redirect-to-internal protection), not just validate
the seed and then let the browser re-resolve."""
with open(os.path.join(DEPLOY_DIR, "server.py")) as f:
source = f.read()
idx = source.index("def get_default_browser_config")
nxt = source.find("\ndef ", idx + 1)
body = source[idx: nxt if nxt != -1 else idx + 800]
self.assertIn("enforce_egress", body,
"get_default_browser_config must call enforce_egress on the returned config")
def test_llm_and_markdown_handlers_pin_egress(self):
"""The /md and /llm handlers and the LLM background worker must pin
egress on their fetch config (same seed-only SSRF root cause)."""
with open(os.path.join(DEPLOY_DIR, "api.py")) as f:
source = f.read()
for handler in ("handle_llm_qa", "handle_markdown_request", "process_llm_extraction"):
idx = source.index(f"def {handler}")
nxt = source.find("\nasync def ", idx + 1)
nxt2 = source.find("\ndef ", idx + 1)
ends = [e for e in (nxt, nxt2) if e != -1]
body = source[idx: min(ends) if ends else len(source)]
self.assertIn("enforce_egress", body,
f"{handler} must call enforce_egress on its fetch config")
def test_utils_has_allow_internal_flag(self):
"""utils.py must have CRAWL4AI_ALLOW_INTERNAL_URLS env var."""
with open(os.path.join(DEPLOY_DIR, "utils.py")) as f:
source = f.read()
self.assertIn("CRAWL4AI_ALLOW_INTERNAL_URLS", source)
self.assertIn("ALLOW_INTERNAL_URLS", source)
def test_validate_url_destination_skips_raw(self):
"""validate_url_destination must skip raw: URLs."""
with open(os.path.join(DEPLOY_DIR, "utils.py")) as f:
source = f.read()
idx = source.index("def validate_url_destination")
func_body = source[idx:idx+500]
self.assertIn("raw:", func_body,
"validate_url_destination must skip raw: URLs")
import pytest
class TestStreamCrawlSSRFBehavioral:
"""End-to-end: an internal seed URL must be 400-blocked on the streaming
path, not just asserted to exist in source. This is the behavioral guard
for the KOH stream-SSRF fix (and proves the deliberate 400 is not masked
to 500 by the handler's generic except)."""
def _auth(self):
from auth import create_access_token
return {"Authorization": f"Bearer {create_access_token({'sub': 'u@x.com'})}"}
INTERNAL = "http://169.254.169.254/latest/meta-data/"
def test_crawl_stream_blocks_internal(self, stock_client, server_module):
r = stock_client.post("/crawl/stream", json={"urls": [self.INTERNAL]}, headers=self._auth())
assert r.status_code == 400, f"expected 400 SSRF block, got {r.status_code}: {r.text[:200]}"
assert "SSRF" in r.text or "blocked" in r.text.lower()
def test_crawl_with_stream_true_blocks_internal(self, stock_client, server_module):
body = {"urls": [self.INTERNAL],
"crawler_config": {"type": "CrawlerRunConfig", "params": {"stream": True}}}
r = stock_client.post("/crawl", json=body, headers=self._auth())
assert r.status_code == 400, f"expected 400 SSRF block, got {r.status_code}: {r.text[:200]}"
def test_non_stream_crawl_still_blocks_internal(self, stock_client, server_module):
r = stock_client.post("/crawl", json={"urls": [self.INTERNAL]}, headers=self._auth())
assert r.status_code == 400, f"expected 400 SSRF block, got {r.status_code}: {r.text[:200]}"
if __name__ == "__main__":
print("=" * 70)
print("Crawl4AI SSRF Tests - Crawl/MD/LLM Endpoints (secsys_codex)")
print("=" * 70)
print()
unittest.main(verbosity=2)
@@ -0,0 +1,142 @@
"""
R3 egress-broker behavioral tests (fully offline via the conftest DNS fixtures).
One rule: reject any resolved IP where not ip.is_global, on every transition
form (v4-mapped / NAT64 / 6to4 / v4-compat). Resolve once and pin, so DNS
rebinding never reaches the second (internal) answer. Errors are opaque.
"""
import socket
import pytest
from egress_broker import (
EgressBlocked,
check_redirect,
is_forbidden_ip,
resolve_and_pin,
)
pytestmark = pytest.mark.posture
class TestForbiddenIp:
@pytest.mark.parametrize("ip", [
"127.0.0.1", "169.254.169.254", "10.0.0.5", "192.168.1.1",
"172.16.0.1", "100.64.0.1", "0.0.0.0",
"::1", "::", "fc00::1", "fe80::1",
"::ffff:127.0.0.1", # v4-mapped loopback
"::ffff:169.254.169.254", # v4-mapped metadata
"64:ff9b::a9fe:a9fe", # NAT64 -> 169.254.169.254
"2002:a9fe:a9fe::1", # 6to4 embedding 169.254.169.254
])
def test_internal_forms_forbidden(self, ip):
assert is_forbidden_ip(ip) is True
@pytest.mark.parametrize("ip", ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"])
def test_global_allowed(self, ip):
assert is_forbidden_ip(ip) is False
class TestResolveAndPin:
def test_public_host_pins_ip(self, offline_dns):
offline_dns.set("good.example", "93.184.216.34")
t = resolve_and_pin("https://good.example/path")
assert t.ip == "93.184.216.34" and t.host == "good.example" and t.port == 443
def test_metadata_blocked(self, offline_dns):
offline_dns.set("meta.example", "169.254.169.254")
with pytest.raises(EgressBlocked):
resolve_and_pin("http://meta.example/latest/meta-data/")
def test_nat64_metadata_blocked(self, offline_dns):
offline_dns.set("nat64.example", "64:ff9b::a9fe:a9fe")
with pytest.raises(EgressBlocked):
resolve_and_pin("http://nat64.example/")
def test_localhost_name_blocked(self, offline_dns):
with pytest.raises(EgressBlocked):
resolve_and_pin("http://localhost/")
def test_non_http_scheme_blocked(self):
with pytest.raises(EgressBlocked):
resolve_and_pin("file:///etc/passwd")
with pytest.raises(EgressBlocked):
resolve_and_pin("gopher://x/")
def test_error_is_opaque(self, offline_dns):
offline_dns.set("meta.example", "169.254.169.254")
try:
resolve_and_pin("http://meta.example/")
assert False
except EgressBlocked as e:
# must not leak the resolved IP or hostname
assert "169.254" not in e.reason
assert "meta.example" not in e.reason
assert e.reason == "URL blocked"
def test_multi_record_one_internal_rejects_host(self, monkeypatch):
def fake(host, port, *a, **k):
return [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port or 0)),
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", port or 0)),
]
monkeypatch.setattr(socket, "getaddrinfo", fake)
with pytest.raises(EgressBlocked):
resolve_and_pin("http://mixed.example/")
class TestDnsRebinding:
def test_pins_first_answer_never_reresolves_internal(self, rebinding_dns):
r = rebinding_dns("rebind.example", "93.184.216.34", "169.254.169.254")
t = resolve_and_pin("http://rebind.example/")
# Pinned to the public answer; the caller dials t.ip, not the host, so
# the internal second answer is never used.
assert t.ip == "93.184.216.34"
assert r.calls == 1 # resolved exactly once
class TestEnforceEgress:
def test_tls_verification_forced_on(self, monkeypatch):
import egress_broker
monkeypatch.setattr(egress_broker, "ALLOW_INSECURE_TLS", False)
from crawl4ai import BrowserConfig
b = BrowserConfig(ignore_https_errors=True)
egress_broker.enforce_egress(b)
assert b.ignore_https_errors is False
def test_proxy_nulled(self):
import egress_broker
from crawl4ai import BrowserConfig
b = BrowserConfig()
b.proxy_config = object()
egress_broker.enforce_egress(b)
assert b.proxy_config is None
def test_dangerous_args_stripped(self):
import egress_broker
from crawl4ai import BrowserConfig
b = BrowserConfig(extra_args=["--proxy-server=http://evil", "--ignore-certificate-errors", "--headless"])
egress_broker.enforce_egress(b)
assert b.extra_args == ["--headless"]
class TestRedirectRevalidation:
def test_redirect_to_internal_blocked(self, offline_dns):
offline_dns.set("evil-redirect.example", "169.254.169.254")
with pytest.raises(EgressBlocked):
check_redirect("http://evil-redirect.example/")
class TestWebhookValidatorUsesBroker:
def test_webhook_internal_blocked_opaque(self, offline_dns):
from utils import validate_webhook_url
offline_dns.set("hook.example", "10.0.0.9")
with pytest.raises(ValueError) as exc:
validate_webhook_url("http://hook.example/cb")
assert "10.0.0" not in str(exc.value) # no IP leak
def test_webhook_public_ok(self, offline_dns):
from utils import validate_webhook_url
offline_dns.set("hook.example", "93.184.216.34")
validate_webhook_url("http://hook.example/cb") # no raise
@@ -0,0 +1,236 @@
"""
R2 trust-boundary behavioral tests (library-level provenance machinery).
Untrusted request bodies may only construct a strict subset of types and may
only set scalar, non-power fields, which are then clamped. Trusted (SDK /
in-process) construction is unchanged.
Docker-layer wiring tests (handlers passing UNTRUSTED, /config/dump
validate-only, declarative hooks) live alongside as the docker pieces land.
"""
import pytest
from crawl4ai.async_configs import (
BrowserConfig,
CrawlerRunConfig,
Provenance,
UntrustedConfigError,
)
T = Provenance.TRUSTED
U = Provenance.UNTRUSTED
pytestmark = pytest.mark.posture
class TestTrustedUnchanged:
"""SDK/in-process callers (default TRUSTED) keep full power."""
def test_trusted_keeps_power_fields(self):
c = CrawlerRunConfig.load(
{"js_code": "x=1", "page_timeout": 999999, "word_count_threshold": 5},
provenance=T,
)
assert c.js_code == "x=1"
assert c.page_timeout == 999999
def test_trusted_is_the_default(self):
# No provenance argument => behaves exactly as before.
c = CrawlerRunConfig.load({"js_code": "y=2"})
assert c.js_code == "y=2"
def test_trusted_roundtrip(self):
c = CrawlerRunConfig(js_code="z=3", screenshot=True, page_timeout=120000)
c2 = CrawlerRunConfig.load(c.dump())
assert c2.js_code == "z=3" and c2.page_timeout == 120000
class TestUntrustedForbiddenFields:
@pytest.mark.parametrize(
"field,value",
[
("js_code", "alert(1)"),
("js_code_before_wait", "x"),
("deep_crawl_strategy", {"type": "BFSDeepCrawlStrategy", "params": {}}),
("proxy_config", {"server": "http://evil"}),
("base_url", "http://evil"),
("simulate_user", True),
("magic", True),
("process_in_browser", True),
("session_id", "shared"),
],
)
def test_crawler_power_field_rejected(self, field, value):
with pytest.raises(UntrustedConfigError):
CrawlerRunConfig.load({field: value}, provenance=U)
@pytest.mark.parametrize(
"field,value",
[
("extra_args", ["--proxy-server=http://evil"]),
("proxy", "http://evil"),
("user_data_dir", "/etc"),
("cdp_url", "ws://evil"),
("cookies", [{"name": "s", "value": "x"}]),
("headers", {"X": "y"}),
("init_scripts", ["evil()"]),
],
)
def test_browser_power_field_rejected(self, field, value):
with pytest.raises(UntrustedConfigError):
BrowserConfig.load({field: value}, provenance=U)
@pytest.mark.parametrize(
"payload",
[
["--no-zygote", "--utility-cmd-prefix=sh -c id>/tmp/pwn"],
["--renderer-cmd-prefix=/bin/sh"],
["--gpu-launcher=touch /tmp/x"],
["--browser-subprocess-path=/bin/sh"],
],
)
def test_extra_args_chromium_cmd_injection_rejected(self, payload):
"""Y4tacker RCE: Chromium launch-arg command injection via extra_args.
extra_args is a forbidden field for untrusted bodies, so ANY value
(including the --*-cmd-prefix / --no-zygote exec primitives) is rejected
outright rather than scrubbed by an always-incomplete denylist."""
with pytest.raises(UntrustedConfigError):
BrowserConfig.load({"extra_args": payload}, provenance=U)
class TestUntrustedTypeGate:
def test_llm_strategy_rejected(self):
body = {
"type": "CrawlerRunConfig",
"params": {
"extraction_strategy": {
"type": "LLMExtractionStrategy",
"params": {},
}
},
}
with pytest.raises(UntrustedConfigError):
CrawlerRunConfig.load(body, provenance=U)
def test_env_exfil_poc_rejected(self):
"""The verified os.getenv-via-'env:' credential-exfil PoC must 400."""
poc = {
"type": "CrawlerRunConfig",
"params": {
"extraction_strategy": {
"type": "LLMExtractionStrategy",
"params": {
"llm_config": {
"type": "LLMConfig",
"params": {"api_token": "env:SECRET_KEY"},
}
},
}
},
}
with pytest.raises(UntrustedConfigError):
CrawlerRunConfig.load(poc, provenance=U)
def test_allowed_non_llm_strategy_accepted(self):
body = {
"type": "CrawlerRunConfig",
"params": {
"extraction_strategy": {
"type": "JsonCssExtractionStrategy",
"params": {"schema": {"name": "x", "baseSelector": "div", "fields": []}},
}
},
}
c = CrawlerRunConfig.load(body, provenance=U)
assert c.extraction_strategy is not None
class TestUntrustedClampAndDrop:
def test_timeout_zero_becomes_cap(self):
c = CrawlerRunConfig.load({"page_timeout": 0}, provenance=U)
assert c.page_timeout == 60_000 # 0 ("no timeout") clamped, never unbounded
def test_timeout_clamped(self):
c = CrawlerRunConfig.load({"wait_for_timeout": 500000}, provenance=U)
assert c.wait_for_timeout == 60_000
def test_unknown_field_dropped_not_raised(self):
c = CrawlerRunConfig.load(
{"css_selector": ".x", "totally_unknown_field": 1}, provenance=U
)
assert c.css_selector == ".x"
def test_viewport_clamped(self):
b = BrowserConfig.load({"viewport_width": 99999, "headless": True}, provenance=U)
assert b.viewport_width == 4000
assert b.headless is True
def test_safe_scalar_kept(self):
c = CrawlerRunConfig.load(
{"word_count_threshold": 7, "screenshot": True, "wait_until": "load"},
provenance=U,
)
assert c.word_count_threshold == 7 and c.screenshot is True
class TestLlmConfigGuard:
def test_untrusted_env_token_rejected(self):
from crawl4ai.async_configs import LLMConfig
with pytest.raises(UntrustedConfigError):
LLMConfig(api_token="env:SECRET_KEY", provenance=U)
def test_untrusted_never_reads_env(self, monkeypatch):
from crawl4ai.async_configs import LLMConfig
monkeypatch.setenv("OPENAI_API_KEY", "leak-me")
cfg = LLMConfig(provider="openai/gpt-4o-mini", provenance=U)
assert cfg.api_token != "leak-me"
class TestDockerHandlersEnforceUntrusted:
"""The Docker endpoints map UntrustedConfigError -> HTTP 400 (not 500)."""
def _auth(self, server_module):
from auth import create_access_token
return {"Authorization": f"Bearer {create_access_token({'sub': 'u@x.com'})}"}
def test_crawl_rejects_js_code(self, stock_client, server_module):
r = stock_client.post(
"/crawl",
json={"urls": ["https://example.com"], "crawler_config": {"js_code": "fetch('http://169.254.169.254')"}},
headers=self._auth(server_module),
)
assert r.status_code == 400, r.status_code
def test_crawl_rejects_proxy_config(self, stock_client, server_module):
r = stock_client.post(
"/crawl",
json={"urls": ["https://example.com"],
"browser_config": {"proxy_config": {"server": "http://evil"}}},
headers=self._auth(server_module),
)
assert r.status_code == 400, r.status_code
def test_config_dump_rejects_power_field(self, stock_client, server_module):
r = stock_client.post(
"/config/dump",
json={"type": "BrowserConfig", "params": {"extra_args": ["--proxy-server=x"]}},
headers=self._auth(server_module),
)
assert r.status_code == 400, r.status_code
def test_config_dump_validates_safe_config(self, stock_client, server_module):
r = stock_client.post(
"/config/dump",
json={"type": "CrawlerRunConfig", "params": {"word_count_threshold": 5, "screenshot": True}},
headers=self._auth(server_module),
)
assert r.status_code == 200, r.status_code
def test_config_dump_drops_unknown_keeps_safe(self, stock_client, server_module):
r = stock_client.post(
"/config/dump",
json={"type": "CrawlerRunConfig", "params": {"css_selector": ".x", "bogus_field": 1}},
headers=self._auth(server_module),
)
assert r.status_code == 200, r.status_code
@@ -0,0 +1,82 @@
"""
R3 webhook connection-pinning tests (real loopback server, offline).
The webhook sender pins the connection to the validated IP (aiohttp resolver),
follows redirects manually, and re-validates every hop. We stub the egress
broker so a "good" host pins to the loopback test server and "internal" hosts /
redirects are refused.
"""
import asyncio
import pytest
import egress_broker
from egress_broker import EgressBlocked, PinnedTarget
from webhook import WebhookDeliveryService
pytestmark = pytest.mark.posture
async def _raw_server(response: bytes):
async def handle(reader, writer):
await reader.read(65536)
writer.write(response)
await writer.drain()
writer.close()
server = await asyncio.start_server(handle, "127.0.0.1", 0)
return server, server.sockets[0].getsockname()[1]
def _svc():
return WebhookDeliveryService({"webhooks": {"retry": {"max_attempts": 2,
"initial_delay_ms": 1, "timeout_ms": 5000}}})
@pytest.mark.asyncio
class TestWebhookPinning:
async def test_delivered_to_pinned_host(self, monkeypatch):
server, port = await _raw_server(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
monkeypatch.setattr(
egress_broker, "resolve_and_pin",
lambda url: PinnedTarget("http", "good.example", port, "127.0.0.1"),
)
try:
ok = await _svc().send_webhook(f"http://good.example:{port}/cb", {"x": 1})
assert ok is True
finally:
server.close()
async def test_internal_target_blocked_no_retry(self, monkeypatch):
def boom(url):
raise EgressBlocked()
monkeypatch.setattr(egress_broker, "resolve_and_pin", boom)
ok = await _svc().send_webhook("http://169.254.169.254/cb", {"x": 1})
assert ok is False
async def test_redirect_to_internal_blocked(self, monkeypatch):
server, port = await _raw_server(
b"HTTP/1.1 302 Found\r\nLocation: http://evil.internal/\r\nContent-Length: 0\r\n\r\n"
)
monkeypatch.setattr(
egress_broker, "resolve_and_pin",
lambda url: PinnedTarget("http", "good.example", port, "127.0.0.1"),
)
def check(loc):
raise EgressBlocked() # the redirect target is internal
monkeypatch.setattr(egress_broker, "check_redirect", check)
try:
ok = await _svc().send_webhook(f"http://good.example:{port}/cb", {"x": 1})
assert ok is False # redirect re-validation refused it
finally:
server.close()
async def test_uses_pinned_resolver(self):
# The resolver returns the pinned IP for any host (TLS still verifies the
# hostname); confirm the wiring.
from webhook import _PinnedResolver
r = _PinnedResolver("good.example", "203.0.113.7")
out = await r.resolve("good.example", 443)
assert out[0]["host"] == "203.0.113.7"
assert out[0]["hostname"] == "good.example"
+434
View File
@@ -0,0 +1,434 @@
import dns.resolver
import logging
import yaml
import os
from datetime import datetime
from enum import Enum
from pathlib import Path
from fastapi import Request
from typing import Dict, Optional
class TaskStatus(str, Enum):
PROCESSING = "processing"
FAILED = "failed"
COMPLETED = "completed"
class FilterType(str, Enum):
RAW = "raw"
FIT = "fit"
BM25 = "bm25"
LLM = "llm"
DEFAULT_CONFIG = {
"app": {
"title": "Crawl4AI API",
"version": "1.0.0",
"host": "0.0.0.0",
"port": 11235,
"reload": False,
"workers": 1,
"timeout_keep_alive": 300,
},
"llm": {
"provider": "openai/gpt-4o-mini",
},
"redis": {
"host": "localhost",
"port": 6379,
"db": 0,
"password": "",
"task_ttl_seconds": 3600,
"ssl": False,
},
"rate_limiting": {
"enabled": True,
"default_limit": "1000/minute",
"trusted_proxies": [],
"storage_uri": "memory://",
},
"security": {
"enabled": False,
"jwt_enabled": False,
"api_token": "",
"https_redirect": False,
"trusted_hosts": ["*"],
"headers": {
"x_content_type_options": "nosniff",
"x_frame_options": "DENY",
"content_security_policy": "default-src 'self'",
"strict_transport_security": "max-age=63072000; includeSubDomains",
},
},
"crawler": {
"base_config": {"simulate_user": True},
"memory_threshold_percent": 95.0,
"rate_limiter": {"enabled": True, "base_delay": [1.0, 2.0]},
"timeouts": {"stream_init": 30.0, "batch_process": 300.0},
"pool": {"max_pages": 40, "idle_ttl_sec": 300},
"browser": {
"kwargs": {"headless": True, "text_mode": True},
"extra_args": [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-software-rasterizer",
"--allow-insecure-localhost",
"--ignore-certificate-errors",
],
},
},
"logging": {
"level": "INFO",
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
},
"observability": {
"prometheus": {"enabled": True, "endpoint": "/metrics"},
"health_check": {"endpoint": "/health"},
},
"webhooks": {
"enabled": True,
"default_url": None,
"data_in_payload": False,
"retry": {
"max_attempts": 5,
"initial_delay_ms": 1000,
"max_delay_ms": 32000,
"timeout_ms": 30000,
},
"headers": {"User-Agent": "Crawl4AI-Webhook/1.0"},
},
}
def _deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge override into base. Override values take precedence."""
merged = base.copy()
for key, value in override.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
merged[key] = _deep_merge(merged[key], value)
else:
merged[key] = value
return merged
def load_config() -> Dict:
"""Load and return application configuration with environment variable overrides."""
config_path = Path(__file__).parent / "config.yml"
with open(config_path, "r") as config_file:
user_config = yaml.safe_load(config_file) or {}
# Deep-merge user config on top of defaults so missing keys get safe values
config = _deep_merge(DEFAULT_CONFIG, user_config)
for section in DEFAULT_CONFIG:
if section not in user_config:
logging.warning(
f"Config section '{section}' missing from config.yml, using defaults"
)
# Override LLM provider from environment if set
llm_provider = os.environ.get("LLM_PROVIDER")
if llm_provider:
config["llm"]["provider"] = llm_provider
logging.info(f"LLM provider overridden from environment: {llm_provider}")
# Also support direct API key from environment if the provider-specific key isn't set
llm_api_key = os.environ.get("LLM_API_KEY")
if llm_api_key and "api_key" not in config["llm"]:
config["llm"]["api_key"] = llm_api_key
logging.info("LLM API key loaded from LLM_API_KEY environment variable")
# Override Redis task TTL from environment if set
redis_task_ttl = os.environ.get("REDIS_TASK_TTL")
if redis_task_ttl:
try:
config["redis"]["task_ttl_seconds"] = int(redis_task_ttl)
logging.info(f"Redis task TTL overridden from REDIS_TASK_TTL: {redis_task_ttl}s")
except ValueError:
logging.warning(f"Invalid REDIS_TASK_TTL value: {redis_task_ttl}, using default")
return config
class CRLFSafeFilter(logging.Filter):
"""Strip CR/LF/control chars from log records (log-injection / forging).
A crawl URL or error reflected into a log line could otherwise inject
newlines and forge additional log entries.
"""
_BAD = {ord(c): None for c in "\r\n"} | {i: None for i in range(0, 32) if i not in (9,)}
def filter(self, record: logging.LogRecord) -> bool:
try:
msg = record.getMessage()
cleaned = msg.translate(self._BAD)
if cleaned != msg:
record.msg = cleaned
record.args = ()
except Exception:
pass
return True
def setup_logging(config: Dict) -> None:
"""Configure application logging with CRLF-safe records."""
logging.basicConfig(
level=config["logging"]["level"],
format=config["logging"]["format"]
)
crlf = CRLFSafeFilter()
for handler in logging.getLogger().handlers:
handler.addFilter(crlf)
def get_base_url(request: Request) -> str:
"""Get base URL including scheme and host."""
return f"{request.url.scheme}://{request.url.netloc}"
def is_task_id(value: str) -> bool:
"""Check if the value matches task ID pattern."""
return value.startswith("llm_") and "_" in value
def datetime_handler(obj: any) -> Optional[str]:
"""Handle datetime serialization for JSON."""
if hasattr(obj, 'isoformat'):
return obj.isoformat()
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
def should_cleanup_task(created_at: str, ttl_seconds: int = 3600) -> bool:
"""Check if task should be cleaned up based on creation time."""
created = datetime.fromisoformat(created_at)
return (datetime.now() - created).total_seconds() > ttl_seconds
def decode_redis_hash(hash_data: Dict[bytes, bytes]) -> Dict[str, str]:
"""Decode Redis hash data from bytes to strings."""
return {k.decode('utf-8'): v.decode('utf-8') for k, v in hash_data.items()}
def get_redis_task_ttl(config: Dict) -> int:
"""Get Redis task TTL in seconds from config.
Args:
config: The application configuration dictionary
Returns:
TTL in seconds (default 3600). Returns 0 if TTL is disabled.
"""
return config.get("redis", {}).get("task_ttl_seconds", 3600)
def get_llm_api_key(config: Dict, provider: Optional[str] = None) -> Optional[str]:
"""Get the appropriate API key based on the LLM provider.
Args:
config: The application configuration dictionary
provider: Optional provider override (e.g., "openai/gpt-4")
Returns:
The API key if directly configured, otherwise None to let litellm handle it
"""
# Check if direct API key is configured (for backward compatibility)
if "api_key" in config["llm"]:
return config["llm"]["api_key"]
# Return None - litellm will automatically find the right environment variable
return None
def validate_llm_provider(config: Dict, provider: Optional[str] = None) -> tuple[bool, str]:
"""Validate that the LLM provider has an associated API key.
Args:
config: The application configuration dictionary
provider: Optional provider override (e.g., "openai/gpt-4")
Returns:
Tuple of (is_valid, error_message)
"""
# If a direct API key is configured, validation passes
if "api_key" in config["llm"]:
return True, ""
# Otherwise, trust that litellm will find the appropriate environment variable
# We can't easily validate this without reimplementing litellm's logic
return True, ""
def get_llm_temperature(config: Dict, provider: Optional[str] = None) -> Optional[float]:
"""Get temperature setting based on the LLM provider.
Priority order:
1. Provider-specific environment variable (e.g., OPENAI_TEMPERATURE)
2. Global LLM_TEMPERATURE environment variable
3. None (to use litellm/provider defaults)
Args:
config: The application configuration dictionary
provider: Optional provider override (e.g., "openai/gpt-4")
Returns:
The temperature setting if configured, otherwise None
"""
# Check provider-specific temperature first
if provider:
provider_name = provider.split('/')[0].upper()
provider_temp = os.environ.get(f"{provider_name}_TEMPERATURE")
if provider_temp:
try:
return float(provider_temp)
except ValueError:
logging.warning(f"Invalid temperature value for {provider_name}: {provider_temp}")
# Check global LLM_TEMPERATURE
global_temp = os.environ.get("LLM_TEMPERATURE")
if global_temp:
try:
return float(global_temp)
except ValueError:
logging.warning(f"Invalid global temperature value: {global_temp}")
# Return None to use litellm/provider defaults
return None
def get_llm_base_url(config: Dict, provider: Optional[str] = None) -> Optional[str]:
"""Get base URL setting based on the LLM provider.
Priority order:
1. Provider-specific environment variable (e.g., OPENAI_BASE_URL)
2. Global LLM_BASE_URL environment variable
3. None (to use default endpoints)
Args:
config: The application configuration dictionary
provider: Optional provider override (e.g., "openai/gpt-4")
Returns:
The base URL if configured, otherwise None
"""
# Check provider-specific base URL first
if provider:
provider_name = provider.split('/')[0].upper()
provider_url = os.environ.get(f"{provider_name}_BASE_URL")
if provider_url:
return provider_url
# Check global LLM_BASE_URL
return os.environ.get("LLM_BASE_URL")
# ── Security utilities ──────────────────────────────────────
# validate_output_path / ALLOWED_OUTPUT_DIR were removed: caller-supplied
# output paths are no longer accepted (string-only validation was bypassable via
# symlink/TOCTOU and sibling-prefix names -> arbitrary write -> RCE). The server
# owns all artifact paths now (see artifacts.py).
import ipaddress
import socket
from urllib.parse import urlparse
_BLOCKED_NETWORKS = [
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("100.64.0.0/10"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.0.0.0/24"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("198.18.0.0/15"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
_BLOCKED_HOSTNAMES = {
"localhost", "metadata.google.internal", "metadata",
"kubernetes.default", "kubernetes.default.svc",
}
ALLOW_INTERNAL_URLS = os.environ.get("CRAWL4AI_ALLOW_INTERNAL_URLS", "false").lower() == "true"
def validate_url_destination(url: str) -> None:
"""Block crawl URLs targeting internal/private networks (SSRF protection).
Skipped when CRAWL4AI_ALLOW_INTERNAL_URLS=true.
Skipped for raw: URLs (inline HTML, no network fetch)."""
if ALLOW_INTERNAL_URLS:
return
if str(url).startswith(("raw:", "raw://")):
return
try:
validate_webhook_url(url)
except ValueError as e:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail=f"URL blocked (SSRF protection): {e}")
def _expand_ip_candidates(ip):
"""Return [ip] plus any IPv4 form wrapped inside the IPv6 address.
SSRF guards must check the unwrapped form because ::ffff:127.0.0.1 and
::127.0.0.1 route to 127.0.0.1 but would not match IPv4 blocklists directly."""
candidates = [ip]
if isinstance(ip, ipaddress.IPv6Address):
if ip.ipv4_mapped is not None:
candidates.append(ip.ipv4_mapped)
else:
as_int = int(ip)
if 0 < as_int < 2**32:
candidates.append(ipaddress.IPv4Address(as_int))
return candidates
def validate_webhook_url(url: str) -> None:
"""Reject webhook/crawl URLs targeting non-global networks (SSRF protection).
Delegates to the single egress rule (egress_broker: reject any resolved IP
where not ip.is_global, including v4-mapped/NAT64/6to4/v4-compat embedded
forms). The raised message is intentionally opaque - it never echoes the
resolved IP or hostname, so this is not a DNS/oracle leak.
"""
from egress_broker import resolve_and_pin, EgressBlocked
parsed = urlparse(str(url))
if not parsed.hostname:
raise ValueError("URL must have a valid hostname")
try:
resolve_and_pin(url)
except EgressBlocked:
raise ValueError("URL blocked")
def verify_email_domain(email: str) -> bool:
try:
domain = email.split('@')[1]
# Try to resolve MX records for the domain.
records = dns.resolver.resolve(domain, 'MX')
return True if records else False
except Exception as e:
return False
def get_container_memory_percent() -> float:
"""Get actual container memory usage vs limit (cgroup v1/v2 aware)."""
try:
# Try cgroup v2 first
usage_path = Path("/sys/fs/cgroup/memory.current")
limit_path = Path("/sys/fs/cgroup/memory.max")
if not usage_path.exists():
# Fall back to cgroup v1
usage_path = Path("/sys/fs/cgroup/memory/memory.usage_in_bytes")
limit_path = Path("/sys/fs/cgroup/memory/memory.limit_in_bytes")
usage = int(usage_path.read_text())
limit = int(limit_path.read_text())
# Handle unlimited (v2: "max", v1: > 1e18)
if limit > 1e18:
import psutil
limit = psutil.virtual_memory().total
return (usage / limit) * 100
except:
# Non-container or unsupported: fallback to host
import psutil
return psutil.virtual_memory().percent
+248
View File
@@ -0,0 +1,248 @@
"""
Webhook delivery service for Crawl4AI.
This module provides webhook notification functionality with exponential backoff retry logic.
"""
import asyncio
import logging
import re
import socket
from typing import Dict, List, Optional
from datetime import datetime, timezone
import aiohttp
from aiohttp.abc import AbstractResolver
logger = logging.getLogger(__name__)
_MAX_WEBHOOK_REDIRECTS = 5
class _WebhookBlocked(Exception):
"""Webhook target (or a redirect hop) resolved to a non-global address."""
class _PinnedResolver(AbstractResolver):
"""aiohttp resolver that returns a single pre-pinned IP for the target host.
aiohttp connects to this IP but still performs TLS SNI / certificate
verification against the original hostname, so this pins the connection
(closing DNS rebinding) without weakening TLS or doing a MITM.
"""
def __init__(self, host: str, ip: str):
self._host = host
self._ip = ip
async def resolve(self, host, port=0, family=socket.AF_INET):
return [{
"hostname": host,
"host": self._ip,
"port": port,
"family": family,
"proto": 0,
"flags": 0,
}]
async def close(self):
pass
# Webhook request-header policy: user-controlled outbound headers could inject
# hop-by-hop / smuggling headers or CRLF. Allow only well-formed names, reject
# control chars in values, and deny sensitive/hop-by-hop names.
_WEBHOOK_HEADER_NAME = re.compile(r"^[A-Za-z0-9-]{1,64}$")
_WEBHOOK_DENY_HEADERS = {
"host", "content-length", "transfer-encoding", "connection",
"content-type", "proxy-authorization", "authorization", "cookie",
"expect", "upgrade", "te", "trailer",
}
_MAX_WEBHOOK_HEADERS = 20
_MAX_WEBHOOK_HEADER_VALUE = 2048
def sanitize_webhook_headers(headers: Optional[Dict[str, str]]) -> Dict[str, str]:
"""Validate user-supplied webhook headers; raise ValueError on any bad one."""
if not headers:
return {}
if len(headers) > _MAX_WEBHOOK_HEADERS:
raise ValueError("too many webhook headers")
clean: Dict[str, str] = {}
for name, value in headers.items():
if not isinstance(name, str) or not _WEBHOOK_HEADER_NAME.match(name):
raise ValueError(f"invalid webhook header name: {name!r}")
if name.lower() in _WEBHOOK_DENY_HEADERS:
raise ValueError(f"webhook header not allowed: {name}")
sval = str(value)
if len(sval) > _MAX_WEBHOOK_HEADER_VALUE or any(c in sval for c in "\r\n\x00"):
raise ValueError(f"invalid value for webhook header {name}")
clean[name] = sval
return clean
class WebhookDeliveryService:
"""Handles webhook delivery with exponential backoff retry logic."""
def __init__(self, config: Dict):
"""
Initialize the webhook delivery service.
Args:
config: Application configuration dictionary containing webhook settings
"""
self.config = config.get("webhooks", {})
self.max_attempts = self.config.get("retry", {}).get("max_attempts", 5)
self.initial_delay = self.config.get("retry", {}).get("initial_delay_ms", 1000) / 1000
self.max_delay = self.config.get("retry", {}).get("max_delay_ms", 32000) / 1000
self.timeout = self.config.get("retry", {}).get("timeout_ms", 30000) / 1000
async def send_webhook(
self,
webhook_url: str,
payload: Dict,
headers: Optional[Dict[str, str]] = None
) -> bool:
"""
Send webhook with exponential backoff retry logic.
Args:
webhook_url: The URL to send the webhook to
payload: The JSON payload to send
headers: Optional custom headers
Returns:
bool: True if delivered successfully, False otherwise
"""
default_headers = self.config.get("headers", {})
try:
safe_custom = sanitize_webhook_headers(headers)
except ValueError as e:
# Defense in depth (the schema validator rejects these at request
# time); never send a forged/unsafe header.
logger.warning(f"Dropping unsafe webhook headers: {e}")
safe_custom = {}
merged_headers = {**default_headers, **safe_custom}
merged_headers["Content-Type"] = "application/json"
for attempt in range(self.max_attempts):
try:
status = await self._deliver(webhook_url, payload, merged_headers)
if 200 <= status < 300:
logger.info("Webhook delivered successfully")
return True
if status < 500:
logger.warning(f"Webhook rejected with status {status}")
return False # client error - don't retry
logger.warning(f"Webhook failed with status {status}, will retry")
except _WebhookBlocked as exc:
# SSRF: target (or a redirect hop) resolved non-global. Do not
# retry - it will not become safe.
logger.warning(f"Webhook blocked (SSRF protection): {exc}")
return False
except Exception as exc:
logger.error(f"Webhook delivery error (attempt {attempt + 1}): {exc}")
if attempt < self.max_attempts - 1:
delay = min(self.initial_delay * (2 ** attempt), self.max_delay)
await asyncio.sleep(delay)
return False
async def _deliver(self, url: str, payload: Dict, headers: Dict[str, str]) -> int:
"""POST with the connection pinned to the validated IP, following (and
re-validating) redirects manually. Returns the final status code."""
from egress_broker import resolve_and_pin, check_redirect, EgressBlocked, ALLOW_INSECURE_TLS
current = url
for _hop in range(_MAX_WEBHOOK_REDIRECTS + 1):
try:
pin = resolve_and_pin(current)
except EgressBlocked as e:
raise _WebhookBlocked(str(e))
connector = aiohttp.TCPConnector(resolver=_PinnedResolver(pin.host, pin.ip))
ssl = None if not ALLOW_INSECURE_TLS else False
timeout = aiohttp.ClientTimeout(total=self.timeout)
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
async with session.post(
current, json=payload, headers=headers,
allow_redirects=False, ssl=ssl,
) as resp:
if resp.status in (301, 302, 303, 307, 308):
loc = resp.headers.get("Location")
if not loc:
return resp.status
# Re-validate every redirect hop before following it.
try:
check_redirect(loc)
except EgressBlocked as e:
raise _WebhookBlocked(f"redirect to blocked target: {e}")
current = loc
continue
return resp.status
raise _WebhookBlocked("too many webhook redirects")
logger.error(
f"Webhook delivery failed after {self.max_attempts} attempts to {webhook_url}"
)
return False
async def notify_job_completion(
self,
task_id: str,
task_type: str,
status: str,
urls: list,
webhook_config: Optional[Dict],
result: Optional[Dict] = None,
error: Optional[str] = None
):
"""
Notify webhook of job completion.
Args:
task_id: The task identifier
task_type: Type of task (e.g., "crawl", "llm_extraction")
status: Task status ("completed" or "failed")
urls: List of URLs that were crawled
webhook_config: Webhook configuration from the job request
result: Optional crawl result data
error: Optional error message if failed
"""
# Determine webhook URL
webhook_url = None
data_in_payload = self.config.get("data_in_payload", False)
custom_headers = None
if webhook_config:
webhook_url = webhook_config.get("webhook_url")
data_in_payload = webhook_config.get("webhook_data_in_payload", data_in_payload)
custom_headers = webhook_config.get("webhook_headers")
if not webhook_url:
webhook_url = self.config.get("default_url")
if not webhook_url:
logger.debug("No webhook URL configured, skipping notification")
return
# Check if webhooks are enabled
if not self.config.get("enabled", True):
logger.debug("Webhooks are disabled, skipping notification")
return
# Build payload
payload = {
"task_id": task_id,
"task_type": task_type,
"status": status,
"timestamp": datetime.now(timezone.utc).isoformat(),
"urls": urls
}
if error:
payload["error"] = error
if data_in_payload and result:
payload["data"] = result
# Send webhook (fire and forget - don't block on completion)
await self.send_webhook(webhook_url, payload, custom_headers)
+110
View File
@@ -0,0 +1,110 @@
"""
work_queue.py - bounded background-job execution with per-principal quotas.
/crawl/job and /llm/job used FastAPI BackgroundTasks with no bound: a client
could enqueue unlimited background jobs and exhaust memory / browser slots, and
one caller could starve others.
This replaces that with a fixed worker pool draining an asyncio.Queue, plus an
optional per-principal concurrency cap. Everything is configurable, and any
limit set to 0 (or null) means "unbounded" - i.e. the previous behavior is fully
recoverable:
limits.queue.maxsize 0 => unbounded queue (never 503)
limits.queue.workers worker pool size (>=1)
limits.queue.per_principal 0 => no per-caller cap (never 429)
"""
from __future__ import annotations
import asyncio
import logging
from typing import Awaitable, Callable, Dict, Optional
logger = logging.getLogger("crawl4ai.workqueue")
JobFactory = Callable[[], Awaitable[None]]
class QueueFull(Exception):
"""The bounded job queue is full -> 503 Retry-After."""
class QuotaExceeded(Exception):
"""The principal has too many concurrent jobs -> 429."""
class WorkQueue:
def __init__(self, maxsize: int = 0, workers: int = 4, per_principal: int = 0):
self.maxsize = max(0, int(maxsize)) # 0 = unbounded
self.workers = max(1, int(workers))
self.per_principal = max(0, int(per_principal)) # 0 = unlimited
self._q: Optional[asyncio.Queue] = None
self._tasks: list = []
self._counts: Dict[str, int] = {}
@property
def started(self) -> bool:
return self._q is not None
async def start(self) -> None:
self._q = asyncio.Queue(maxsize=self.maxsize)
self._tasks = [asyncio.create_task(self._worker()) for _ in range(self.workers)]
logger.info(
"work queue started (maxsize=%s, workers=%s, per_principal=%s)",
self.maxsize or "unbounded", self.workers, self.per_principal or "unlimited",
)
async def stop(self) -> None:
for t in self._tasks:
t.cancel()
self._tasks = []
self._q = None
async def _worker(self) -> None:
assert self._q is not None
while True:
principal, factory = await self._q.get()
try:
await factory()
except Exception:
logger.exception("background job failed")
finally:
self._release(principal)
self._q.task_done()
def _release(self, principal: Optional[str]) -> None:
if not principal:
return
n = self._counts.get(principal, 0) - 1
if n <= 0:
self._counts.pop(principal, None)
else:
self._counts[principal] = n
def submit(self, factory: JobFactory, principal: Optional[str] = None) -> None:
"""Enqueue a job. Raises QuotaExceeded / QueueFull (mapped to 429 / 503)."""
if self._q is None:
raise RuntimeError("work queue not started")
if self.per_principal and principal:
if self._counts.get(principal, 0) >= self.per_principal:
raise QuotaExceeded()
try:
self._q.put_nowait((principal, factory))
except asyncio.QueueFull:
raise QueueFull()
if principal:
self._counts[principal] = self._counts.get(principal, 0) + 1
# Process-wide singleton, set at server boot.
_JOB_QUEUE: Optional[WorkQueue] = None
def set_job_queue(q: Optional[WorkQueue]) -> None:
global _JOB_QUEUE
_JOB_QUEUE = q
def get_job_queue() -> Optional[WorkQueue]:
return _JOB_QUEUE