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