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:
curo1305
2026-05-30 11:51:54 +02:00
parent 9935c06aab
commit 34f012b4e8
4 changed files with 46 additions and 8 deletions
+4 -2
View File
@@ -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")
+22 -3
View File
@@ -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()
+11
View File
@@ -96,6 +96,14 @@ export function confirmUpload(documentId) {
return request(`/api/documents/${documentId}/confirm`, { method: 'POST' })
}
export function uploadToCloud(file, provider, folderPath) {
const form = new FormData()
form.append('file', file)
form.append('target_backend', provider)
if (folderPath) form.append('cloud_folder_path', folderPath)
return request('/api/documents/upload', { method: 'POST', body: form })
}
// ── Topics ───────────────────────────────────────────────────────────────────
export function listTopics() {
@@ -413,6 +421,9 @@ export async function fetchDocumentContent(docId, options = {}) {
}
}
if (!res.ok) {
throw new Error(`Failed to fetch document content: ${res.status}`)
}
return res
}
+9 -3
View File
@@ -173,9 +173,15 @@ async function openPdf() {
}
const blob = await res.blob()
const objectUrl = URL.createObjectURL(blob)
window.open(objectUrl, '_blank')
// Revoke after a delay to allow the new tab to load the content
setTimeout(() => URL.revokeObjectURL(objectUrl), 60000)
const tab = window.open(objectUrl, '_blank')
// Revoke once the new tab has loaded, or after 120s as a fallback.
// Also revoke if the user navigates away from this page before the tab loads.
const revoke = () => URL.revokeObjectURL(objectUrl)
const timer = setTimeout(revoke, 120000)
window.addEventListener('pagehide', revoke, { once: true })
if (tab) {
tab.addEventListener('load', () => { clearTimeout(timer); revoke() }, { once: true })
}
} catch (err) {
console.error('Failed to open document:', err)
}