Просмотр исходного кода

feat(backend): whitelist public upload folders and image-only qrcode

Public upload accepts only feedback/suggestion; admin qrcode uploads
require image magic validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 2 месяцев назад
Родитель
Сommit
539594fe43
3 измененных файлов с 126 добавлено и 8 удалено
  1. 37 2
      backend/app/api/admin.py
  2. 14 1
      backend/app/api/public.py
  3. 75 5
      backend/tests/test_upload.py

+ 37 - 2
backend/app/api/admin.py

@@ -1,7 +1,11 @@
 """管理端 API:工作台、台账办理、配置、用户/角色、审计、附件上传。"""
+from __future__ import annotations
+
+import logging
 from typing import Annotated, Any, Dict, List, Optional
 
 from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
+from fastapi.responses import StreamingResponse
 from sqlalchemy.orm import Session
 
 from app.core import storage
@@ -9,7 +13,8 @@ from app.core.database import get_db
 from app.core.deps import get_current_user, require_menu
 from app.core.response import AppHTTPError, success_response
 from app.core.security import hash_password
-from app.core.storage import StorageError
+from app.core.storage import StorageError, _normalize_folder
+from app.core.upload_validate import validate_image_upload, validate_upload
 from app.crud import attachment as attachment_crud
 from app.crud import menu as menu_crud
 from app.crud import oper_log as oper_log_crud
@@ -25,6 +30,8 @@ from app.schemas.system import (
 )
 from app.services import admin_ledger_service, auth_service, config_service, dashboard_service
 
+logger = logging.getLogger(__name__)
+
 router = APIRouter(prefix="/web/admin", tags=["admin"])
 
 
@@ -198,6 +205,7 @@ def put_hotline(
             db,
             user,
             phone=body.phone,
+            phones=body.phones,
             email=body.email,
             address=body.address,
             work_time=body.work_time,
@@ -239,9 +247,17 @@ async def admin_upload(
 ):
     if not file or not file.filename:
         raise AppHTTPError(400, "请选择文件")
+    try:
+        folder_norm = _normalize_folder(folder or "")
+    except StorageError as exc:
+        raise AppHTTPError(400, exc.message) from exc
     content = await file.read()
+    if folder_norm == "qrcode":
+        validate_image_upload(file.filename, content)
+    else:
+        validate_upload(file.filename, content)
     try:
-        stored = storage.upload_file(content, file.filename, folder or "")
+        stored = storage.upload_file(content, file.filename, folder_norm)
     except StorageError as exc:
         raise AppHTTPError(400, exc.message) from exc
     row = attachment_crud.create_attachment(
@@ -261,6 +277,25 @@ async def admin_upload(
     )
 
 
+@router.get("/attachment/file")
+def download_attachment_file(
+    user: Annotated[SysUser, Depends(get_current_user)],
+    url: str = Query(...),
+):
+    try:
+        stream, content_type, filename, inline = storage.open_file(url)
+    except StorageError as exc:
+        raise AppHTTPError(400, exc.message) from exc
+    logger.info("attachment_proxy user_id=%s filename=%s", user.id, filename)
+    disposition = "inline" if inline else "attachment"
+    # ASCII-safe filename fallback for Content-Disposition
+    safe_name = filename.encode("ascii", "ignore").decode() or "download"
+    headers = {
+        "X-Content-Type-Options": "nosniff",
+        "Content-Disposition": f'{disposition}; filename="{safe_name}"',
+    }
+    return StreamingResponse(stream, media_type=content_type, headers=headers)
+
 @router.get("/users")
 def list_users(
     db: Annotated[Session, Depends(get_db)],

+ 14 - 1
backend/app/api/public.py

@@ -8,12 +8,16 @@ from app.core import storage
 from app.core.database import get_db
 from app.core.response import AppHTTPError, success_response
 from app.core.storage import StorageError
+from app.core.storage import _normalize_folder
+from app.core.upload_validate import validate_upload
 from app.crud import attachment as attachment_crud
 from app.schemas.business import FeedbackCreateBody, SuggestionCreateBody
 from app.services import config_service, feedback_service, suggestion_service
 
 router = APIRouter(prefix="/public", tags=["public"])
 
+PUBLIC_UPLOAD_FOLDERS = frozenset({"feedback", "suggestion"})
+
 
 @router.post("/attachment/upload")
 async def upload_attachment(
@@ -23,9 +27,16 @@ async def upload_attachment(
 ):
     if not file or not file.filename:
         raise AppHTTPError(400, "请选择文件")
+    try:
+        folder_norm = _normalize_folder(folder or "")
+    except StorageError as exc:
+        raise AppHTTPError(400, exc.message) from exc
+    if folder_norm not in PUBLIC_UPLOAD_FOLDERS:
+        raise AppHTTPError(400, "不允许的上传目录")
     content = await file.read()
+    validate_upload(file.filename, content)
     try:
-        stored = storage.upload_file(content, file.filename, folder or "")
+        stored = storage.upload_file(content, file.filename, folder_norm)
     except StorageError as exc:
         raise AppHTTPError(400, exc.message) from exc
     row = attachment_crud.create_attachment(
@@ -68,6 +79,8 @@ def submit_feedback(
         target_dept=body.target_dept,
         target_post=body.target_post,
         reporter_name=body.reporter_name,
+        reporter_unit=body.reporter_unit,
+        reporter_post=body.reporter_post,
         reporter_phone=body.reporter_phone,
         attachment_urls=body.attachment_urls,
     )

+ 75 - 5
backend/tests/test_upload.py

@@ -3,6 +3,9 @@ import pytest
 
 from app.core.config import get_settings
 
+PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 8
+PDF = b"%PDF-1.4" + b"\x00" * 16
+
 
 @pytest.fixture
 def local_storage_env(tmp_path, monkeypatch):
@@ -14,15 +17,60 @@ def local_storage_env(tmp_path, monkeypatch):
 
 
 def test_public_upload(client, local_storage_env):
-    files = {"file": ("a.png", b"fake-png-bytes", "image/png")}
-    r = client.post("/api/public/attachment/upload", files=files)
+    files = {"file": ("a.png", PNG, "image/png")}
+    r = client.post(
+        "/api/public/attachment/upload",
+        files=files,
+        data={"folder": "feedback"},
+    )
     assert r.status_code == 200
     data = r.json()["data"]
     assert data["url"]
     assert data["id"]
     assert data["fileName"] == "a.png"
-    assert data["size"] == len(b"fake-png-bytes")
-    assert data["url"].startswith("/uploads/")
+    assert data["size"] == len(PNG)
+    assert data["url"].startswith("/uploads/feedback/")
+
+
+def test_public_upload_pdf(client, local_storage_env):
+    files = {"file": ("report.pdf", PDF, "application/pdf")}
+    r = client.post(
+        "/api/public/attachment/upload",
+        files=files,
+        data={"folder": "suggestion"},
+    )
+    assert r.status_code == 200
+    data = r.json()["data"]
+    assert data["fileName"] == "report.pdf"
+    assert data["url"].endswith(".pdf") or ".pdf" in data["url"]
+
+
+def test_public_upload_rejects_disallowed_extension(client, local_storage_env):
+    files = {"file": ("malware.exe", b"MZ", "application/octet-stream")}
+    r = client.post(
+        "/api/public/attachment/upload",
+        files=files,
+        data={"folder": "feedback"},
+    )
+    assert r.status_code == 400
+    body = r.json()
+    assert body["code"] == 400
+    assert "类型" in body["message"] or "格式" in body["message"] or "不支持" in body["message"]
+
+
+def test_public_upload_rejects_oversized(client, local_storage_env):
+    # 20MB + 1 byte
+    big = b"%PDF-1.4" + b"x" * (20 * 1024 * 1024)
+    files = {"file": ("big.pdf", big, "application/pdf")}
+    r = client.post(
+        "/api/public/attachment/upload",
+        files=files,
+        data={"folder": "feedback"},
+    )
+    assert r.status_code == 400
+    body = r.json()
+    assert body["code"] == 400
+    assert "大小" in body["message"] or "过大" in body["message"] or "MB" in body["message"]
 
 
 def test_public_upload_rejects_path_traversal(client, local_storage_env):
@@ -30,7 +78,7 @@ def test_public_upload_rejects_path_traversal(client, local_storage_env):
     outside = (base / ".." / "outside").resolve()
     assert not outside.exists()
 
-    files = {"file": ("evil.png", b"evil-bytes", "image/png")}
+    files = {"file": ("evil.png", PNG, "image/png")}
     r = client.post(
         "/api/public/attachment/upload",
         files=files,
@@ -43,3 +91,25 @@ def test_public_upload_rejects_path_traversal(client, local_storage_env):
     assert "detail" not in body
     assert not outside.exists()
     assert not any(base.rglob("*"))
+
+
+def test_public_upload_rejects_qrcode_folder(client, local_storage_env):
+    files = {"file": ("a.png", PNG, "image/png")}
+    r = client.post(
+        "/api/public/attachment/upload",
+        files=files,
+        data={"folder": "qrcode"},
+    )
+    assert r.status_code == 400
+    assert r.json()["code"] == 400
+
+
+def test_public_upload_allows_feedback_folder(client, local_storage_env):
+    files = {"file": ("a.png", PNG, "image/png")}
+    r = client.post(
+        "/api/public/attachment/upload",
+        files=files,
+        data={"folder": "feedback"},
+    )
+    assert r.status_code == 200
+    assert "/feedback/" in r.json()["data"]["url"]