Sfoglia il codice sorgente

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 mesi fa
parent
commit
539594fe43
3 ha cambiato i file con 126 aggiunte e 8 eliminazioni
  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:工作台、台账办理、配置、用户/角色、审计、附件上传。"""
 """管理端 API:工作台、台账办理、配置、用户/角色、审计、附件上传。"""
+from __future__ import annotations
+
+import logging
 from typing import Annotated, Any, Dict, List, Optional
 from typing import Annotated, Any, Dict, List, Optional
 
 
 from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
 from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
+from fastapi.responses import StreamingResponse
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
 from app.core import storage
 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.deps import get_current_user, require_menu
 from app.core.response import AppHTTPError, success_response
 from app.core.response import AppHTTPError, success_response
 from app.core.security import hash_password
 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 attachment as attachment_crud
 from app.crud import menu as menu_crud
 from app.crud import menu as menu_crud
 from app.crud import oper_log as oper_log_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
 from app.services import admin_ledger_service, auth_service, config_service, dashboard_service
 
 
+logger = logging.getLogger(__name__)
+
 router = APIRouter(prefix="/web/admin", tags=["admin"])
 router = APIRouter(prefix="/web/admin", tags=["admin"])
 
 
 
 
@@ -198,6 +205,7 @@ def put_hotline(
             db,
             db,
             user,
             user,
             phone=body.phone,
             phone=body.phone,
+            phones=body.phones,
             email=body.email,
             email=body.email,
             address=body.address,
             address=body.address,
             work_time=body.work_time,
             work_time=body.work_time,
@@ -239,9 +247,17 @@ async def admin_upload(
 ):
 ):
     if not file or not file.filename:
     if not file or not file.filename:
         raise AppHTTPError(400, "请选择文件")
         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()
     content = await file.read()
+    if folder_norm == "qrcode":
+        validate_image_upload(file.filename, content)
+    else:
+        validate_upload(file.filename, content)
     try:
     try:
-        stored = storage.upload_file(content, file.filename, folder or "")
+        stored = storage.upload_file(content, file.filename, folder_norm)
     except StorageError as exc:
     except StorageError as exc:
         raise AppHTTPError(400, exc.message) from exc
         raise AppHTTPError(400, exc.message) from exc
     row = attachment_crud.create_attachment(
     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")
 @router.get("/users")
 def list_users(
 def list_users(
     db: Annotated[Session, Depends(get_db)],
     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.database import get_db
 from app.core.response import AppHTTPError, success_response
 from app.core.response import AppHTTPError, success_response
 from app.core.storage import StorageError
 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.crud import attachment as attachment_crud
 from app.schemas.business import FeedbackCreateBody, SuggestionCreateBody
 from app.schemas.business import FeedbackCreateBody, SuggestionCreateBody
 from app.services import config_service, feedback_service, suggestion_service
 from app.services import config_service, feedback_service, suggestion_service
 
 
 router = APIRouter(prefix="/public", tags=["public"])
 router = APIRouter(prefix="/public", tags=["public"])
 
 
+PUBLIC_UPLOAD_FOLDERS = frozenset({"feedback", "suggestion"})
+
 
 
 @router.post("/attachment/upload")
 @router.post("/attachment/upload")
 async def upload_attachment(
 async def upload_attachment(
@@ -23,9 +27,16 @@ async def upload_attachment(
 ):
 ):
     if not file or not file.filename:
     if not file or not file.filename:
         raise AppHTTPError(400, "请选择文件")
         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()
     content = await file.read()
+    validate_upload(file.filename, content)
     try:
     try:
-        stored = storage.upload_file(content, file.filename, folder or "")
+        stored = storage.upload_file(content, file.filename, folder_norm)
     except StorageError as exc:
     except StorageError as exc:
         raise AppHTTPError(400, exc.message) from exc
         raise AppHTTPError(400, exc.message) from exc
     row = attachment_crud.create_attachment(
     row = attachment_crud.create_attachment(
@@ -68,6 +79,8 @@ def submit_feedback(
         target_dept=body.target_dept,
         target_dept=body.target_dept,
         target_post=body.target_post,
         target_post=body.target_post,
         reporter_name=body.reporter_name,
         reporter_name=body.reporter_name,
+        reporter_unit=body.reporter_unit,
+        reporter_post=body.reporter_post,
         reporter_phone=body.reporter_phone,
         reporter_phone=body.reporter_phone,
         attachment_urls=body.attachment_urls,
         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
 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
 @pytest.fixture
 def local_storage_env(tmp_path, monkeypatch):
 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):
 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
     assert r.status_code == 200
     data = r.json()["data"]
     data = r.json()["data"]
     assert data["url"]
     assert data["url"]
     assert data["id"]
     assert data["id"]
     assert data["fileName"] == "a.png"
     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):
 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()
     outside = (base / ".." / "outside").resolve()
     assert not outside.exists()
     assert not outside.exists()
 
 
-    files = {"file": ("evil.png", b"evil-bytes", "image/png")}
+    files = {"file": ("evil.png", PNG, "image/png")}
     r = client.post(
     r = client.post(
         "/api/public/attachment/upload",
         "/api/public/attachment/upload",
         files=files,
         files=files,
@@ -43,3 +91,25 @@ def test_public_upload_rejects_path_traversal(client, local_storage_env):
     assert "detail" not in body
     assert "detail" not in body
     assert not outside.exists()
     assert not outside.exists()
     assert not any(base.rglob("*"))
     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"]