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")
|
||||
|
||||
+13
-18
@@ -9,8 +9,9 @@ from fastapi.responses import PlainTextResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.database import async_session, get_db
|
||||
from app.middleware.auth import get_current_user, require_director
|
||||
from app.models.user import User
|
||||
from app.services.wecom import wecom_client, store_bind_token, consume_bind_token
|
||||
@@ -116,21 +117,20 @@ async def wecom_callback_event(request: Request):
|
||||
# Triggers for binding
|
||||
if (msg_type == "text" and content == "绑定") or (msg_type == "event" and event == "click" and event_key == "BIND_ACCOUNT"):
|
||||
_log.info("wecom bind triggered")
|
||||
_handle_bind_request(from_user)
|
||||
await _handle_bind_request(from_user)
|
||||
return PlainTextResponse(content="success")
|
||||
|
||||
_log.info("wecom message ignored (no matching action)")
|
||||
return PlainTextResponse(content="success")
|
||||
|
||||
|
||||
def _handle_bind_request(wecom_userid: str):
|
||||
async def _handle_bind_request(wecom_userid: str):
|
||||
"""Generate bind token, store it, and push a bind link to the user."""
|
||||
if not wecom_userid:
|
||||
return
|
||||
|
||||
import asyncio
|
||||
|
||||
bind_token = store_bind_token(wecom_userid)
|
||||
async with async_session() as db:
|
||||
bind_token = await store_bind_token(db, wecom_userid)
|
||||
|
||||
content = (
|
||||
f"【账号绑定】\n\n"
|
||||
@@ -139,16 +139,7 @@ def _handle_bind_request(wecom_userid: str):
|
||||
f"绑定后可使用企微一键登录,并接收填报提醒通知。"
|
||||
)
|
||||
|
||||
try:
|
||||
# Must run async in sync context
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
loop.run_until_complete(
|
||||
wecom_client.send_text_message([wecom_userid], content)
|
||||
)
|
||||
await wecom_client.send_text_message([wecom_userid], content)
|
||||
|
||||
|
||||
# ── Bind confirmation (JWT-protected) ──
|
||||
@@ -163,7 +154,7 @@ async def bind_confirm(
|
||||
if not data.token:
|
||||
raise HTTPException(status_code=400, detail="token 不能为空")
|
||||
|
||||
wecom_userid = consume_bind_token(data.token)
|
||||
wecom_userid = await consume_bind_token(db, data.token)
|
||||
if not wecom_userid:
|
||||
raise HTTPException(status_code=400, detail="绑定链接已过期或无效,请重新在企微发送「绑定」")
|
||||
|
||||
@@ -181,7 +172,11 @@ async def bind_confirm(
|
||||
)
|
||||
user = result.scalar_one()
|
||||
user.wecom_userid = wecom_userid
|
||||
await db.commit()
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="该企业微信已绑定其他账号") from exc
|
||||
|
||||
return {"code": 200, "msg": "绑定成功", "wecom_userid": wecom_userid}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user