diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py index 80bbe4d..7e1523a 100644 --- a/routes/calendar_routes.py +++ b/routes/calendar_routes.py @@ -1,6 +1,7 @@ """Calendar routes — local SQLite-backed calendar CRUD.""" import logging +import re import uuid from datetime import datetime, date, timedelta 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: """Extract the base series UID from a compound occurrence UID. @@ -1178,11 +1188,14 @@ def setup_calendar_routes() -> APIRouter: lines.append("END:VCALENDAR") ics_data = "\r\n".join(lines) - safe_name = cal.name.replace(" ", "_").replace("/", "_") + download_name = _safe_ics_filename(cal.name) return Response( content=ics_data, 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: raise diff --git a/tests/test_calendar_owner_scope.py b/tests/test_calendar_owner_scope.py index 4e66eb0..aa83d38 100644 --- a/tests/test_calendar_owner_scope.py +++ b/tests/test_calendar_owner_scope.py @@ -324,3 +324,21 @@ def test_export_ics_rejects_cross_owner_calendar_at_route_boundary(monkeypatch): assert exc.value.status_code == 404 assert not session.event_query.all_called 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() diff --git a/tests/test_ics_escape.py b/tests/test_ics_escape.py index bc9321e..e22dee5 100644 --- a/tests/test_ics_escape.py +++ b/tests/test_ics_escape.py @@ -23,3 +23,19 @@ def test_newlines_become_literal_backslash_n(): def test_empty_and_none_safe(): assert _esc()("") == "" 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"