diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 6a6144c5..b108555f 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -19,3 +19,7 @@ **Vulnerability:** The `_test_imap_connection` and `_test_s3_connection` functions in `app/api/integrations.py` did not validate user-provided `host` and `endpoint_url` variables against `is_private_ip()`. This allowed an attacker to test the presence of internal IMAP servers or direct S3 SDK API calls to internal infrastructure via SSRF. **Learning:** Any time a new generic connection or integration test is added, SSRF validation may be forgotten if the core network utility (`is_private_ip`) is not systematically applied to all outbound network operations, regardless of the protocol (e.g., IMAP, S3). **Prevention:** Establish a pattern where any user-configurable host or endpoint URL is immediately passed through the centralized `is_private_ip` validation function before any network call or third-party client initialization. +## 2026-03-27 - SSRF Bypass via HTTP Redirects in httpx +**Vulnerability:** The `/process-url` endpoint used `httpx.AsyncClient(follow_redirects=True)` after validating the initial user-provided URL against SSRF protections. However, it did not validate the target URLs of any subsequent HTTP redirects, allowing an attacker to provide a safe URL that redirects to an internal/private IP, bypassing the security check. +**Learning:** Initial URL validation is insufficient when the HTTP client is configured to follow redirects automatically. The client must be explicitly configured to validate every redirect target. +**Prevention:** When using `httpx.AsyncClient(follow_redirects=True)` for user-provided URLs, always implement a redirect validator hook function (e.g., using `event_hooks={'response': [validate_redirect]}`) that resolves the `Location` header and passes it through the same SSRF validation logic before the redirect is followed. diff --git a/app/api/url_upload.py b/app/api/url_upload.py index e93eaea3..91cc3a06 100644 --- a/app/api/url_upload.py +++ b/app/api/url_upload.py @@ -151,6 +151,19 @@ async def process_url( if not safe_filename: safe_filename = "download" + # Hook to validate redirects and prevent SSRF + async def validate_redirect(response: httpx.Response): + if response.is_redirect: + location = response.headers.get("Location") + if location: + # Resolve relative URLs + next_url = urllib.parse.urljoin(str(response.url), location) + try: + validate_url_safety(next_url) + except HTTPException as e: + # Reraise as a RequestError so httpx aborts the request + raise httpx.RequestError(f"Unsafe redirect target: {e.detail}", request=response.request) + # Download file with security measures # Initialize target_path to None to prevent UnboundLocalError in exception handlers # that may execute before target_path is assigned during error cases @@ -162,6 +175,7 @@ async def process_url( async with httpx.AsyncClient( timeout=settings.http_request_timeout, follow_redirects=True, + event_hooks={"response": [validate_redirect]}, headers={ "User-Agent": "DocuElevate/1.0", # Identify ourselves },