mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 00:16:20 +00:00
fix: k8s config and silent source ingests
This commit is contained in:
1 parent
e4b06609b1
commit
cf075790ab
9 files changed
+76
-26
No files matched your search
@@ -269,7 +269,7 @@ def _validate_agent_image_upload(file, filename: str) -> None:
|
||||
|
||||
with Image.open(stream) as image:
|
||||
image_format = (image.format or "").upper()
|
||||
if image_format != image_policy[0]:
|
||||
if image_format not in image_policy[0]:
|
||||
raise ValueError("Image content does not match its extension")
|
||||
if image.width * image.height > settings.AGENT_IMAGE_MAX_PIXELS:
|
||||
raise ValueError("Image exceeds the pixel limit")
|
||||
|
||||
@@ -128,7 +128,9 @@ class S3Storage(BaseStorage):
|
||||
metadata = self.s3.head_object(Bucket=self.bucket_name, Key=path)
|
||||
except ClientError as exc:
|
||||
error_code = str(exc.response.get("Error", {}).get("Code", ""))
|
||||
if error_code in {"404", "NoSuchKey", "NotFound"}:
|
||||
# HEAD on a missing key returns 403 instead of 404 when credentials
|
||||
# lack s3:ListBucket, so access-denied codes also mean "absent".
|
||||
if error_code in {"404", "NoSuchKey", "NotFound", "403", "AccessDenied", "Forbidden"}:
|
||||
raise FileNotFoundError(f"File not found: {path}") from exc
|
||||
raise
|
||||
return int(metadata["ContentLength"])
|
||||
|
||||
@@ -238,12 +238,15 @@ def validate_function_name(function_name):
|
||||
return True
|
||||
|
||||
|
||||
# Extension -> (accepted Pillow formats, MIME type). JPEG entries also accept
|
||||
# MPO because Pillow reports multi-picture JPEGs (e.g. iPhone portrait photos,
|
||||
# Samsung motion photos) as MPO.
|
||||
AGENT_IMAGE_FORMATS = {
|
||||
".gif": ("GIF", "image/gif"),
|
||||
".jpeg": ("JPEG", "image/jpeg"),
|
||||
".jpg": ("JPEG", "image/jpeg"),
|
||||
".png": ("PNG", "image/png"),
|
||||
".webp": ("WEBP", "image/webp"),
|
||||
".gif": (("GIF",), "image/gif"),
|
||||
".jpeg": (("JPEG", "MPO"), "image/jpeg"),
|
||||
".jpg": (("JPEG", "MPO"), "image/jpeg"),
|
||||
".png": (("PNG",), "image/png"),
|
||||
".webp": (("WEBP",), "image/webp"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -375,6 +375,10 @@ def extract_zip_recursive(
|
||||
extract_to (str): Destination path for extracted files.
|
||||
current_depth (int): Current depth of recursion.
|
||||
max_depth (int): Maximum allowed depth of recursion to prevent infinite loops.
|
||||
|
||||
Raises:
|
||||
ZipExtractionError: If the archive fails safety validation, so ingestion
|
||||
fails loudly instead of indexing an empty directory.
|
||||
"""
|
||||
if current_depth > max_depth:
|
||||
logging.warning(f"Reached maximum recursion depth of {max_depth}")
|
||||
@@ -407,7 +411,7 @@ def extract_zip_recursive(
|
||||
os.remove(zip_path)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
"Error extracting zip file %s: %s",
|
||||
@@ -415,7 +419,7 @@ def extract_zip_recursive(
|
||||
safe_zip_error_message(e),
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
raise
|
||||
|
||||
|
||||
def download_file(url, params, dest_path):
|
||||
|
||||
@@ -12,8 +12,10 @@ type: Opaque
|
||||
# manually add a MONGO_URI key below (base64-encoded) and apply the
|
||||
# opt-in manifests under deployment/k8s/optional-mongo/. Example:
|
||||
# MONGO_URI: <base64 of mongodb://mongodb-service:27017/docsgpt?retryWrites=true&w=majority>
|
||||
# - Before deploying, add JWT_SECRET_KEY below using the base64 encoding of a
|
||||
# strong random value. Every API and worker replica must receive the same key.
|
||||
# - JWT_SECRET_KEY below is a known dev-grade default so the stock manifests
|
||||
# boot out of the box. Replace it with the base64 encoding of a strong random
|
||||
# value before exposing the API. Every API and worker replica must receive
|
||||
# the same key.
|
||||
# Example generation: openssl rand -hex 32 | base64
|
||||
data:
|
||||
LLM_PROVIDER: ZG9jc2dwdA==
|
||||
@@ -26,4 +28,5 @@ data:
|
||||
POSTGRES_URI: cG9zdGdyZXNxbDovL2RvY3NncHQ6ZG9jc2dwdEBwb3N0Z3Jlczo1NDMyL2RvY3NncHQ=
|
||||
postgres-user: ZG9jc2dwdA==
|
||||
postgres-password: ZG9jc2dwdA==
|
||||
# JWT_SECRET_KEY: <base64-encoded strong random value>
|
||||
# docsgpt-dev-jwt-secret-change-me — CHANGE before exposing the API
|
||||
JWT_SECRET_KEY: ZG9jc2dwdC1kZXYtand0LXNlY3JldC1jaGFuZ2UtbWU=
|
||||
@@ -165,6 +165,29 @@ class TestHandleImageUpload:
|
||||
assert "user123" in url
|
||||
mock_storage.save_file.assert_called_once()
|
||||
|
||||
def test_accepts_multi_picture_jpeg(self, flask_app):
|
||||
from application.api.user.base import handle_image_upload
|
||||
|
||||
# Pillow reports multi-picture JPEGs (e.g. iPhone portrait photos) as
|
||||
# MPO; they must still be accepted under a .jpg extension.
|
||||
buffer = io.BytesIO()
|
||||
frame = Image.new("RGB", (1, 1), color="white")
|
||||
frame.save(buffer, format="MPO", save_all=True, append_images=[frame])
|
||||
|
||||
with flask_app.test_request_context():
|
||||
mock_file = FileStorage(
|
||||
stream=io.BytesIO(buffer.getvalue()), filename="photo.jpg"
|
||||
)
|
||||
mock_storage = Mock()
|
||||
|
||||
url, error = handle_image_upload(
|
||||
Mock(files={"image": mock_file}), "", "user123", mock_storage
|
||||
)
|
||||
|
||||
assert error is None
|
||||
assert url is not None
|
||||
mock_storage.save_file.assert_called_once()
|
||||
|
||||
def test_uploads_image_with_non_ascii_basename(self, flask_app):
|
||||
from application.api.user.base import handle_image_upload
|
||||
|
||||
|
||||
@@ -270,6 +270,19 @@ class TestS3StorageFileSize:
|
||||
with pytest.raises(FileNotFoundError, match="File not found"):
|
||||
s3_storage.get_file_size("avatars/missing.png")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_file_size_maps_denied_head_to_file_not_found(
|
||||
self, s3_storage, mock_boto3_client
|
||||
):
|
||||
# Without s3:ListBucket, HEAD on a missing key returns 403, not 404.
|
||||
mock_boto3_client.head_object.side_effect = ClientError(
|
||||
{"Error": {"Code": "403", "Message": "Forbidden"}},
|
||||
"head_object",
|
||||
)
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="File not found"):
|
||||
s3_storage.get_file_size("avatars/missing.png")
|
||||
|
||||
|
||||
class TestS3StoragePresignedUrl:
|
||||
"""Test response metadata on short-lived direct avatar delivery URLs."""
|
||||
|
||||
@@ -204,7 +204,7 @@ class TestExtractZipRecursive:
|
||||
assert found, "expected nested zip to be extracted"
|
||||
|
||||
def test_nested_archives_share_one_expansion_budget(self, tmp_path):
|
||||
from application.worker import extract_zip_recursive
|
||||
from application.worker import extract_zip_recursive, ZipExtractionError
|
||||
|
||||
inner = tmp_path / "inner.zip"
|
||||
with zipfile.ZipFile(inner, "w", zipfile.ZIP_STORED) as zf:
|
||||
@@ -222,7 +222,8 @@ class TestExtractZipRecursive:
|
||||
with patch("application.worker.MAX_UNCOMPRESSED_SIZE", limit), patch(
|
||||
"application.worker.MAX_COMPRESSION_RATIO", 10_000,
|
||||
):
|
||||
extract_zip_recursive(str(outer), str(extract_to))
|
||||
with pytest.raises(ZipExtractionError):
|
||||
extract_zip_recursive(str(outer), str(extract_to))
|
||||
|
||||
assert not (extract_to / "inside.txt").exists()
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ class TestExtractZipRecursive:
|
||||
assert not os.path.exists(os.path.join(extract_to, "inner.zip"))
|
||||
|
||||
def test_respects_max_depth(self):
|
||||
"""Extraction should stop at max recursion depth."""
|
||||
"""Nesting beyond max depth should fail the extraction loudly."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
extract_to = os.path.join(temp_dir, "extract")
|
||||
os.makedirs(extract_to)
|
||||
@@ -311,10 +311,8 @@ class TestExtractZipRecursive:
|
||||
f.write(current_content)
|
||||
|
||||
# Extract with max_depth=2
|
||||
extract_zip_recursive(zip_path, extract_to, max_depth=2)
|
||||
|
||||
# The deepest nested zips should remain unextracted
|
||||
# (we can't easily verify the exact behavior, but the function should not crash)
|
||||
with pytest.raises(ZipExtractionError):
|
||||
extract_zip_recursive(zip_path, extract_to, max_depth=2)
|
||||
|
||||
def test_rejects_path_traversal(self):
|
||||
"""Zip with path traversal should be rejected and removed."""
|
||||
@@ -327,7 +325,8 @@ class TestExtractZipRecursive:
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("../../../tmp/malicious.txt", "malicious")
|
||||
|
||||
extract_zip_recursive(zip_path, extract_to)
|
||||
with pytest.raises(ZipExtractionError):
|
||||
extract_zip_recursive(zip_path, extract_to)
|
||||
|
||||
# Zip should be removed
|
||||
assert not os.path.exists(zip_path)
|
||||
@@ -348,12 +347,13 @@ class TestExtractZipRecursive:
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr(link, "../../outside")
|
||||
|
||||
extract_zip_recursive(zip_path, extract_to)
|
||||
with pytest.raises(ZipExtractionError):
|
||||
extract_zip_recursive(zip_path, extract_to)
|
||||
|
||||
assert not os.path.lexists(os.path.join(extract_to, "escape-link"))
|
||||
|
||||
def test_handles_corrupted_zip_gracefully(self):
|
||||
"""Corrupted zip should be handled gracefully without crashing."""
|
||||
def test_corrupted_zip_fails_loudly(self):
|
||||
"""Corrupted zip should raise so ingestion fails instead of indexing nothing."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
zip_path = os.path.join(temp_dir, "corrupted.zip")
|
||||
extract_to = os.path.join(temp_dir, "extract")
|
||||
@@ -363,10 +363,11 @@ class TestExtractZipRecursive:
|
||||
with open(zip_path, "wb") as f:
|
||||
f.write(b"This is not a valid zip file")
|
||||
|
||||
# Should not raise, just log error
|
||||
extract_zip_recursive(zip_path, extract_to)
|
||||
with pytest.raises(ZipExtractionError):
|
||||
extract_zip_recursive(zip_path, extract_to)
|
||||
|
||||
# Function should complete without exception
|
||||
# The rejected zip should be removed
|
||||
assert not os.path.exists(zip_path)
|
||||
|
||||
|
||||
class TestZipBombProtection:
|
||||
|
||||
Reference in new issue
Block a user