GitHub Security Advisory: GHSA-8757-69j2-hx56
Package: changedetection.io (PIP)
Severity: HIGH (CVSS 7.5)
CWE: CWE-73: External Control of File Name or Path
Vulnerable versions: <= 0.54.10
Patched in: 0.55.1

Summary: changedetection.io has an Arbitrary Local File Read via a crafted backup restore

Details:
### 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:

`[PII_REDACTED],/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