From c5c5284bd0f0d7ca21af18ad1fac49671b323f66 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 13:37:55 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]=20Fi?= =?UTF-8?q?x=20SSRF=20bypass=20via=20HTTP=20redirects=20in=20URL=20upload?= 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 | 4 ++++ app/api/url_upload.py | 10 ++++++++++ reproduce.py | 34 ---------------------------------- 3 files changed, 14 insertions(+), 34 deletions(-) delete mode 100644 reproduce.py diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 6a6144c5..a77499bc 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 +**Vulnerability:** The `process_url` endpoint in `app/api/url_upload.py` used `httpx.AsyncClient` with `follow_redirects=True`. While the initial user-provided URL was validated against SSRF, if the remote server returned an HTTP redirect to an internal IP (like 127.0.0.1 or an AWS metadata endpoint), the HTTP client would automatically follow the redirect without validating the new target URL. +**Learning:** Initial URL validation is insufficient if the HTTP client automatically follows redirects. Attackers can easily set up external servers that respond with `302 Found` pointing to internal network addresses. +**Prevention:** If `follow_redirects=True` is required, always implement an event hook (e.g., `event_hooks={"response": [check_redirect]}`) to intercept redirect responses, extract the `Location` header, and validate the target URL using `is_private_ip` or `validate_url_safety` before the client follows it. diff --git a/app/api/url_upload.py b/app/api/url_upload.py index e93eaea3..4765293e 100644 --- a/app/api/url_upload.py +++ b/app/api/url_upload.py @@ -137,6 +137,15 @@ async def process_url( # Validate URL safety (SSRF protection) validate_url_safety(url) + # Event hook to intercept and validate redirects + async def check_redirect(response: httpx.Response): + if response.is_redirect: + location = response.headers.get("Location") + if location: + redirect_url = str(response.url.join(location)) + # Validate the redirect target + validate_url_safety(redirect_url) + # Parse URL to extract filename if not provided if url_request.filename: original_filename = url_request.filename @@ -165,6 +174,7 @@ async def process_url( headers={ "User-Agent": "DocuElevate/1.0", # Identify ourselves }, + event_hooks={"response": [check_redirect]}, ) as client: async with client.stream("GET", url) as response: response.raise_for_status() diff --git a/reproduce.py b/reproduce.py deleted file mode 100644 index 6e5a412e..00000000 --- a/reproduce.py +++ /dev/null @@ -1,34 +0,0 @@ -import sys -from unittest.mock import MagicMock -from fastapi.templating import Jinja2Templates - -import os -# We don't really need a real path, but let's mock it -os.makedirs("templates", exist_ok=True) -with open("templates/files.html", "w") as f: - f.write("Hello") - -templates = Jinja2Templates(directory="templates") -original_template_response = templates.TemplateResponse - -def template_response_with_version(*args, **kwargs): - if len(args) == 2 and isinstance(args[0], str) and isinstance(args[1], dict): - context = args[1] - request = context.get("request") - if request is not None: - # THIS IS MY FIX - print("Running fix logic") - return original_template_response(request=request, name=args[0], context=context, **kwargs) - - print("Running original fallback logic") - return original_template_response(*args, **kwargs) - -templates.TemplateResponse = template_response_with_version - -req = MagicMock() -try: - templates.TemplateResponse("files.html", {"request": req}) - print("SUCCESS") -except Exception as e: - import traceback - traceback.print_exc()