fix(upload): tipo da magic bytes, non da Content-Type dichiarato (#36 punto 2)

Il Content-Type del client non era verificato: un file "x.html" dichiarato
image/png veniva salvato e poi servito per estensione come text/html,
eseguito dal browser. Nuova detect_file_type() (PNG/JPEG/GIF/WebP/PDF) in
files.py, usata anche dal logo in settings.py; l'estensione salvata segue
sempre il tipo rilevato, mai il nome file dichiarato dal client. Il logo
rifiuta SVG a prescindere (puo' portare script). Upload letti a blocchi da
1MB, interrotti appena superano il limite invece di bufferizzare tutto
prima di controllare la dimensione.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:48:52 +02:00
parent bfe66a5710
commit 805f41c757
4 changed files with 219 additions and 41 deletions
+61 -18
View File
@@ -26,6 +26,39 @@ ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
ALLOWED_PDF_TYPE = "application/pdf" ALLOWED_PDF_TYPE = "application/pdf"
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE} ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE}
# Magic-byte signatures: the client-declared Content-Type is only what the
# browser claimed and cannot be trusted (#36 point 2 — a file named "x.html"
# declared image/png was stored and then served by extension as text/html,
# which a browser executes). SVG is intentionally not recognised: it is not
# in ALLOWED_TYPES and can itself carry script content.
_MAGIC_SIGNATURES: list[tuple[bytes, str, str]] = [
# (signature, mime type, canonical extension)
(b"\x89PNG\r\n\x1a\n", "image/png", ".png"),
(b"\xff\xd8\xff", "image/jpeg", ".jpg"),
(b"GIF87a", "image/gif", ".gif"),
(b"GIF89a", "image/gif", ".gif"),
(b"%PDF-", "application/pdf", ".pdf"),
]
# Read uploads in chunks so a declared-huge body is rejected as soon as it
# crosses the limit, instead of being buffered into memory in full first.
_UPLOAD_CHUNK_SIZE = 1024 * 1024
def detect_file_type(content: bytes) -> tuple[str, str] | None:
"""Identify a file's real type from its magic bytes.
Returns (mime_type, canonical_extension), or None when the content does
not match any type this system serves.
"""
for signature, mime, ext in _MAGIC_SIGNATURES:
if content.startswith(signature):
return mime, ext
# WebP: "RIFF" + 4-byte little-endian size + "WEBP"; the size varies.
if len(content) >= 12 and content[0:4] == b"RIFF" and content[8:12] == b"WEBP":
return "image/webp", ".webp"
return None
# Top-level folders nothing may DELETE through this router: statistics/reports are # Top-level folders nothing may DELETE through this router: statistics/reports are
# audit records, the logo is only ever replaced via POST /api/settings/logo. # audit records, the logo is only ever replaced via POST /api/settings/logo.
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/") _UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/")
@@ -178,25 +211,36 @@ async def upload_file(
"""Upload an image or PDF file. """Upload an image or PDF file.
Files are stored in uploads/{recipe_id}/{version_id}/ Files are stored in uploads/{recipe_id}/{version_id}/
Thumbnails are generated for images. Thumbnails are generated for images. The type is authoritatively
determined from the file's magic bytes, not the client-declared
Content-Type (#36 point 2): the stored extension always matches what was
actually detected, so it can never be served back as something else.
""" """
# Validate file type # Read in chunks, stopping as soon as the size limit is crossed instead
if not validate_file_type(file.content_type): # of buffering an oversized body into memory first.
max_bytes = settings.max_upload_size_mb * 1024 * 1024
buffer = bytearray()
while True:
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
if not chunk:
break
buffer.extend(chunk)
if len(buffer) > max_bytes:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {file.content_type} not allowed. Must be image or PDF.", detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
) )
content = bytes(buffer)
# Read file content
content = await file.read()
file_size = len(content) file_size = len(content)
# Validate file size # Validate file type from the actual bytes, not file.content_type
if not validate_file_size(file_size): detected = detect_file_type(content)
if detected is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB", detail="File type not allowed. Must be a recognised image or PDF.",
) )
detected_mime, detected_ext = detected
# Determine storage path # Determine storage path
if recipe_id is not None and version_id is not None: if recipe_id is not None and version_id is not None:
@@ -206,18 +250,17 @@ async def upload_file(
storage_dir.mkdir(parents=True, exist_ok=True) storage_dir.mkdir(parents=True, exist_ok=True)
# Sanitize the uploaded filename # Sanitize the uploaded filename, then force its extension to the one
# actually detected: a declared/renamed extension is not trusted either.
safe_filename = sanitize_filename(file.filename or "") safe_filename = sanitize_filename(file.filename or "")
base_name = Path(safe_filename).stem or "file"
safe_filename = f"{base_name}{detected_ext}"
# Generate unique filename if file already exists # Generate unique filename if file already exists
file_path = storage_dir / safe_filename file_path = storage_dir / safe_filename
counter = 1 counter = 1
while file_path.exists(): while file_path.exists():
name_parts = safe_filename.rsplit(".", 1) file_path = storage_dir / f"{base_name}_{counter}{detected_ext}"
if len(name_parts) == 2:
file_path = storage_dir / f"{name_parts[0]}_{counter}.{name_parts[1]}"
else:
file_path = storage_dir / f"{safe_filename}_{counter}"
counter += 1 counter += 1
# Write file # Write file
@@ -225,7 +268,7 @@ async def upload_file(
# Generate thumbnail for images # Generate thumbnail for images
thumbnail_path = None thumbnail_path = None
if file.content_type in ALLOWED_IMAGE_TYPES: if detected_mime in ALLOWED_IMAGE_TYPES:
thumbnail_dir = storage_dir / "thumbnails" thumbnail_dir = storage_dir / "thumbnails"
thumbnail_dir.mkdir(exist_ok=True) thumbnail_dir.mkdir(exist_ok=True)
thumbnail_path = thumbnail_dir / file_path.name thumbnail_path = thumbnail_dir / file_path.name
@@ -242,7 +285,7 @@ async def upload_file(
return { return {
"file_path": str(relative_path).replace("\\", "/"), "file_path": str(relative_path).replace("\\", "/"),
"thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None, "thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None,
"file_type": file.content_type, "file_type": detected_mime,
"file_size": file_size, "file_size": file_size,
} }
+26 -17
View File
@@ -1,6 +1,4 @@
"""System settings router - read/update settings, upload company logo.""" """System settings router - read/update settings, upload company logo."""
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings from src.backend.config import settings
from src.backend.database import get_db from src.backend.database import get_db
from src.backend.api.middleware.api_key import get_current_user, require_admin_user from src.backend.api.middleware.api_key import get_current_user, require_admin_user
from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, detect_file_type
from src.backend.models.orm.setting import SystemSetting from src.backend.models.orm.setting import SystemSetting
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
@@ -126,35 +125,45 @@ async def upload_company_logo(
"""Upload company logo. """Upload company logo.
Saves logo to uploads/logos/ and updates company_logo_path setting. Saves logo to uploads/logos/ and updates company_logo_path setting.
Only admins can upload the logo. Only admins can upload the logo. The type is determined from the file's
magic bytes, not the client-declared Content-Type (#36 point 2). SVG is
refused outright: it can itself carry script content, and there is no
safe way to serve it inline for a logo shown on every page.
""" """
# Validate file type (must be image) # Read in chunks, stopping as soon as the size limit is crossed instead
allowed_types = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"} # of buffering an oversized body into memory first.
if file.content_type not in allowed_types: max_bytes = settings.max_upload_size_mb * 1024 * 1024
buffer = bytearray()
chunk_size = 1024 * 1024
while True:
chunk = await file.read(chunk_size)
if not chunk:
break
buffer.extend(chunk)
if len(buffer) > max_bytes:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {file.content_type} not allowed. Must be an image.", detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
) )
content = bytes(buffer)
# Read file content
content = await file.read()
file_size = len(content) file_size = len(content)
# Validate file size # Validate file type from the actual bytes (SVG never matches: refused)
max_bytes = settings.max_upload_size_mb * 1024 * 1024 detected = detect_file_type(content)
if file_size > max_bytes: if detected is None or detected[0] not in ALLOWED_IMAGE_TYPES:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB", detail="File type not allowed. Must be a recognised image (SVG is not accepted).",
) )
_detected_mime, detected_ext = detected
# Create logos directory # Create logos directory
logos_dir = settings.upload_path / "logos" logos_dir = settings.upload_path / "logos"
logos_dir.mkdir(parents=True, exist_ok=True) logos_dir.mkdir(parents=True, exist_ok=True)
# Use a fixed filename for the company logo # Fixed filename for the company logo, extension forced to what was
file_extension = Path(file.filename).suffix # actually detected (not what the client claimed or the filename said).
logo_filename = f"company_logo{file_extension}" logo_filename = f"company_logo{detected_ext}"
logo_path = logos_dir / logo_filename logo_path = logos_dir / logo_filename
# Write file # Write file
+45
View File
@@ -65,6 +65,51 @@ class TestUploadFile:
assert data["file_type"] == "image/jpeg" assert data["file_type"] == "image/jpeg"
assert data["file_size"] > 0 assert data["file_size"] > 0
async def test_extension_forced_to_detected_type_ignores_filename(
self, client: AsyncClient, maker_user: User, tmp_path: Path
):
"""gitea#36 point 2: the stored extension follows the detected magic
bytes, not the client-supplied filename — even a misleading one."""
jpeg_bytes = b"\xff\xd8\xff\xe0\x00\x10JFIF" + b"\x00" * 20 + b"\xff\xd9"
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
mock_settings.max_upload_size_mb = 50
resp = await client.post(
"/api/files/upload",
headers=auth_headers(maker_user),
# Filename claims .txt; content is a real JPEG.
files={"file": ("photo.txt", io.BytesIO(jpeg_bytes), "text/plain")},
)
assert resp.status_code == 200
data = resp.json()
assert data["file_type"] == "image/jpeg"
assert data["file_path"].endswith(".jpg")
async def test_upload_declared_type_lies_about_html_content(
self, client: AsyncClient, maker_user: User, tmp_path: Path
):
"""gitea#36 point 2: a file whose bytes are HTML must be rejected even
when the client declares Content-Type: image/png and names it .png —
this is what previously let a stored file be served back as
text/html by extension and executed by the browser."""
html_bytes = b"<html><body><script>alert(1)</script></body></html>"
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
mock_settings.max_upload_size_mb = 50
resp = await client.post(
"/api/files/upload",
headers=auth_headers(maker_user),
files={"file": ("x.png", io.BytesIO(html_bytes), "image/png")},
)
assert resp.status_code == 400
assert not any(tmp_path.rglob("x.png"))
async def test_upload_invalid_type( async def test_upload_invalid_type(
self, client: AsyncClient, maker_user: User self, client: AsyncClient, maker_user: User
): ):
+81
View File
@@ -1,4 +1,8 @@
"""Tests for settings router (/api/settings).""" """Tests for settings router (/api/settings)."""
import io
from pathlib import Path
from unittest.mock import patch
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -182,3 +186,80 @@ class TestUpdateSettingsValidation:
json={"some_future_key": "anything"}, json={"some_future_key": "anything"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
class TestUploadLogo:
"""POST /api/settings/logo tests (gitea#36 point 2)."""
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
async def test_valid_png_accepted(
self, client: AsyncClient, admin_user: User, tmp_path: Path
):
with patch("src.backend.api.routers.settings.settings") as mock_settings:
mock_settings.upload_path = tmp_path
mock_settings.max_upload_size_mb = 50
resp = await client.post(
"/api/settings/logo",
headers=auth_headers(admin_user),
files={"file": ("logo.png", io.BytesIO(self._PNG_BYTES), "image/png")},
)
assert resp.status_code == 200
assert resp.json()["logo_path"].endswith(".png")
async def test_svg_refused_even_with_svg_content_type(
self, client: AsyncClient, admin_user: User, tmp_path: Path
):
"""SVG is refused outright, not just when it lies about its type."""
svg_bytes = b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
with patch("src.backend.api.routers.settings.settings") as mock_settings:
mock_settings.upload_path = tmp_path
mock_settings.max_upload_size_mb = 50
resp = await client.post(
"/api/settings/logo",
headers=auth_headers(admin_user),
files={"file": ("logo.svg", io.BytesIO(svg_bytes), "image/svg+xml")},
)
assert resp.status_code == 400
assert not any(tmp_path.rglob("*.svg"))
async def test_pdf_refused_for_logo(
self, client: AsyncClient, admin_user: User, tmp_path: Path
):
"""The logo slot only accepts images: files.py's PDF signature must
not slip through settings.py's narrower ALLOWED_IMAGE_TYPES check."""
pdf_bytes = b"%PDF-1.4\n" + b"\x00" * 20
with patch("src.backend.api.routers.settings.settings") as mock_settings:
mock_settings.upload_path = tmp_path
mock_settings.max_upload_size_mb = 50
resp = await client.post(
"/api/settings/logo",
headers=auth_headers(admin_user),
files={"file": ("logo.pdf", io.BytesIO(pdf_bytes), "application/pdf")},
)
assert resp.status_code == 400
async def test_html_disguised_as_png_refused(
self, client: AsyncClient, admin_user: User, tmp_path: Path
):
html_bytes = b"<html><body><script>alert(1)</script></body></html>"
with patch("src.backend.api.routers.settings.settings") as mock_settings:
mock_settings.upload_path = tmp_path
mock_settings.max_upload_size_mb = 50
resp = await client.post(
"/api/settings/logo",
headers=auth_headers(admin_user),
files={"file": ("logo.png", io.BytesIO(html_bytes), "image/png")},
)
assert resp.status_code == 400