{"record":{"id":"4639a9304d490ea2","repo":"BerriAI/litellm","slug":"f-non-admin-users-cannot-modify-field-on-thei","errorCode":null,"errorMessage":"f\"Non-admin users cannot modify '{_field}' on their own record. Contact your proxy admin.\"","messagePattern":"f\"Non-admin users cannot modify '(.+?)' on their own record\\. Contact your proxy admin\\.\"","errorType":"http","errorClass":"HTTPException","httpStatus":403,"severity":"error","filePath":"litellm/proxy/management_endpoints/internal_user_endpoints.py","lineNumber":1398,"sourceCode":"    # Prevent budget self-escalation (GHSA-wvg4-6222-3q4r): non-admin callers\n    # must not be able to raise their own budget/spend fields.\n    # can_user_call_user_update() already restricts non-admins to self-updates,\n    # so this guard only fires for self-escalation attempts.\n    _target_user_id: Final = user_request.user_id or (\n        getattr(existing_user_row, \"user_id\", None) if existing_user_row is not None else None\n    )\n    _is_self_update: Final = _target_user_id is not None and user_api_key_dict.user_id == _target_user_id\n    if _is_self_update and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value:\n        # object_permission is a CEILING on what this human may reach, so a self-write is an\n        # escalation path: sending an empty grant list means \"no restriction\" and would lift a\n        # restriction an admin placed on them. Checked against the fields the caller actually SENT,\n        # because `_update_internal_user_params` drops empty values, and `object_permission: {}` is\n        # precisely the clear-my-own-ceiling case this must refuse.\n        _sent_fields: Final = user_request.fields_set() if hasattr(user_request, \"fields_set\") else set()\n        _protected_fields: Final = (\"max_budget\", \"soft_budget\", \"spend\", \"object_permission\")\n        for _field in _protected_fields:\n            if _field in non_default_values or _field in _sent_fields:\n                raise HTTPException(\n                    status_code=403,\n                    detail={\n                        \"error\": f\"Non-admin users cannot modify '{_field}' on their own record. Contact your proxy admin.\"\n                    },\n                )\n\n    existing_metadata: Final = (\n        cast(dict, getattr(existing_user_row, \"metadata\", {}) or {}) if existing_user_row is not None else {}\n    )\n\n    non_default_values = prepare_metadata_fields(\n        data=user_request,\n        non_default_values=non_default_values,\n        existing_metadata=existing_metadata or {},\n    )\n\n    # Reject NaN/±inf spend before it can reach the DB / spend counter.\n    validate_finite_spend(non_default_values.get(\"spend\"))","sourceCodeStart":1380,"sourceCodeEnd":1416,"githubUrl":"https://github.com/BerriAI/litellm/blob/77b7c6c40c0c5aa5fbcb1d6a1825ac39ca8829b8/litellm/proxy/management_endpoints/internal_user_endpoints.py#L1380-L1416","documentation":"Self-update escalation guard on /user/update: when the caller's key user_id equals the target user and the caller is not proxy_admin, LiteLLM rejects writes to max_budget, soft_budget, spend, and object_permission with 403. object_permission is a ceiling an admin placed on the user, so sending object_permission: {} (empty) would lift that restriction - the check therefore inspects the fields the caller actually sent (fields_set), not the cleaned payload, and even an empty object_permission trips it.","triggerScenarios":"Non-admin POST /user/update targeting their own user with any of max_budget, soft_budget, spend, or object_permission present in the body - including the empty-object case {\"object_permission\": {}} meant to 'clear' restrictions.","commonSituations":"Profile UIs that forward the entire user object back on save; users trying to raise their own budget or reset their spend to 0; clients syncing full state including server-managed grant lists.","solutions":["Remove the four protected fields from self-update payloads","Ask a proxy admin (or use an admin key) to change budgets, spend, or object permissions","If you keep the full user object client-side, strip server-owned fields before posting"],"exampleFix":"# before: self, non-admin\nPOST /user/update {\"user_id\": \"me\", \"user_alias\": \"Me\", \"max_budget\": 999}  # 403\n\n# after\nPOST /user/update {\"user_id\": \"me\", \"user_alias\": \"Me\"}  # 200","handlingStrategy":"validation","validationCode":"PROTECTED = {\"max_budget\", \"soft_budget\", \"spend\", \"object_permission\"}\n\ndef sanitize_self_update(payload: dict, key_role: str, key_user_id: str, target_user_id: str | None) -> dict:\n    if key_role != \"proxy_admin\" and target_user_id == key_user_id:\n        return {k: v for k, v in payload.items() if k not in PROTECTED}\n    return payload","typeGuard":"type SelfSafeUpdate = Omit<Record<string, unknown>, \"max_budget\" | \"soft_budget\" | \"spend\" | \"object_permission\">;\nfunction isSelfSafeUpdate(payload: Record<string, unknown>): boolean {\n  return ![\"max_budget\", \"soft_budget\", \"spend\", \"object_permission\"].some(k => k in payload);\n}","tryCatchPattern":"except requests.HTTPError as e:\n    body = e.response.text if e.response is not None else \"\"\n    if e.response is not None and e.response.status_code == 403 and \"own record\" in body:\n        # strip protected fields and retry, or route the change to an admin\n        ...","preventionTips":["Send only the fields the user actually changed, never the full user object","Keep budget/spend/grant changes in an admin-only code path","Treat object_permission as server-owned state; never echo it back from clients"],"tags":["litellm","rbac","self-update","budget","privilege-escalation"],"backgroundTag":"self-privilege-escalation-blocked","analyzedSha":"77b7c6c40c0c5aa5fbcb1d6a1825ac39ca8829b8","analyzedAt":"2026-08-18T11:44:31.656Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}