{"record":{"id":"6e2b1fcf108fd872","repo":"odysseus-dev/odysseus","slug":"str-e","errorCode":null,"errorMessage":"str(e)","messagePattern":"str\\(e\\)","errorType":"http","errorClass":"HTTPException","httpStatus":400,"severity":"error","filePath":"routes/calendar_routes.py","lineNumber":872,"sourceCode":"        }\n\n    @router.post(\"/config\")\n    async def save_config(request: Request):\n        \"\"\"Legacy single-account endpoint — upserts the first account.\"\"\"\n        owner = _require_user(request)\n        try:\n            body = await request.json()\n        except Exception:\n            body = {}\n        accounts = _get_caldav_accounts(owner)\n        if not (body.get(\"url\") or \"\").strip():\n            _save_caldav_accounts(owner, [])\n            return {\"ok\": True, \"cleared\": True}\n        from src.caldav_sync import validate_caldav_url\n        try:\n            validated_url = validate_caldav_url(body.get(\"url\", \"\"))\n        except ValueError as e:\n            raise HTTPException(400, str(e))\n        if accounts:\n            acc = dict(accounts[0])\n        else:\n            import uuid as _uuid\n            acc = {\"id\": str(_uuid.uuid4()), \"label\": \"CalDAV\"}\n        acc[\"url\"] = validated_url\n        acc[\"username\"] = (body.get(\"username\") or \"\").strip()\n        if body.get(\"password\"):\n            from src.secret_storage import encrypt\n            acc[\"password\"] = encrypt(body[\"password\"])\n        new_accounts = [acc] + (accounts[1:] if len(accounts) > 1 else [])\n        _save_caldav_accounts(owner, new_accounts)\n        return {\"ok\": True}\n\n    # ── CalDAV multi-account CRUD ─────────────────────────────────────────────\n\n    @router.get(\"/config/accounts\")\n    async def list_caldav_accounts(request: Request):","sourceCodeStart":854,"sourceCodeEnd":890,"githubUrl":"https://github.com/odysseus-dev/odysseus/blob/f9235ebbf13f693a6fd29ce70b097f6ec83705bf/routes/calendar_routes.py#L854-L890","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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'))\"."],"exampleFix":"# before\n{\"url\": \"caldav.example.com/user/cal/\"}   # -> 400 'CalDAV URL must start with http:// or https://'\n\n# after\n{\"url\": \"https://caldav.example.com/user/cal/\", \"username\": \"u\", \"password\": \"p\"}","handlingStrategy":"validation","validationCode":"function prevalidateCalDavUrl(raw) {\n  const u = new URL(raw);                      // throws on garbage\n  if (!['http:','https:'].includes(u.protocol)) throw new Error('must start with http:// or https://');\n  if (u.username || u.password) throw new Error('credentials belong in their fields');\n  if (u.hash) throw new Error('no fragments');\n  return u.origin + u.pathname;\n}","typeGuard":"const isPlainHttpUrl = (s: string): boolean => {\n  try { const u = new URL(s); return (u.protocol === 'http:' || u.protocol === 'https:') && !u.username && !u.password && !u.hash; }\n  catch { return false; }\n};","tryCatchPattern":"catch (e) { if (e.status === 400) { showCalDavError(e.message); /* message is the validator's exact reason */ } }","preventionTips":["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."],"tags":["validation","http-400","caldav","ssrf","url-parsing"],"backgroundTag":null,"analyzedSha":"f9235ebbf13f693a6fd29ce70b097f6ec83705bf","analyzedAt":"2026-08-14T21:47:48.359Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}