From c547ad1acc5049d3824d08ef0c58251bc088db42 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 04:18:28 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]?= =?UTF-8?q?=20Fix=20SSRF=20vulnerability=20in=20S3=20connection=20test=20e?= =?UTF-8?q?ndpoint=5Furl?= 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/integrations.py | 17 ++++++++++++++- tests/test_api_integrations.py | 39 ++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index f3b5481a..f6c58123 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -15,3 +15,7 @@ **Vulnerability:** The `is_private_ip` function in `app/utils/network.py` failed open (returned `False`) when a hostname could not be resolved (`socket.gaierror`). **Learning:** This fail-open pattern was originally added to allow external domains in tests, but in production, it created a severe SSRF risk. An attacker could bypass SSRF protections by providing a URL that fails to resolve during the security check but resolves later (DNS rebinding), or by exploiting internal routing behaviors via unresolvable addresses. **Prevention:** Always fail securely in network authorization functions. If a domain cannot be resolved to verify its safety, the request must be blocked (`return True` / default-deny). Tests should mock DNS resolution correctly instead of compromising production security logic. +## 2024-11-06 - SSRF in S3 Connection Test +**Vulnerability:** The `_test_s3_connection` function in `app/api/integrations.py` accepted a user-provided `endpoint_url` without validation, passing it directly to `boto3.client`. An attacker could provide an internal URL (e.g., `http://169.254.169.254` or `http://localhost`) to execute Server-Side Request Forgery (SSRF) when `head_bucket` was called. +**Learning:** Even SDK initializations (like AWS boto3) that accept URLs can be abused for SSRF. Input validation is required for any user-controlled URL before it is used to initialize an outbound network client. +**Prevention:** Validate the scheme (must be HTTP/HTTPS) and use a centralized IP resolution function (e.g., `is_private_ip`) to ensure the hostname does not resolve to private or internal addresses before passing the `endpoint_url` to the SDK. diff --git a/app/api/integrations.py b/app/api/integrations.py index f5b11a16..5879d7fb 100644 --- a/app/api/integrations.py +++ b/app/api/integrations.py @@ -547,13 +547,28 @@ def _test_s3_connection(config: dict[str, Any] | None, credentials: dict[str, An if not bucket: return {"success": False, "message": "Missing required field: bucket"} + endpoint_url = cfg.get("endpoint_url") + if endpoint_url: + from urllib.parse import urlparse + + parsed = urlparse(endpoint_url) + if parsed.scheme not in ("http", "https"): + return {"success": False, "message": "URL must use http or https scheme"} + + hostname = parsed.hostname or "" + if hostname: + from app.utils.network import is_private_ip + + if is_private_ip(hostname): + return {"success": False, "message": "URLs pointing to internal or private networks are not allowed"} + try: client = boto3.client( "s3", region_name=region, aws_access_key_id=creds.get("access_key_id", ""), aws_secret_access_key=creds.get("secret_access_key", ""), - endpoint_url=cfg.get("endpoint_url"), + endpoint_url=endpoint_url, ) client.head_bucket(Bucket=bucket) return {"success": True, "message": f"S3 bucket '{bucket}' is accessible"} diff --git a/tests/test_api_integrations.py b/tests/test_api_integrations.py index 958a1a86..8e82e7d9 100644 --- a/tests/test_api_integrations.py +++ b/tests/test_api_integrations.py @@ -1011,6 +1011,45 @@ class TestConnectionTestEndpoint: assert data["success"] is False assert "bucket" in data["message"].lower() + def test_test_s3_blocks_private_ip(self, int_client): + """S3 test blocks requests to private/internal IPs via endpoint_url (SSRF protection).""" + payload = { + "integration_type": "S3", + "config": {"bucket": "my-bucket", "endpoint_url": "http://127.0.0.1/s3"}, + "credentials": {"access_key_id": "AKIA", "secret_access_key": "secret"}, + } + resp = int_client.post("/api/integrations/test", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert "internal" in data["message"].lower() or "private" in data["message"].lower() + + def test_test_s3_blocks_localhost(self, int_client): + """S3 test blocks requests to localhost via endpoint_url.""" + payload = { + "integration_type": "S3", + "config": {"bucket": "my-bucket", "endpoint_url": "http://localhost/s3"}, + "credentials": {"access_key_id": "AKIA", "secret_access_key": "secret"}, + } + resp = int_client.post("/api/integrations/test", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert "internal" in data["message"].lower() or "private" in data["message"].lower() + + def test_test_s3_blocks_file_scheme(self, int_client): + """S3 test blocks file:// scheme via endpoint_url.""" + payload = { + "integration_type": "S3", + "config": {"bucket": "my-bucket", "endpoint_url": "file:///etc/passwd"}, + "credentials": {"access_key_id": "AKIA", "secret_access_key": "secret"}, + } + resp = int_client.post("/api/integrations/test", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert "scheme" in data["message"].lower() + def test_test_webdav_missing_url(self, int_client): """WebDAV test with missing URL returns failure.""" payload = { From 57db4c7c82f4a8df2e7e5e5505e1d5c01768fc16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:50:51 +0000 Subject: [PATCH 2/3] fix(api): resolve merge conflicts, add type safety for endpoint_url in S3 connection test Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/8a9f717e-a6cb-45f0-8f2a-0e5d1d404657 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- CHANGELOG.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d14d963..c231b9ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,27 +31,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([`470f08d`](https://github.com/christianlouis/DocuElevate/commit/470f08d89322f2904b78a8b0f820973611486c26)) -## 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] - ([`76f202f`](https://github.com/christianlouis/DocuElevate/commit/76f202f7f1b94e39a4e79cd984770310599405bf)) - - -## Unreleased - -### Chores - -- **ci**: Ignore CVE-2026-4539 in pip-audit until pygments releases a fix - ([`6927e76`](https://github.com/christianlouis/DocuElevate/commit/6927e7643f9cbe1664f4a1a093511df5b079ed0a)) - - ## v0.172.8 (2026-03-25) ### Bug Fixes From 925864ddca0396d8bae6ca8f962740e7d13946f6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:51:28 +0000 Subject: [PATCH 3/3] Close as obsolete Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- .jules/sentinel.md | 2 +- CHANGELOG.md | 21 +++++++++++++ app/api/integrations.py | 18 +++-------- tests/test_api_integrations.py | 56 ++-------------------------------- 4 files changed, 28 insertions(+), 69 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index bf6a8821..6a6144c5 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -18,4 +18,4 @@ ## 2026-03-26 - SSRF in Integration Connection Tests **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. Also validate that the URL is a string type and has a valid non-empty hostname before parsing, to prevent `TypeError` exceptions when the config value is non-string (e.g., integer or dict). +**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. diff --git a/CHANGELOG.md b/CHANGELOG.md index c231b9ce..1d14d963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([`470f08d`](https://github.com/christianlouis/DocuElevate/commit/470f08d89322f2904b78a8b0f820973611486c26)) +## 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] + ([`76f202f`](https://github.com/christianlouis/DocuElevate/commit/76f202f7f1b94e39a4e79cd984770310599405bf)) + + +## Unreleased + +### Chores + +- **ci**: Ignore CVE-2026-4539 in pip-audit until pygments releases a fix + ([`6927e76`](https://github.com/christianlouis/DocuElevate/commit/6927e7643f9cbe1664f4a1a093511df5b079ed0a)) + + ## v0.172.8 (2026-03-25) ### Bug Fixes diff --git a/app/api/integrations.py b/app/api/integrations.py index c6df1364..99e83bc2 100644 --- a/app/api/integrations.py +++ b/app/api/integrations.py @@ -554,25 +554,15 @@ def _test_s3_connection(config: dict[str, Any] | None, credentials: dict[str, An if not bucket: return {"success": False, "message": "Missing required field: bucket"} - if endpoint_url is not None: - if not isinstance(endpoint_url, str): - return {"success": False, "message": "endpoint_url must be a string"} - + if endpoint_url: from urllib.parse import urlparse from app.utils.network import is_private_ip - parsed = urlparse(endpoint_url) - if parsed.scheme not in ("http", "https"): - return {"success": False, "message": "URL must use http or https scheme"} - - hostname = parsed.hostname or "" - if not hostname: - return {"success": False, "message": "endpoint_url must include a valid hostname"} - - if is_private_ip(hostname): + parsed_url = urlparse(endpoint_url) + if parsed_url.hostname and is_private_ip(parsed_url.hostname): logger.warning("SSRF blocked: Attempt to connect to private IP via S3 endpoint %s", endpoint_url) - return {"success": False, "message": "URLs pointing to internal or private networks are not allowed"} + return {"success": False, "message": "Connection error: Invalid endpoint URL or private IP"} try: client = boto3.client( diff --git a/tests/test_api_integrations.py b/tests/test_api_integrations.py index a1247f2f..561ac177 100644 --- a/tests/test_api_integrations.py +++ b/tests/test_api_integrations.py @@ -1029,7 +1029,7 @@ class TestConnectionTestEndpoint: assert "bucket" in data["message"].lower() def test_test_s3_blocks_private_ip(self, int_client): - """S3 test blocks requests to private/internal IPs via endpoint_url (SSRF protection).""" + """S3 test with private IP endpoint returns failure (SSRF protection).""" payload = { "integration_type": "S3", "config": {"bucket": "my-bucket", "endpoint_url": "http://127.0.0.1:9000"}, @@ -1039,59 +1039,7 @@ class TestConnectionTestEndpoint: assert resp.status_code == 200 data = resp.json() assert data["success"] is False - assert "internal" in data["message"].lower() or "private" in data["message"].lower() - - def test_test_s3_blocks_localhost(self, int_client): - """S3 test blocks requests to localhost via endpoint_url.""" - payload = { - "integration_type": "S3", - "config": {"bucket": "my-bucket", "endpoint_url": "http://localhost/s3"}, - "credentials": {"access_key_id": "AKIA", "secret_access_key": "secret"}, - } - resp = int_client.post("/api/integrations/test", json=payload) - assert resp.status_code == 200 - data = resp.json() - assert data["success"] is False - assert "internal" in data["message"].lower() or "private" in data["message"].lower() - - def test_test_s3_blocks_file_scheme(self, int_client): - """S3 test blocks file:// scheme via endpoint_url.""" - payload = { - "integration_type": "S3", - "config": {"bucket": "my-bucket", "endpoint_url": "file:///etc/passwd"}, - "credentials": {"access_key_id": "AKIA", "secret_access_key": "secret"}, - } - resp = int_client.post("/api/integrations/test", json=payload) - assert resp.status_code == 200 - data = resp.json() - assert data["success"] is False - assert "scheme" in data["message"].lower() - - def test_test_s3_blocks_non_string_endpoint_url(self, int_client): - """S3 test rejects non-string endpoint_url values gracefully (no 500).""" - payload = { - "integration_type": "S3", - "config": {"bucket": "my-bucket", "endpoint_url": 12345}, - "credentials": {"access_key_id": "AKIA", "secret_access_key": "secret"}, - } - resp = int_client.post("/api/integrations/test", json=payload) - assert resp.status_code == 200 - data = resp.json() - assert data["success"] is False - assert "string" in data["message"].lower() - - def test_test_s3_blocks_empty_hostname_endpoint_url(self, int_client): - """S3 test rejects endpoint_url with no hostname (e.g. malformed URL).""" - payload = { - "integration_type": "S3", - "config": {"bucket": "my-bucket", "endpoint_url": "https://"}, - "credentials": {"access_key_id": "AKIA", "secret_access_key": "secret"}, - } - resp = int_client.post("/api/integrations/test", json=payload) - assert resp.status_code == 200 - data = resp.json() - assert data["success"] is False - assert "hostname" in data["message"].lower() + assert "Invalid endpoint URL or private IP" in data["message"] def test_test_webdav_missing_url(self, int_client): """WebDAV test with missing URL returns failure."""