fix(security): harden uploads and wecom binding
This commit is contained in:
+41
-20
@@ -1,12 +1,15 @@
|
||||
import uuid
|
||||
import io
|
||||
import warnings
|
||||
from datetime import date
|
||||
from pathlib import PurePosixPath
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.services.minio_client import generate_presigned_upload_url, generate_presigned_download_url
|
||||
from app.services.minio_client import generate_presigned_download_url, put_validated_image
|
||||
from app.models.visit import Visit
|
||||
|
||||
router = APIRouter(prefix="/upload", tags=["Upload"])
|
||||
@@ -16,16 +19,17 @@ _IMAGE_TYPES = {
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
}
|
||||
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
MAX_IMAGE_PIXELS = 25_000_000
|
||||
|
||||
|
||||
def validate_image_upload(filename: str, content_type: str) -> str:
|
||||
"""Validate client-declared image metadata before issuing a short-lived PUT URL."""
|
||||
extension = _IMAGE_TYPES.get(content_type.lower())
|
||||
if not extension:
|
||||
"""Validate image metadata before accepting bytes for server-side verification."""
|
||||
if content_type.lower() not in _IMAGE_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Only JPEG, PNG, and WebP images are supported")
|
||||
if PurePosixPath(filename).suffix.lower() not in {".jpg", ".jpeg", ".png", ".webp"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid image filename extension")
|
||||
return extension
|
||||
return _IMAGE_TYPES[content_type.lower()]
|
||||
|
||||
|
||||
def build_object_key(user_id: str, extension: str) -> str:
|
||||
@@ -44,22 +48,39 @@ def is_owned_upload_key(object_key: str, user_id: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
@router.post("/presigned-url")
|
||||
async def get_presigned_upload_url(
|
||||
filename: str,
|
||||
content_type: str = "image/jpeg",
|
||||
def normalize_image(image_bytes: bytes) -> bytes:
|
||||
"""Verify image bytes, enforce a pixel limit, and strip active/metadata content by re-encoding."""
|
||||
if not image_bytes or len(image_bytes) > MAX_IMAGE_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Image must be between 1 byte and 10 MB")
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
||||
with Image.open(io.BytesIO(image_bytes)) as image:
|
||||
image.verify()
|
||||
with Image.open(io.BytesIO(image_bytes)) as image:
|
||||
if image.width * image.height > MAX_IMAGE_PIXELS:
|
||||
raise HTTPException(status_code=413, detail="Image dimensions are too large")
|
||||
normalized = image.convert("RGB")
|
||||
output = io.BytesIO()
|
||||
normalized.save(output, format="JPEG", quality=85, optimize=True)
|
||||
return output.getvalue()
|
||||
except HTTPException:
|
||||
raise
|
||||
except (Image.DecompressionBombError, Image.DecompressionBombWarning, OSError, UnidentifiedImageError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid image content") from exc
|
||||
|
||||
|
||||
@router.post("/image")
|
||||
async def upload_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict = Depends(require_any_role),
|
||||
):
|
||||
"""Get a presigned PUT URL for direct MinIO upload."""
|
||||
extension = validate_image_upload(filename, content_type)
|
||||
object_key = build_object_key(current_user["user_id"], extension)
|
||||
|
||||
url = generate_presigned_upload_url(object_key)
|
||||
|
||||
return {
|
||||
"upload_url": url,
|
||||
"object_key": object_key,
|
||||
}
|
||||
"""Validate and normalize an image before persisting it to private object storage."""
|
||||
validate_image_upload(file.filename or "upload.jpg", file.content_type or "")
|
||||
normalized = normalize_image(await file.read(MAX_IMAGE_BYTES + 1))
|
||||
object_key = build_object_key(current_user["user_id"], "jpg")
|
||||
put_validated_image(object_key, normalized)
|
||||
return {"object_key": object_key}
|
||||
|
||||
|
||||
@router.get("/download-url")
|
||||
|
||||
Reference in New Issue
Block a user