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."""
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

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 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()

View File

@@ -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"