fix(security): harden uploads and wecom binding

This commit is contained in:
2026-07-28 12:18:45 +08:00
parent 29e4e4a804
commit 6b43b77c3b
14 changed files with 185 additions and 84 deletions
+41 -20
View File
@@ -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
View File
@@ -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}