- Remove dead kitchen->KDS internal API (api/internal.py, verify_internal_secret) — KDS reads kitchen_db directly (E16), nothing ever called this endpoint - Add expires_at to dispute_attachments; public attachment links now expire after 30 days instead of staying valid forever (A4) - Add services/upload_validation.py: sniff real file content via python-magic instead of trusting the client-supplied Content-Type header, plus a 20MB cap. Applied across invoices/logbook/food_flags/credit_notes/disputes upload endpoints (A5) — disputes previously had no file-type check at all - Fix nginx client_max_body_size drift (800m -> the plan's intended 20m) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
40 lines
1.4 KiB
Python
40 lines
1.4 KiB
Python
"""
|
|
Shared upload validation — sniffs the real file content (libmagic) rather
|
|
than trusting the client-supplied Content-Type header, and enforces a size
|
|
cap. See port log A5: the archive only checked `file.content_type`, which is
|
|
attacker-controlled and proves nothing about what's actually in the body.
|
|
"""
|
|
import magic
|
|
from fastapi import HTTPException, UploadFile
|
|
|
|
DEFAULT_MAX_BYTES = 20 * 1024 * 1024 # 20 MB — matches nginx client_max_body_size
|
|
|
|
|
|
async def read_and_validate_upload(
|
|
file: UploadFile,
|
|
allowed_mimes: set[str],
|
|
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
) -> bytes:
|
|
"""
|
|
Read an UploadFile fully, verify its sniffed MIME type is in
|
|
`allowed_mimes`, and enforce `max_bytes`. Returns the file bytes for the
|
|
caller to save/process. Raises HTTPException(400) on any failure.
|
|
"""
|
|
content = await file.read()
|
|
|
|
if not content:
|
|
raise HTTPException(status_code=400, detail="Empty file")
|
|
if len(content) > max_bytes:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"File too large — max {max_bytes // (1024 * 1024)}MB",
|
|
)
|
|
|
|
sniffed = magic.from_buffer(content, mime=True)
|
|
if sniffed not in allowed_mimes:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"File content doesn't match an allowed type (detected: {sniffed})",
|
|
)
|
|
|
|
return content
|