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
+81
View File
@@ -1,4 +1,8 @@
"""Tests for settings router (/api/settings)."""
import io
from pathlib import Path
from unittest.mock import patch
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
@@ -182,3 +186,80 @@ class TestUpdateSettingsValidation:
json={"some_future_key": "anything"},
)
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