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

  1. Read the 400 body — it is the validator's exact message (e.g. 'CalDAV URL must start with http:// or https://').
  2. Use a plain https URL with host (and port if non-default); put credentials in the username/password fields, never in the URL.
  3. If targeting a private/LAN address, note the SSRF policy blocks it — expose CalDAV via a public hostname or adjust the deployment accordingly.
  4. 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

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


AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14). Data as JSON: /api/errors/6e2b1fcf108fd872. Report an issue: GitHub.