odysseus-dev/odysseus · error · HTTPException
str(e)
Error message
str(e)
What it means
HTTP 400 whose message is str(e) from validate_caldav_url in src/caldav_sync.py. That validator raises ValueError for: empty URL, scheme not http/https, missing host, credentials embedded in the URL, URL fragments, invalid port, blocked/localhost host, unresolvable hostname, or an IP failing the private/link-local SSRF checks. The route surfaces the exact reason verbatim, so the message text identifies which check failed.
Source
Thrown at routes/calendar_routes.py:872
}
@router.post("/config")
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 = {}
accounts = _get_caldav_accounts(owner)
if not (body.get("url") or "").strip():
_save_caldav_accounts(owner, [])
return {"ok": True, "cleared": True}
from src.caldav_sync import validate_caldav_url
try:
validated_url = validate_caldav_url(body.get("url", ""))
except ValueError as e:
raise HTTPException(400, str(e))
if accounts:
acc = dict(accounts[0])
else:
import uuid as _uuid
acc = {"id": str(_uuid.uuid4()), "label": "CalDAV"}
acc["url"] = validated_url
acc["username"] = (body.get("username") or "").strip()
if body.get("password"):
from src.secret_storage import encrypt
acc["password"] = encrypt(body["password"])
new_accounts = [acc] + (accounts[1:] if len(accounts) > 1 else [])
_save_caldav_accounts(owner, new_accounts)
return {"ok": True}
# ── CalDAV multi-account CRUD ─────────────────────────────────────────────
@router.get("/config/accounts")
async def list_caldav_accounts(request: Request):View on GitHub (pinned to f9235ebbf1)
Solutions
- Read the 400 body — it is the validator's exact message (e.g. 'CalDAV URL must start with http:// or https://').
- Use a plain https URL with host (and port if non-default); put credentials in the username/password fields, never in the URL.
- If targeting a private/LAN address, note the SSRF policy blocks it — expose CalDAV via a public hostname or adjust the deployment accordingly.
- Verify DNS resolves from the server: python -c "import socket; print(socket.gethostbyname('host'))".
Example fix
# before
{"url": "caldav.example.com/user/cal/"} # -> 400 'CalDAV URL must start with http:// or https://'
# after
{"url": "https://caldav.example.com/user/cal/", "username": "u", "password": "p"} Defensive patterns
Strategy: validation
Validate before calling
function prevalidateCalDavUrl(raw) {
const u = new URL(raw); // throws on garbage
if (!['http:','https:'].includes(u.protocol)) throw new Error('must start with http:// or https://');
if (u.username || u.password) throw new Error('credentials belong in their fields');
if (u.hash) throw new Error('no fragments');
return u.origin + u.pathname;
} Type guard
const isPlainHttpUrl = (s: string): boolean => {
try { const u = new URL(s); return (u.protocol === 'http:' || u.protocol === 'https:') && !u.username && !u.password && !u.hash; }
catch { return false; }
}; Try / catch
catch (e) { if (e.status === 400) { showCalDavError(e.message); /* message is the validator's exact reason */ } } Prevention
- Send scheme + host always; keep credentials in the dedicated fields.
- Expect SSRF guards to block private/localhost targets from server-side validation.
- Show the 400 body verbatim — it pinpoints the failed check.
- Remember an empty URL does not 400; it clears the CalDAV config.
When it happens
Trigger: POST the CalDAV config endpoint with e.g. 'caldav.example.com' (no scheme), 'https://user:pass@host/' (embedded credentials), 'https://host/#frag', an unresolvable hostname, or a private/loopback IP (SSRF protection). An empty/whitespace url instead clears accounts and returns {'cleared': true}, so this 400 only fires for non-empty invalid URLs.
Common situations: Typing a bare hostname without https://; pasting a URL copied from a client that includes credentials; home-Lab users pointing at 192.168.x.x or localhost (blocked by SSRF guards); DNS typos; self-hosted instances where the host genuinely does not resolve from the server.
Related errors
- Password is required
- detail
- d && d.detail ? d.detail : ('HTTP ' + res.status)
- result.detail || 'Failed to rename session'
- await res.text()
AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14).
Data as JSON: /api/errors/6e2b1fcf108fd872.
Report an issue: GitHub.