Close as obsolete
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
+1
-1
@@ -18,4 +18,4 @@
|
|||||||
## 2026-03-26 - SSRF in Integration Connection Tests
|
## 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.
|
**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).
|
**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.
|
||||||
|
|||||||
@@ -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))
|
([`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)
|
## v0.172.8 (2026-03-25)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
+4
-14
@@ -554,25 +554,15 @@ def _test_s3_connection(config: dict[str, Any] | None, credentials: dict[str, An
|
|||||||
if not bucket:
|
if not bucket:
|
||||||
return {"success": False, "message": "Missing required field: bucket"}
|
return {"success": False, "message": "Missing required field: bucket"}
|
||||||
|
|
||||||
if endpoint_url is not None:
|
if endpoint_url:
|
||||||
if not isinstance(endpoint_url, str):
|
|
||||||
return {"success": False, "message": "endpoint_url must be a string"}
|
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from app.utils.network import is_private_ip
|
from app.utils.network import is_private_ip
|
||||||
|
|
||||||
parsed = urlparse(endpoint_url)
|
parsed_url = urlparse(endpoint_url)
|
||||||
if parsed.scheme not in ("http", "https"):
|
if parsed_url.hostname and is_private_ip(parsed_url.hostname):
|
||||||
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):
|
|
||||||
logger.warning("SSRF blocked: Attempt to connect to private IP via S3 endpoint %s", endpoint_url)
|
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:
|
try:
|
||||||
client = boto3.client(
|
client = boto3.client(
|
||||||
|
|||||||
@@ -1029,7 +1029,7 @@ class TestConnectionTestEndpoint:
|
|||||||
assert "bucket" in data["message"].lower()
|
assert "bucket" in data["message"].lower()
|
||||||
|
|
||||||
def test_test_s3_blocks_private_ip(self, int_client):
|
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 = {
|
payload = {
|
||||||
"integration_type": "S3",
|
"integration_type": "S3",
|
||||||
"config": {"bucket": "my-bucket", "endpoint_url": "http://127.0.0.1:9000"},
|
"config": {"bucket": "my-bucket", "endpoint_url": "http://127.0.0.1:9000"},
|
||||||
@@ -1039,59 +1039,7 @@ class TestConnectionTestEndpoint:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["success"] is False
|
assert data["success"] is False
|
||||||
assert "internal" in data["message"].lower() or "private" in data["message"].lower()
|
assert "Invalid endpoint URL or private IP" in data["message"]
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
def test_test_webdav_missing_url(self, int_client):
|
def test_test_webdav_missing_url(self, int_client):
|
||||||
"""WebDAV test with missing URL returns failure."""
|
"""WebDAV test with missing URL returns failure."""
|
||||||
|
|||||||
Reference in New Issue
Block a user