From 641752387b4b6694cd72d394d56396e0c5f95545 Mon Sep 17 00:00:00 2001 From: dxrpl Date: Tue, 27 Oct 2026 17:34:41 +0100 Subject: [PATCH] fix(calendar): answer 400 when a CalDAV config body is not a JSON object (#6903) --- routes/calendar_routes.py | 32 +++++++++++++-------------- tests/test_calendar_config_body.py | 35 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 tests/test_calendar_config_body.py diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py index aa84768fd..19c8fd6a7 100644 --- a/routes/calendar_routes.py +++ b/routes/calendar_routes.py @@ -80,6 +80,18 @@ def _require_user(request: Request) -> str: return FALLBACK_OWNER +async def _json_object_body(request: Request) -> dict: + """Parse a JSON object body. An unreadable body counts as empty; a body + that is valid JSON but not an object (a list, string or number) is a 400.""" + try: + body = await request.json() + except Exception: + return {} + if not isinstance(body, dict): + raise HTTPException(400, "Request body must be a JSON object") + return body + + def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal: cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first() if not cal: @@ -994,10 +1006,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter: async def save_config(request: Request): """Legacy single-account endpoint — upserts the first account.""" owner = _require_user(request) - try: - body = await request.json() - except Exception: - body = {} + body = await _json_object_body(request) accounts = _get_caldav_accounts(owner) if not (body.get("url") or "").strip(): _save_caldav_accounts(owner, []) @@ -1052,10 +1061,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter: """Add a new CalDAV account.""" import uuid as _uuid owner = _require_user(request) - try: - body = await request.json() - except Exception: - body = {} + body = await _json_object_body(request) from src.caldav_sync import validate_caldav_url try: url = validate_caldav_url(body.get("url", "")) @@ -1080,10 +1086,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter: async def update_caldav_account(account_id: str, request: Request): """Update an existing CalDAV account by id.""" owner = _require_user(request) - try: - body = await request.json() - except Exception: - body = {} + body = await _json_object_body(request) accounts = _get_caldav_accounts(owner) idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None) if idx is None: @@ -1124,10 +1127,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter: test an already-saved account. Falls back to the first saved account when nothing is provided.""" owner = _require_user(request) - try: - body = await request.json() - except Exception: - body = {} + body = await _json_object_body(request) url = (body.get("url") or "").strip() user = (body.get("username") or "").strip() pw = body.get("password") or "" diff --git a/tests/test_calendar_config_body.py b/tests/test_calendar_config_body.py new file mode 100644 index 000000000..04eb9bfbf --- /dev/null +++ b/tests/test_calendar_config_body.py @@ -0,0 +1,35 @@ +"""The CalDAV config routes answer 400 for a body that is JSON but not an object.""" +import asyncio +import json +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from routes.calendar_routes import _json_object_body + + +def _request(raw: bytes): + async def _json(): + return json.loads(raw) + return SimpleNamespace(json=_json) + + +def _parse(raw: bytes): + return asyncio.run(_json_object_body(_request(raw))) + + +def test_object_body_is_returned(): + assert _parse(b'{"url": "https://dav.example/"}') == {"url": "https://dav.example/"} + + +@pytest.mark.parametrize("raw", [b"", b"not json"]) +def test_unreadable_body_counts_as_empty(raw): + assert _parse(raw) == {} + + +@pytest.mark.parametrize("raw", [b"[]", b'"x"', b"5", b"null"]) +def test_non_object_body_is_a_400(raw): + with pytest.raises(HTTPException) as exc: + _parse(raw) + assert exc.value.status_code == 400