From 46a9a30af0d41c23df0394281ee60255c174ee6d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 02:55:58 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]=20Fi?= =?UTF-8?q?x=20SSRF=20bypass=20via=20httpx=20redirects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: HIGH 💡 Vulnerability: The `/process-url` endpoint used `httpx.AsyncClient` with `follow_redirects=True`. While the initial user-provided URL was validated against SSRF protections (blocking private/internal IPs), the client implicitly followed subsequent HTTP redirects without validating their target locations. This allowed an attacker to bypass the initial check by supplying a valid URL that redirected to an internal IP or cloud metadata endpoint. 🎯 Impact: An attacker could potentially access internal network services or cloud metadata endpoints. 🔧 Fix: Implemented an `event_hooks` listener (`validate_redirect`) on the `httpx.AsyncClient` that intercepts responses, extracts the `Location` header, resolves the absolute target URL, and applies the same `validate_url_safety` check before allowing the redirect to be followed. ✅ Verification: Ran `pytest tests/test_url_upload.py`, formatting checks via `ruff format` and linting via `ruff check`. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- .jules/sentinel.md | 4 ++++ app/api/url_upload.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+) 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 },