Browse Source

fix(http): RFC 6266-encode Content-Disposition so non-ASCII filenames don't crash response (issue #1245)

  Reported by @1000Delta. The printer file download (and three sibling
  endpoints) raised UnicodeEncodeError: 'latin-1' codec can't encode
  characters... on any filename outside U+0000..U+00FF (Chinese,
  Japanese, Arabic, accented Latin), because the route pushed `filename`
  straight into Content-Disposition: attachment; filename="...".
  Starlette/uvicorn encodes response headers as latin-1, so the assignment
  crashed at write-time.

  New backend/app/utils/http.py::build_content_disposition emits both an
  ASCII-stripped legacy filename="..." fallback and an RFC 5987
  filename*=UTF-8''<percent-encoded> parameter. Every modern browser
  prefers the *= form, so the original Unicode filename round-trips
  through Save-As intact.

  Same shape was latent in three siblings and fixed in the same PR
  (no deferred follow-ups): archive QR endpoint (archive.print_name
  from 3MF metadata), project ZIP export (project.name — the existing
  isalnum() sanitiser passes non-ASCII through), and the PDF label
  streamer (latent today, callers ASCII-only but the helper hardens it).
maziggy 2 weeks ago
parent
commit
3f58fc74b4

File diff suppressed because it is too large
+ 1 - 0
CHANGELOG.md


+ 5 - 3
backend/app/api/routes/archives.py

@@ -27,6 +27,7 @@ from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.services.archive import ArchiveService
+from backend.app.utils.http import build_content_disposition
 from backend.app.utils.threemf_tools import (
 from backend.app.utils.threemf_tools import (
     extract_nozzle_mapping_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
     extract_project_filaments_from_3mf,
@@ -633,7 +634,7 @@ async def export_archives(
     return StreamingResponse(
     return StreamingResponse(
         io.BytesIO(file_bytes),
         io.BytesIO(file_bytes),
         media_type=content_type,
         media_type=content_type,
-        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+        headers={"Content-Disposition": build_content_disposition(filename)},
     )
     )
 
 
 
 
@@ -672,7 +673,7 @@ async def export_stats(
     return StreamingResponse(
     return StreamingResponse(
         io.BytesIO(file_bytes),
         io.BytesIO(file_bytes),
         media_type=content_type,
         media_type=content_type,
-        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+        headers={"Content-Disposition": build_content_disposition(filename)},
     )
     )
 
 
 
 
@@ -2343,10 +2344,11 @@ async def get_qrcode(
     pil_img.save(buffer, format="PNG")
     pil_img.save(buffer, format="PNG")
     buffer.seek(0)
     buffer.seek(0)
 
 
+    qr_filename = f"qr_{archive.print_name or archive_id}.png"
     return Response(
     return Response(
         content=buffer.getvalue(),
         content=buffer.getvalue(),
         media_type="image/png",
         media_type="image/png",
-        headers={"Content-Disposition": f'inline; filename="qr_{archive.print_name or archive_id}.png"'},
+        headers={"Content-Disposition": build_content_disposition(qr_filename, disposition="inline")},
     )
     )
 
 
 
 

+ 2 - 1
backend/app/api/routes/labels.py

@@ -30,6 +30,7 @@ from backend.app.models.spool import Spool
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services.label_renderer import LabelData, TemplateName, render_labels
 from backend.app.services.label_renderer import LabelData, TemplateName, render_labels
 from backend.app.services.spoolman import get_spoolman_client
 from backend.app.services.spoolman import get_spoolman_client
+from backend.app.utils.http import build_content_disposition
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -126,7 +127,7 @@ def _stream_pdf(pdf: bytes, filename: str) -> StreamingResponse:
         io.BytesIO(pdf),
         io.BytesIO(pdf),
         media_type="application/pdf",
         media_type="application/pdf",
         headers={
         headers={
-            "Content-Disposition": f'inline; filename="{filename}"',
+            "Content-Disposition": build_content_disposition(filename, disposition="inline"),
             "Content-Length": str(len(pdf)),
             "Content-Length": str(len(pdf)),
             # PDFs are deterministic per request; tell the browser not to cache
             # PDFs are deterministic per request; tell the browser not to cache
             # so re-printing after edits picks up the new data.
             # so re-printing after edits picks up the new data.

+ 2 - 1
backend/app/api/routes/printers.py

@@ -45,6 +45,7 @@ from backend.app.services.printer_manager import (
     supports_chamber_temp,
     supports_chamber_temp,
     supports_drying,
     supports_drying,
 )
 )
+from backend.app.utils.http import build_content_disposition
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -1057,7 +1058,7 @@ async def download_printer_file(
     return Response(
     return Response(
         content=data,
         content=data,
         media_type=content_type,
         media_type=content_type,
-        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+        headers={"Content-Disposition": build_content_disposition(filename)},
     )
     )
 
 
 
 

+ 2 - 1
backend/app/api/routes/projects.py

@@ -40,6 +40,7 @@ from backend.app.schemas.project import (
     ProjectUpdate,
     ProjectUpdate,
     TimelineEvent,
     TimelineEvent,
 )
 )
+from backend.app.utils.http import build_content_disposition
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -1651,7 +1652,7 @@ async def export_project(
     return StreamingResponse(
     return StreamingResponse(
         zip_buffer,
         zip_buffer,
         media_type="application/zip",
         media_type="application/zip",
-        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+        headers={"Content-Disposition": build_content_disposition(filename)},
     )
     )
 
 
 
 

+ 17 - 0
backend/app/utils/http.py

@@ -0,0 +1,17 @@
+"""HTTP response helpers."""
+
+from urllib.parse import quote
+
+
+def build_content_disposition(filename: str, disposition: str = "attachment") -> str:
+    """Build an RFC 6266-compliant Content-Disposition header value.
+
+    Starlette/uvicorn encodes response headers as latin-1, so any non-ASCII
+    character in a raw `filename="..."` parameter raises UnicodeEncodeError.
+    The fix is RFC 5987's `filename*=UTF-8''<percent-encoded>` form alongside
+    a stripped ASCII fallback in the legacy `filename="..."` parameter — every
+    modern browser prefers the `*` form when present.
+    """
+    ascii_fallback = filename.encode("ascii", "ignore").decode("ascii").strip(" ._-") or "download"
+    ascii_fallback = ascii_fallback.replace('"', "").replace("\\", "")
+    return f"{disposition}; filename=\"{ascii_fallback}\"; filename*=UTF-8''{quote(filename)}"

+ 48 - 0
backend/tests/integration/test_printers_api.py

@@ -4,6 +4,7 @@ Tests the full request/response cycle for /api/v1/printers/ endpoints.
 """
 """
 
 
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
+from urllib.parse import unquote
 
 
 import pytest
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
@@ -231,6 +232,53 @@ class TestPrintersAPI:
 
 
         assert response.status_code == 404
         assert response.status_code == 404
 
 
+    # ========================================================================
+    # File download endpoint — non-ASCII filename regression (#1245)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        ("filename", "ascii_fallback"),
+        [
+            ("龙泡泡石墩子_p2s_ok.gcode.3mf", "p2s_ok.gcode.3mf"),
+            ("こんにちは.gcode.3mf", "gcode.3mf"),
+            ("résumé.gcode.3mf", "rsum.gcode.3mf"),
+            ("مرحبا.gcode.3mf", "gcode.3mf"),
+            ("文件.3mf", "3mf"),
+            ("hello.3mf", "hello.3mf"),
+        ],
+    )
+    async def test_download_printer_file_non_ascii_filename(
+        self,
+        async_client: AsyncClient,
+        printer_factory,
+        filename: str,
+        ascii_fallback: str,
+        db_session,
+    ):
+        """Non-ASCII filenames must not crash header encoding (issue #1245)."""
+        printer = await printer_factory()
+        file_bytes = b"fake 3mf content"
+
+        with patch(
+            "backend.app.api.routes.printers.download_file_bytes_async",
+            new=AsyncMock(return_value=file_bytes),
+        ):
+            response = await async_client.get(
+                f"/api/v1/printers/{printer.id}/files/download",
+                params={"path": f"/cache/{filename}"},
+            )
+
+        assert response.status_code == 200
+        assert response.content == file_bytes
+
+        content_disposition = response.headers["content-disposition"]
+        assert f'filename="{ascii_fallback}"' in content_disposition
+        assert "filename*=UTF-8''" in content_disposition
+        encoded_name = content_disposition.split("filename*=UTF-8''", 1)[1]
+        assert unquote(encoded_name) == filename
+
     # ========================================================================
     # ========================================================================
     # Status endpoint
     # Status endpoint
     # ========================================================================
     # ========================================================================

+ 71 - 0
backend/tests/unit/test_http_utils.py

@@ -0,0 +1,71 @@
+"""Unit tests for backend.app.utils.http."""
+
+from urllib.parse import unquote
+
+import pytest
+
+from backend.app.utils.http import build_content_disposition
+
+
+@pytest.mark.parametrize(
+    ("filename", "expected_ascii_fallback"),
+    [
+        ("hello.gcode.3mf", "hello.gcode.3mf"),
+        ("龙泡泡石墩子_p2s_ok.gcode.3mf", "p2s_ok.gcode.3mf"),
+        ("こんにちは.gcode.3mf", "gcode.3mf"),
+        ("résumé.gcode.3mf", "rsum.gcode.3mf"),
+        ("مرحبا.gcode.3mf", "gcode.3mf"),
+        ("文件.3mf", "3mf"),
+        ("模型.gcode.3mf", "gcode.3mf"),
+        ("project_2026-05-08.zip", "project_2026-05-08.zip"),
+        ("___.zip", "zip"),
+        ("", "download"),
+    ],
+)
+def test_ascii_fallback_strips_non_ascii(filename: str, expected_ascii_fallback: str) -> None:
+    header = build_content_disposition(filename)
+    assert f'filename="{expected_ascii_fallback}"' in header
+
+
+@pytest.mark.parametrize(
+    "filename",
+    [
+        "龙泡泡石墩子_p2s_ok.gcode.3mf",
+        "こんにちは.gcode.3mf",
+        "résumé.gcode.3mf",
+        "مرحبا.gcode.3mf",
+        "文件.3mf",
+        "hello world (final).pdf",
+        "你好/世界.pdf",
+    ],
+)
+def test_filename_star_round_trips_to_original(filename: str) -> None:
+    header = build_content_disposition(filename)
+    assert "filename*=UTF-8''" in header
+    encoded = header.split("filename*=UTF-8''", 1)[1]
+    assert unquote(encoded) == filename
+
+
+def test_header_is_latin1_encodable() -> None:
+    """Starlette/uvicorn encodes response headers as latin-1 — the helper's
+    output MUST round-trip through latin-1 without raising."""
+    for filename in [
+        "龙泡泡石墩子_p2s_ok.gcode.3mf",
+        "こんにちは.gcode.3mf",
+        "résumé.gcode.3mf",
+        "مرحبا.gcode.3mf",
+        '"quoted"name.zip',
+        "back\\slash.zip",
+    ]:
+        header = build_content_disposition(filename)
+        header.encode("latin-1")
+
+
+def test_disposition_param_is_respected() -> None:
+    assert build_content_disposition("foo.pdf", disposition="inline").startswith("inline; ")
+    assert build_content_disposition("foo.pdf").startswith("attachment; ")
+
+
+def test_quotes_and_backslashes_stripped_from_ascii_fallback() -> None:
+    header = build_content_disposition('a"b\\c.pdf')
+    assert 'filename="abc.pdf"' in header

Some files were not shown because too many files changed in this diff