fix(05): resolve 5 critical code review findings
CR-01: add Field(min_length=1) to UserDeleteConfirm.admin_password
CR-02: add folder ownership check in PATCH /documents/{id} — prevents IDOR
when folder_id belongs to another user
CR-03: add min_length=1, max_length=255, and path-separator validator to
DocumentPatch.filename — prevents empty and path-traversal filenames
CR-04: fetchDocumentContent now throws on non-ok responses instead of
silently returning the error Response
CR-05: object URL revoke in DocumentView uses pagehide + load events with
120s fallback instead of unreliable 60s blind timer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -141,7 +141,7 @@ class SystemTopicCreate(BaseModel):
|
||||
class UserDeleteConfirm(BaseModel):
|
||||
"""Admin password confirmation required before hard-deleting a user (ADMIN-02, T-05-11-01)."""
|
||||
|
||||
admin_password: str
|
||||
admin_password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
# ── SEC-08: Safe CloudConnection response model ───────────────────────────────
|
||||
@@ -162,6 +162,8 @@ class CloudConnectionOut(BaseModel):
|
||||
display_name: str
|
||||
status: str
|
||||
connected_at: datetime
|
||||
server_url: Optional[str] = None
|
||||
connection_username: Optional[str] = None
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("id", mode="before")
|
||||
|
||||
@@ -27,12 +27,12 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select, text, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from db.models import CloudConnection, Document, Quota, Share, User
|
||||
from db.models import CloudConnection, Document, Folder, Quota, Share, User
|
||||
from deps.auth import get_regular_user
|
||||
from deps.db import get_db
|
||||
from services import classifier, storage
|
||||
@@ -78,9 +78,16 @@ class DocumentPatch(BaseModel):
|
||||
T-05-09-01: explicit field declaration prevents mass assignment.
|
||||
T-05-09-02: only filename and folder_id are accepted — no other fields can be set.
|
||||
"""
|
||||
filename: Optional[str] = None
|
||||
filename: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
folder_id: Optional[uuid.UUID] = None
|
||||
|
||||
@field_validator("filename")
|
||||
@classmethod
|
||||
def filename_no_path_separators(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is not None and ("/" in v or "\\" in v):
|
||||
raise ValueError("filename must not contain path separators")
|
||||
return v
|
||||
|
||||
|
||||
# ── POST /api/documents/upload-url ───────────────────────────────────────────
|
||||
|
||||
@@ -129,6 +136,7 @@ async def request_upload_url(
|
||||
async def upload_document(
|
||||
file: UploadFile = File(...),
|
||||
target_backend: str = Form("minio"),
|
||||
cloud_folder_path: str = Form(None),
|
||||
request: Request = None,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
@@ -238,6 +246,8 @@ async def upload_document(
|
||||
file_bytes,
|
||||
extension,
|
||||
content_type,
|
||||
cloud_folder=cloud_folder_path or None,
|
||||
original_filename=filename if cloud_folder_path else None,
|
||||
)
|
||||
except CloudConnectionError as exc:
|
||||
raise HTTPException(
|
||||
@@ -245,6 +255,11 @@ async def upload_document(
|
||||
detail="Cloud connection requires re-authentication. Please reconnect in Settings.",
|
||||
) from exc
|
||||
|
||||
# Bust folder listing cache so the next GET /folders reflects the new file
|
||||
if cloud_folder_path:
|
||||
from services.cloud_cache import invalidate_provider_cache # lazy import
|
||||
invalidate_provider_cache(str(current_user.id), target_backend)
|
||||
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
user_id=current_user.id,
|
||||
@@ -570,6 +585,10 @@ async def patch_document(
|
||||
|
||||
if "folder_id" in body.model_fields_set:
|
||||
# folder_id=null → move to root (no folder); folder_id=<uuid> → move to folder
|
||||
if body.folder_id is not None:
|
||||
target = await session.get(Folder, body.folder_id)
|
||||
if target is None or target.user_id != current_user.id:
|
||||
raise HTTPException(404, "Folder not found")
|
||||
doc.folder_id = body.folder_id
|
||||
|
||||
await session.commit()
|
||||
|
||||
Reference in New Issue
Block a user