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:
@@ -65,6 +65,51 @@ class TestUploadFile:
|
||||
assert data["file_type"] == "image/jpeg"
|
||||
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(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user