Sanitize calendar export filenames (#2840)

This commit is contained in:
Vykos
2026-06-05 10:18:09 +02:00
committed by GitHub
parent 46f128b9df
commit 2cae5a681d
3 changed files with 49 additions and 2 deletions

View File

@@ -1,6 +1,7 @@
"""Calendar routes — local SQLite-backed calendar CRUD.""" """Calendar routes — local SQLite-backed calendar CRUD."""
import logging import logging
import re
import uuid import uuid
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from typing import Optional, List from typing import Optional, List
@@ -100,6 +101,15 @@ def _ics_escape(text: str) -> str:
) )
def _safe_ics_filename(name: str) -> str:
"""Return a conservative .ics filename safe for Content-Disposition."""
stem = name if isinstance(name, str) else ""
stem = re.sub(r"[^A-Za-z0-9._-]", "_", stem).strip("._-")
if not stem:
stem = "calendar"
return f"{stem[:128]}.ics"
def _resolve_base_uid(uid: str) -> str: def _resolve_base_uid(uid: str) -> str:
"""Extract the base series UID from a compound occurrence UID. """Extract the base series UID from a compound occurrence UID.
@@ -1178,11 +1188,14 @@ def setup_calendar_routes() -> APIRouter:
lines.append("END:VCALENDAR") lines.append("END:VCALENDAR")
ics_data = "\r\n".join(lines) ics_data = "\r\n".join(lines)
safe_name = cal.name.replace(" ", "_").replace("/", "_") download_name = _safe_ics_filename(cal.name)
return Response( return Response(
content=ics_data, content=ics_data,
media_type="text/calendar", media_type="text/calendar",
headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'}, headers={
"Content-Disposition": f'attachment; filename="{download_name}"',
"X-Content-Type-Options": "nosniff",
},
) )
except HTTPException: except HTTPException:
raise raise

View File

@@ -324,3 +324,21 @@ def test_export_ics_rejects_cross_owner_calendar_at_route_boundary(monkeypatch):
assert exc.value.status_code == 404 assert exc.value.status_code == 404
assert not session.event_query.all_called assert not session.event_query.all_called
session.close.assert_called_once() session.close.assert_called_once()
def test_export_ics_sanitizes_calendar_name_for_download_header(monkeypatch):
calendar_routes = _import_calendar_routes(monkeypatch)
cal = _calendar("alice")
cal.name = 'Work\r\nX-Injected: yes";/..\\evil'
session = _FakeSession(calendars=[cal])
monkeypatch.setattr(calendar_routes, "SessionLocal", lambda: session)
export_ics = _route_endpoint(calendar_routes, "/export/{cal_id}", "GET")
response = asyncio.run(export_ics(_request(), cal_id="cal-target"))
assert (
response.headers["content-disposition"]
== 'attachment; filename="Work__X-Injected__yes___.._evil.ics"'
)
assert response.headers["x-content-type-options"] == "nosniff"
session.close.assert_called_once()

View File

@@ -23,3 +23,19 @@ def test_newlines_become_literal_backslash_n():
def test_empty_and_none_safe(): def test_empty_and_none_safe():
assert _esc()("") == "" assert _esc()("") == ""
assert _esc()(None) == "" assert _esc()(None) == ""
def test_safe_ics_filename_strips_header_metacharacters():
safe_filename = _import_calendar_helpers()._safe_ics_filename
assert (
safe_filename('Work\r\nX-Injected: yes";/..\\evil')
== "Work__X-Injected__yes___.._evil.ics"
)
def test_safe_ics_filename_falls_back_for_empty_names():
safe_filename = _import_calendar_helpers()._safe_ics_filename
assert safe_filename("////") == "calendar.ics"
assert safe_filename(None) == "calendar.ics"