Merge pull request #6904 from dxrpl/fix/calendar-config-object-body

fix(calendar): answer 400 when a CalDAV config body is not a JSON object (#6903)
This commit is contained in:
Alexandre Teixeira
2026-10-30 19:42:45 +00:00
committed by GitHub
2 changed files with 51 additions and 16 deletions
+16 -16
View File
@@ -80,6 +80,18 @@ def _require_user(request: Request) -> str:
return FALLBACK_OWNER 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: def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal:
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first() cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
if not cal: if not cal:
@@ -994,10 +1006,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
async def save_config(request: Request): async def save_config(request: Request):
"""Legacy single-account endpoint — upserts the first account.""" """Legacy single-account endpoint — upserts the first account."""
owner = _require_user(request) owner = _require_user(request)
try: body = await _json_object_body(request)
body = await request.json()
except Exception:
body = {}
accounts = _get_caldav_accounts(owner) accounts = _get_caldav_accounts(owner)
if not (body.get("url") or "").strip(): if not (body.get("url") or "").strip():
_save_caldav_accounts(owner, []) _save_caldav_accounts(owner, [])
@@ -1052,10 +1061,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
"""Add a new CalDAV account.""" """Add a new CalDAV account."""
import uuid as _uuid import uuid as _uuid
owner = _require_user(request) owner = _require_user(request)
try: body = await _json_object_body(request)
body = await request.json()
except Exception:
body = {}
from src.caldav_sync import validate_caldav_url from src.caldav_sync import validate_caldav_url
try: try:
url = validate_caldav_url(body.get("url", "")) 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): async def update_caldav_account(account_id: str, request: Request):
"""Update an existing CalDAV account by id.""" """Update an existing CalDAV account by id."""
owner = _require_user(request) owner = _require_user(request)
try: body = await _json_object_body(request)
body = await request.json()
except Exception:
body = {}
accounts = _get_caldav_accounts(owner) accounts = _get_caldav_accounts(owner)
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None) idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
if idx is 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 test an already-saved account. Falls back to the first saved account
when nothing is provided.""" when nothing is provided."""
owner = _require_user(request) owner = _require_user(request)
try: body = await _json_object_body(request)
body = await request.json()
except Exception:
body = {}
url = (body.get("url") or "").strip() url = (body.get("url") or "").strip()
user = (body.get("username") or "").strip() user = (body.get("username") or "").strip()
pw = body.get("password") or "" pw = body.get("password") or ""
+35
View File
@@ -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