#!/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 = '' escaped = html.escape(payload) self.assertNotIn("