GitHub Security Advisory: GHSA-3c45-4pj5-ch7m
Package: changedetection.io (PIP)
Severity: HIGH (CVSS 8.6)
CWE: CWE-918: Server-Side Request Forgery (SSRF)
Vulnerable versions: < 0.54.1
Patched in: 0.54.1

Summary: changedetection.io is Vulnerable to SSRF via Watch URLs

Details:
## Summary

Changedetection.io is vulnerable to Server-Side Request Forgery (SSRF) because the URL validation function `is_safe_valid_url()` does not validate the resolved IP address of watch URLs against private, loopback, or link-local address ranges. An authenticated user (or any user when no password is configured, which is the default) can add a watch for internal network URLs such as:

- `http://169.254.169.254`
- `http://10.0.0.1/`
- `http://127.0.0.1/`

The application fetches these URLs server-side, stores the response content, and makes it viewable through the web UI — enabling full data exfiltration from internal services.

This is particularly severe because:

- The fetched content is stored and viewable - this is not a blind SSRF
- Watches are fetched periodically - creating a persistent SSRF that continuously accesses internal resources
- By default, no password is set - the web UI is accessible without authentication
- Self-hosted deployments typically run on cloud infrastructure where `169.254.169.254` returns real IAM credentials

---

## Details

The URL validation function `is_safe_valid_url()` in `changedetectionio/validate_url.py` (lines 60–122) validates the URL protocol (http/https/ftp) and format using the `validators` library, but does not perform any DNS resolution or IP address validation:

```python
# changedetectionio/validate_url.py:60-122
@lru_cache(maxsize=1000)
def is_safe_valid_url(test_url):

    safe_protocol_regex = '^(http|https|ftp):'

    # Check protocol
    pattern = re.compile(os.getenv('SAFE_PROTOCOL_REGEX', safe_protocol_regex), re.IGNORECASE)
    if not pattern.match(test_url.strip()):
        return False

    # Check URL format
    if not validators.url(test_url, simple_host=True):
        return False

    return True  # No IP address validation performed
```

The HTTP fetcher in `changedetectionio/content_fetchers/requests.py` (lines 83–89) then makes the request without any additional IP validation:

```python
# changedetectionio/content_fetchers/requests.py:83-89
r = session.request(method=request_method,
                    url=url,            # User-provided URL, no IP validation
                    headers=request_headers,
                    timeout=timeout,
                    proxies=proxies,
                    verify=False)
```
The response content is stored and made available to the user:

```python
# changedetectionio/content_fetchers/requests.py:140-142
self.content = r.text     # Text content stored
self.raw_content = r.content  # Raw bytes stored
```
This validation gap exists in all entry points that accept watch URLs:

- Web UI: `changedetectionio/store/__init__.py:718`
- REST API: `changedetectionio/api/watch.py:163, 428`
- Import API: `changedetectionio/api/import.py:188`

All use the same `is_safe_valid_url()` function, so a single fix addresses all paths.

---

## PoC

### Prerequisites

- A changedetection.io instance (Docker deployment)
- Network access to the instance (defa