From a75e8b9297d8bf782a819d64e3b49ff66db00ea5 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 03:02:05 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]?= =?UTF-8?q?=20Fix=20SSRF=20bypass=20via=20HTTP=20redirects=20in=20url=5Fup?= =?UTF-8?q?load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- .jules/sentinel.md | 5 +++++ app/api/url_upload.py | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 6a6144c5..32d06205 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -19,3 +19,8 @@ **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. + +## 2024-05-27 - SSRF Bypass via HTTP Redirects +**Vulnerability:** In `app/api/url_upload.py`, the `validate_url_safety` function was correctly verifying the initially requested URL to prevent fetching internal IPs or cloud metadata endpoints. However, the subsequent `httpx.AsyncClient` was configured with `follow_redirects=True` without validating the destination of those redirects. An attacker could bypass SSRF protections by providing a URL to an attacker-controlled server that responds with a 301/302 redirect pointing to an internal target (e.g., `http://127.0.0.1` or `http://169.254.169.254`). +**Learning:** Checking the URL before sending the request is insufficient if the HTTP client automatically follows redirects. The target of every single redirect must be subject to the same strict validation as the initial request. +**Prevention:** Avoid `follow_redirects=True` for user-provided URLs when possible. If redirects must be followed, attach an event hook (e.g., `event_hooks={"response": [hook_function]}`) to the `httpx` client to intercept the response, calculate the redirect destination from the `Location` header, and run the URL safety validation logic before the redirect is actually followed. diff --git a/app/api/url_upload.py b/app/api/url_upload.py index e93eaea3..610378f4 100644 --- a/app/api/url_upload.py +++ b/app/api/url_upload.py @@ -106,6 +106,25 @@ def validate_file_type(content_type: str, filename: str) -> bool: return False +async def verify_redirect(response: httpx.Response) -> None: + """ + Event hook to intercept redirects and validate the new destination URL. + Prevents SSRF bypasses via redirects to internal networks or metadata endpoints. + """ + if response.status_code in (301, 302, 303, 307, 308): + location = response.headers.get("Location") + if location: + # Resolve relative redirects + new_url = str(response.url.join(location)) + # Validate the new URL + try: + validate_url_safety(new_url) + except HTTPException as e: + # Map the validation error to an httpx exception so it can be handled + # properly by the caller, avoiding raw HTTPExceptions escaping the client scope + raise httpx.RequestError(f"Redirect to unsafe URL blocked: {e.detail}", request=response.request) from e + + @router.post("/process-url") @require_login async def process_url( @@ -165,6 +184,7 @@ async def process_url( headers={ "User-Agent": "DocuElevate/1.0", # Identify ourselves }, + event_hooks={"response": [verify_redirect]}, ) as client: async with client.stream("GET", url) as response: response.raise_for_status() From 152ee15b06ebf7beb6216423b4c8d93ec2243165 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 03:23:56 +0000 Subject: [PATCH 2/4] test: add coverage for url_upload redirect SSRF bypass prevention hook Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- tests/test_url_upload.py | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/test_url_upload.py b/tests/test_url_upload.py index 5dff00ac..b5207e1b 100644 --- a/tests/test_url_upload.py +++ b/tests/test_url_upload.py @@ -879,3 +879,45 @@ class TestURLUploadCoverageGaps: # Generic exception (not HTTPException/OSError/RequestException) is caught and returns 500 assert response.status_code == 500 assert "Unexpected error" in response.json()["detail"] + + @pytest.mark.asyncio + async def test_verify_redirect_allows_safe_url(self): + """Test verify_redirect allows safe redirects (lines 115, 118, 120-121)""" + from app.api.url_upload import verify_redirect + import httpx + + req = httpx.Request("GET", "http://example.com") + resp = httpx.Response(301, headers={"Location": "https://google.com"}, request=req) + + # Should not raise any exception + await verify_redirect(resp) + + @pytest.mark.asyncio + @patch("app.api.url_upload.validate_url_safety") + async def test_verify_redirect_blocks_unsafe_url(self, mock_validate): + """Test verify_redirect blocks unsafe redirects (lines 122-125)""" + from app.api.url_upload import verify_redirect + from fastapi import HTTPException + import httpx + + mock_validate.side_effect = HTTPException(status_code=400, detail="Unsafe URL") + + req = httpx.Request("GET", "http://example.com") + resp = httpx.Response(301, headers={"Location": "http://127.0.0.1"}, request=req) + + with pytest.raises(httpx.RequestError) as exc_info: + await verify_redirect(resp) + + assert "Redirect to unsafe URL blocked" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_verify_redirect_ignores_non_redirects(self): + """Test verify_redirect ignores 200 OK responses""" + from app.api.url_upload import verify_redirect + import httpx + + req = httpx.Request("GET", "http://example.com") + resp = httpx.Response(200, request=req) + + # Should not raise any exception and should ignore missing Location header + await verify_redirect(resp) From 8295279ec93570da4eb0445ede8084d1eb2aba99 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 03:24:20 +0000 Subject: [PATCH 3/4] style: apply ruff auto-fix - Auto-formatted code with ruff format - Applied ruff linting fixes with --fix Co-authored-by: github-actions[bot] --- tests/test_url_upload.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_url_upload.py b/tests/test_url_upload.py index b5207e1b..b3caaff8 100644 --- a/tests/test_url_upload.py +++ b/tests/test_url_upload.py @@ -883,9 +883,10 @@ class TestURLUploadCoverageGaps: @pytest.mark.asyncio async def test_verify_redirect_allows_safe_url(self): """Test verify_redirect allows safe redirects (lines 115, 118, 120-121)""" - from app.api.url_upload import verify_redirect import httpx + from app.api.url_upload import verify_redirect + req = httpx.Request("GET", "http://example.com") resp = httpx.Response(301, headers={"Location": "https://google.com"}, request=req) @@ -896,9 +897,10 @@ class TestURLUploadCoverageGaps: @patch("app.api.url_upload.validate_url_safety") async def test_verify_redirect_blocks_unsafe_url(self, mock_validate): """Test verify_redirect blocks unsafe redirects (lines 122-125)""" - from app.api.url_upload import verify_redirect - from fastapi import HTTPException import httpx + from fastapi import HTTPException + + from app.api.url_upload import verify_redirect mock_validate.side_effect = HTTPException(status_code=400, detail="Unsafe URL") @@ -913,9 +915,10 @@ class TestURLUploadCoverageGaps: @pytest.mark.asyncio async def test_verify_redirect_ignores_non_redirects(self): """Test verify_redirect ignores 200 OK responses""" - from app.api.url_upload import verify_redirect import httpx + from app.api.url_upload import verify_redirect + req = httpx.Request("GET", "http://example.com") resp = httpx.Response(200, request=req) From bdfa3ba1e0a5702414e3b449fbde6a6d3149557a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 03:31:23 +0000 Subject: [PATCH 4/4] style: sort imports in test_url_upload.py Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>