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 1/2] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]?= =?UTF-8?q?=20Fix=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 }, From c6e0b80becab81a75aea4ee78f5aaf8b6ac54854 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Apr 2026 09:33:42 +0000 Subject: [PATCH 2/2] docs(changelog): update changelog [skip ci] --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d14d963..878f6651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## Unreleased + +### Chores + +- **ci**: Ignore CVE-2026-4539 in pip-audit until pygments releases a fix + ([`6927e76`](https://github.com/christianlouis/DocuElevate/commit/6927e7643f9cbe1664f4a1a093511df5b079ed0a)) + +### Documentation + +- **changelog**: Update changelog [skip ci] + ([`9b9882c`](https://github.com/christianlouis/DocuElevate/commit/9b9882c4d62691d0ddd20444e3b77bfe6eecc8c3)) + +- **changelog**: Update changelog [skip ci] + ([`69053bf`](https://github.com/christianlouis/DocuElevate/commit/69053bfb08d3e2f12a86878044667ac500888837)) + +- **changelog**: Update changelog [skip ci] + ([`76f202f`](https://github.com/christianlouis/DocuElevate/commit/76f202f7f1b94e39a4e79cd984770310599405bf)) + +### Testing + +- Add tests for SSRF validation in integrations + ([`470f08d`](https://github.com/christianlouis/DocuElevate/commit/470f08d89322f2904b78a8b0f820973611486c26)) + + ## Unreleased ### Chores