CVE-2026-43891: Changedetection.io Changedetection.io Has An Arbitrary Local Vulnerability

HIGH
Advisory Identifier: GHSA-8757-69j2-hx56
CVSS 7.5
CWE-73
Published
Advisory Summary

CVE-2026-43891 is a HIGH (CVSS 7.5) changedetection.io has an arbitrary local vulnerability (CWE-73: External Control of File Name or Path) affecting Changedetection.io libraries in the PyPI ecosystem. The issue involves changedetection.io has an arbitrary local file read via a crafted backup restore. Remediation is available by upgrading affected packages to version 0.55.1.

CVE-2026-43891 at a Glance

CVE Identifier CVE-2026-43891
GHSA Identifier GHSA-8757-69j2-hx56
Severity HIGH
CVSS v3.1 Base Score 7.5
CWE Classification CWE-73: External Control of File Name or Path
Affected Product Changedetection.io
Package Ecosystem PyPI
Published Date
Last Updated

Security Impact

CVSS: 7.5

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity None (I:N)
Availability None (A:N)

How to Fix CVE-2026-43891

To remediate this vulnerability, upgrade all affected Changedetection.io dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

changedetection.io fixed: 0.55.1
pip install changedetection.io>=0.55.1
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

pip show changedetection.io

How to Check if You Are Affected

To determine whether your Python environment is affected by CVE-2026-43891, inspect your installed packages using pip:

pip show changedetection.io

Analyze the terminal output to locate any version of Changedetection.io in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.55.1), your application is affected.

Note: The pip show changedetection.io command checks currently installed packages in your active Python environment.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-73: External Control of File Name or Path, where the product allows user input to control or influence paths or file names that are used in filesystem operations. In Changedetection.io, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### Details The vulnerability is caused by trusting attacker-controlled snapshot paths restored from backup files. The vulnerable flow starts in the backup restore logic. When a backup ZIP is restored, the application extracts the archive and copies each restored watch UUID directory directly into the live datastore using `shutil.copytree(entry.path, dst_dir)`. This preserves attacker-controlled files inside the restored watch directory, including `history.txt`. Relevant code: - [changedetectionio/blueprint/backups/restore.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/blueprint/backups/restore.py#L132) - [changedetectionio/blueprint/backups/restore.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/blueprint/backups/restore.py#L141) After restore, the application parses `history.txt` in the watch `history` property. This is the core trust-boundary issue. Relevant code: - [changedetectionio/model/Watch.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/model/Watch.py#L460) - [changedetectionio/model/Watch.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/model/Watch.py#L471) - [changedetectionio/model/Watch.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/model/Watch.py#L478) The relevant logic is effectively: ```python if os.sep not in v and '/' not in v and '\\' not in v: v = os.path.join(self.data_dir, v) else: snapshot_fname = os.path.basename(v) proposed_new_path = os.path.join(self.data_dir, snapshot_fname) if not os.path.exists(v) and os.path.exists(proposed_new_path): v = proposed_new_path ``` This has the following security consequence: - If the `history.txt` value is only a filename, it is resolved safely under `self.data_dir`. - If the value contains path separators, it is treated as a path reference rather than a watch-local snapshot name. - If that external path already exists, it is preserved unchanged. As a result, a malicious restored `history.txt` entry such as: `1776969105,/etc/passwd` will be accepted if the referenced file exists and is readable by the application process. The second vulnerable step is in `get_history_snapshot()`. Once the untrusted path has been accepted into the watch history, the application reads the resolved path directly without enforcing that it remains inside the watch directory. Relevant code: - [changedetectionio/model/Watch.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/model/Watch.py#L554) That function eventually performs direct file reads such as: ```python with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: return f.read() ``` The third step is reachability. The trusted history entry is consumed by both the Preview UI and the watch history API. Relevant code: - [changedetectionio/blueprint/ui/preview.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/blueprint/ui/preview.py#L77) - [changedetectionio/api/Watch.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/api/Watch.py#L263) In the Preview flow, the application selects the latest history timestamp and calls: ```python content = watch.get_history_snapshot(timestamp=timestamp) ``` In the API flow, the application also calls: ```python content = watch.get_history_snapshot(timestamp=timestamp) ``` This creates the following end-to-end exploit chain: 1. An attacker supplies a crafted backup ZIP. 2. The restore process preserves attacker-controlled `history.txt`. 3. The `history.txt` parser accepts an absolute or out-of-directory path if that path exists. 4. Preview or the history API dereferences the stored path directly. 5. The application returns the contents of the targeted local file. The root cause is that imported history entries are treated as trusted filesystem paths instead of being restricted to safe basenames under `watch.data_dir`. ### PoC The following proof of concept demonstrates the end-to-end exploit chain. It assumes the attacker has gained access to the backup restore functionality to upload the crafted archive. 1. Create a normal watch in the UI, for example: ```text https://example.com ``` 2. Trigger at least one successful check so the watch generates a valid history entry and can later be included in a backup. <img width="1230" height="587" alt="image" src="https://github.com/user-attachments/assets/a76302c9-48d6-4aa3-9bfc-0ba2fdb31156" /> 3. Go to the `Backups` section and create a backup archive. <img width="1232" height="390" alt="image" src="https://github.com/user-attachments/assets/8fef0444-d8f0-4db6-be75-04fa7db2fec8" /> 4. Extract the backup archive and identify the watch UUID directory that contains the target watch's `watch.json`. For example: ```text 5db3d3d8-71e6-4db2-a81e-e1f0445c3e47 ``` 5. Open that watch directory and edit `history.txt`. 6. Replace the latest history entry with a path to an existing local file that is readable by the application process. For example: ```text 1776969188,/etc/passwd ``` If the timestamp differs in the extracted backup, keep the original latest timestamp and only replace the filename/path portion. Example: ```text 1776969188,742215043ff9be7e635f05e680ff9b11.txt ``` becomes: ```text 1776969188,/etc/passwd ``` <img width="1139" height="366" alt="image" src="https://github.com/user-attachments/assets/07277b36-4747-4181-82d1-523385b40de3" /> 7. Repack the backup so that the UUID directories are located at the root of the ZIP archive. Important: - Do not add an extra parent directory layer when repacking. - The archive root should contain directories such as: ```text <watch-uuid>/ <group-uuid>/ changedetection.json url-list.txt ``` <img width="986" height="353" alt="image" src="https://github.com/user-attachments/assets/21b30368-2cb5-4b88-9055-79d5bf06ad48" /> 8. In the UI, restore the modified backup and enable replacement of existing watches with the same UUID. <img width="1229" height="700" alt="image" src="https://github.com/user-attachments/assets/79abd9d2-8b2d-4e08-abf3-3b63238a8055" /> 9. After restore completes, open `Preview` for the restored watch. 10. The application will read the attacker-controlled path from `history.txt` and display the contents of the referenced local file instead of the original watch snapshot. Observed result: - The Preview page returns the content of the attacker-selected local file. Expected result: - The application should reject absolute paths or out-of-directory paths restored from `history.txt`. - Snapshot history should be restricted to files within the watch's own data directory. Optional API verification: - The same issue can also be confirmed through the watch history API by requesting the modified timestamp after restore. - The API returns the same file content because it also calls `watch.get_history_snapshot(timestamp=timestamp)` on the trusted history entry. <img width="1233" height="545" alt="image" src="https://github.com/user-attachments/assets/a2814a5a-2cdd-49a1-916b-c956dd5fda1f" /> ### Impact This is an arbitrary local file disclosure vulnerability reachable through malicious backup restore content. Who is impacted: - Deployments where the application process has read access to sensitive local system files. - Docker or host-mounted environments where secrets, config files, or operational artifacts are explicitly readable by the service. What can be exposed: - **Arbitrary System Files:** Core operating system files (e.g., `/etc/passwd`, `/proc/self/environ`), system-level configurations, and host metrics. - **Application Data:** Internal records and files residing under the /datastore directory. - **Secrets & Artifacts:** Application-local configuration files, API tokens, database credentials, and other sensitive artifacts accessible to the application process. By accessing the backup restore functionality and importing a crafted archive, an attacker can exploit the application's fail-open path validation. The confidentiality impact is exceptionally high because, once the payload is ingested, the application can be manipulated to disclose arbitrary local system files and highly sensitive environment variables directly through standard UI or API responses. ### Recommendation The application should treat all paths restored from `history.txt` as untrusted input. The root cause is in [changedetectionio/model/Watch.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/model/Watch.py#L460), where values containing path separators are currently accepted as filesystem paths and preserved if the referenced file already exists. The fix should be: 1. Never trust absolute or external paths from `history.txt`. 2. Normalize every history entry to `os.path.basename(v)`. 3. Join the normalized filename to `self.data_dir`. 4. Skip the entry if the resolved file does not exist inside the watch directory. Suggested code change: ```python snapshot_fname = os.path.basename(v.strip()) resolved_path = os.path.join(self.data_dir, snapshot_fname) if not os.path.exists(resolved_path): logger.warning( f"Skipping unsafe or missing history entry for {self.get('uuid')}: {v!r}" ) continue tmp_history[k] = resolved_path ``` This ensures restored history entries can only reference files inside the watch's own data directory and prevents arbitrary local file reads through Preview or the history API.

CVE-2026-43891 Affected Changedetection.io Versions

1 component(s)
Fixed Versions:
0.55.1
Product / Package Ecosystem Affected Versions Fixed Version
changedetection.io
PyPI
< 0.55.1
0.55.1

References

Frequently Asked Questions

What is CVE-2026-43891?
CVE-2026-43891 is a security vulnerability in Changedetection.io involving changedetection.io has an Arbitrary Local File Read via a crafted backup restore under CWE-73: External Control of File Name or Path.
Which Changedetection.io versions are affected?
Affected versions include releases prior to 0.55.1 across affected packages: changedetection.io.
What version fixes CVE-2026-43891?
Upgrading to 0.55.1 (or the latest patched release for your respective branch) resolves the vulnerability.
Is changedetection.io affected?
Yes, changedetection.io is affected in releases prior to 0.55.1. Upgrade your dependency to 0.55.1 or later.
What is the CVSS score of CVE-2026-43891?
CVE-2026-43891 has a CVSS v3.1 base score of 7.5 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N.
What CWE is associated with CVE-2026-43891?
CVE-2026-43891 is classified under CWE-73: External Control of File Name or Path (The product allows user input to control or influence paths or file names that are used in filesystem operations.).
How can I check PyPI dependencies for CVE-2026-43891?
Run the dependency tree command: 'pip show changedetection.io'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-8757-69j2-hx56",
  "modified" : "2026-06-05T14:31:27Z",
  "published" : "2026-05-05T21:16:21Z",
  "aliases" : [ "CVE-2026-43891" ],
  "summary" : "changedetection.io has an Arbitrary Local File Read via a crafted backup restore",
  "details" : "### Details\nThe vulnerability is caused by trusting attacker-controlled snapshot paths restored from backup files.\n\nThe vulnerable flow starts in the backup restore logic. When a backup ZIP is restored, the application extracts the archive and copies each restored watch UUID directory directly into the live datastore using `shutil.copytree(entry.path, dst_dir)`. This preserves attacker-controlled files inside the restored watch directory, including `history.txt`.\n\nRelevant code:\n- [changedetectionio/blueprint/backups/restore.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/blueprint/backups/restore.py#L132)\n- [changedetectionio/blueprint/backups/restore.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/blueprint/backups/restore.py#L141)\n\nAfter restore, the application parses `history.txt` in the watch `history` property. This is the core trust-boundary issue.\n\nRelevant code:\n- [changedetectionio/model/Watch.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/model/Watch.py#L460)\n- [changedetectionio/model/Watch.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/model/Watch.py#L471)\n- [changedetectionio/model/Watch.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/model/Watch.py#L478)\n\nThe relevant logic is effectively:\n\n```python\nif os.sep not in v and '/' not in v and '\\\\' not in v:\n    v = os.path.join(self.data_dir, v)\nelse:\n    snapshot_fname = os.path.basename(v)\n    proposed_new_path = os.path.join(self.data_dir, snapshot_fname)\n    if not os.path.exists(v) and os.path.exists(proposed_new_path):\n        v = proposed_new_path\n```\n\nThis has the following security consequence:\n\n- If the `history.txt` value is only a filename, it is resolved safely under `self.data_dir`.\n- If the value contains path separators, it is treated as a path reference rather than a watch-local snapshot name.\n- If that external path already exists, it is preserved unchanged.\n\nAs a result, a malicious restored `history.txt` entry such as:\n\n`1776969105,/etc/passwd`\n\nwill be accepted if the referenced file exists and is readable by the application process.\n\nThe second vulnerable step is in `get_history_snapshot()`. Once the untrusted path has been accepted into the watch history, the application reads the resolved path directly without enforcing that it remains inside the watch directory.\n\nRelevant code:\n- [changedetectionio/model/Watch.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/model/Watch.py#L554)\n\nThat function eventually performs direct file reads such as:\n\n```python\nwith open(filepath, 'r', encoding='utf-8', errors='ignore') as f:\n    return f.read()\n```\n\nThe third step is reachability. The trusted history entry is consumed by both the Preview UI and the watch history API.\n\nRelevant code:\n- [changedetectionio/blueprint/ui/preview.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/blueprint/ui/preview.py#L77)\n- [changedetectionio/api/Watch.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/api/Watch.py#L263)\n\nIn the Preview flow, the application selects the latest history timestamp and calls:\n\n```python\ncontent = watch.get_history_snapshot(timestamp=timestamp)\n```\n\nIn the API flow, the application also calls:\n\n```python\ncontent = watch.get_history_snapshot(timestamp=timestamp)\n```\n\nThis creates the following end-to-end exploit chain:\n\n1. An attacker supplies a crafted backup ZIP.\n2. The restore process preserves attacker-controlled `history.txt`.\n3. The `history.txt` parser accepts an absolute or out-of-directory path if that path exists.\n4. Preview or the history API dereferences the stored path directly.\n5. The application returns the contents of the targeted local file.\n\nThe root cause is that imported history entries are treated as trusted filesystem paths instead of being restricted to safe basenames under `watch.data_dir`.\n\n\n### PoC\nThe following proof of concept demonstrates the end-to-end exploit chain. It assumes the attacker has gained access to the backup restore functionality to upload the crafted archive.\n\n1. Create a normal watch in the UI, for example:\n```text\nhttps://example.com\n```\n\n2. Trigger at least one successful check so the watch generates a valid history entry and can later be included in a backup.\n\n<img width=\"1230\" height=\"587\" alt=\"image\" src=\"https://github.com/user-attachments/assets/a76302c9-48d6-4aa3-9bfc-0ba2fdb31156\" />\n\n3. Go to the `Backups` section and create a backup archive.\n\n<img width=\"1232\" height=\"390\" alt=\"image\" src=\"https://github.com/user-attachments/assets/8fef0444-d8f0-4db6-be75-04fa7db2fec8\" />\n\n4. Extract the backup archive and identify the watch UUID directory that contains the target watch's `watch.json`. For example:\n```text\n5db3d3d8-71e6-4db2-a81e-e1f0445c3e47\n```\n\n5. Open that watch directory and edit `history.txt`.\n\n6. Replace the latest history entry with a path to an existing local file that is readable by the application process. For example:\n```text\n1776969188,/etc/passwd\n```\n\nIf the timestamp differs in the extracted backup, keep the original latest timestamp and only replace the filename/path portion.\n\nExample:\n```text\n1776969188,742215043ff9be7e635f05e680ff9b11.txt\n```\n\nbecomes:\n\n```text\n1776969188,/etc/passwd\n```\n\n<img width=\"1139\" height=\"366\" alt=\"image\" src=\"https://github.com/user-attachments/assets/07277b36-4747-4181-82d1-523385b40de3\" />\n\n7. Repack the backup so that the UUID directories are located at the root of the ZIP archive.\n\nImportant:\n- Do not add an extra parent directory layer when repacking.\n- The archive root should contain directories such as:\n```text\n<watch-uuid>/\n<group-uuid>/\nchangedetection.json\nurl-list.txt\n```\n\n<img width=\"986\" height=\"353\" alt=\"image\" src=\"https://github.com/user-attachments/assets/21b30368-2cb5-4b88-9055-79d5bf06ad48\" />\n\n8. In the UI, restore the modified backup and enable replacement of existing watches with the same UUID.\n\n<img width=\"1229\" height=\"700\" alt=\"image\" src=\"https://github.com/user-attachments/assets/79abd9d2-8b2d-4e08-abf3-3b63238a8055\" />\n\n9. After restore completes, open `Preview` for the restored watch.\n\n10. The application will read the attacker-controlled path from `history.txt` and display the contents of the referenced local file instead of the original watch snapshot.\n\nObserved result:\n- The Preview page returns the content of the attacker-selected local file.\n\nExpected result:\n- The application should reject absolute paths or out-of-directory paths restored from `history.txt`.\n- Snapshot history should be restricted to files within the watch's own data directory.\n\nOptional API verification:\n- The same issue can also be confirmed through the watch history API by requesting the modified timestamp after restore.\n- The API returns the same file content because it also calls `watch.get_history_snapshot(timestamp=timestamp)` on the trusted history entry.\n\n<img width=\"1233\" height=\"545\" alt=\"image\" src=\"https://github.com/user-attachments/assets/a2814a5a-2cdd-49a1-916b-c956dd5fda1f\" />\n\n### Impact\nThis is an arbitrary local file disclosure vulnerability reachable through malicious backup restore content.\n\nWho is impacted:\n- Deployments where the application process has read access to sensitive local system files.\n- Docker or host-mounted environments where secrets, config files, or operational artifacts are explicitly readable by the service.\n\nWhat can be exposed:\n- **Arbitrary System Files:** Core operating system files (e.g., `/etc/passwd`, `/proc/self/environ`), system-level configurations, and host metrics.\n- **Application Data:** Internal records and files residing under the /datastore directory.\n- **Secrets & Artifacts:** Application-local configuration files, API tokens, database credentials, and other sensitive artifacts accessible to the application process.\n\nBy accessing the backup restore functionality and importing a crafted archive, an attacker can exploit the application's fail-open path validation. The confidentiality impact is exceptionally high because, once the payload is ingested, the application can be manipulated to disclose arbitrary local system files and highly sensitive environment variables directly through standard UI or API responses.\n\n### Recommendation\nThe application should treat all paths restored from `history.txt` as untrusted input.\n\nThe root cause is in [changedetectionio/model/Watch.py](https://github.com/dgtlmoon/changedetection.io/blob/master/changedetectionio/model/Watch.py#L460), where values containing path separators are currently accepted as filesystem paths and preserved if the referenced file already exists.\n\nThe fix should be:\n\n1. Never trust absolute or external paths from `history.txt`.\n2. Normalize every history entry to `os.path.basename(v)`.\n3. Join the normalized filename to `self.data_dir`.\n4. Skip the entry if the resolved file does not exist inside the watch directory.\n\nSuggested code change:\n\n```python\nsnapshot_fname = os.path.basename(v.strip())\nresolved_path = os.path.join(self.data_dir, snapshot_fname)\n\nif not os.path.exists(resolved_path):\n    logger.warning(\n        f\"Skipping unsafe or missing history entry for {self.get('uuid')}: {v!r}\"\n    )\n    continue\n\ntmp_history[k] = resolved_path\n```\n\nThis ensures restored history entries can only reference files inside the watch's own data directory and prevents arbitrary local file reads through Preview or the history API.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "PyPI",
      "name" : "changedetection.io"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "0.55.1"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 0.54.10"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-8757-69j2-hx56"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/pocket-id/pocket-id/security/advisories/GHSA-w6p7-2fxx-4f44"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-43891"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/dgtlmoon/changedetection.io"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/pypa/advisory-database/tree/main/vulns/changedetection-io/PYSEC-2026-30.yaml"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-73" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-05-05T21:16:21Z",
    "nvd_published_at" : "2026-05-12T18:17:28Z"
  }
}