Sanitize calendar export filenames (#2840)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user