Skip to content

Commit fe7aa38

Browse files
committed
CVE-2026-27696 - Server-Side Request Forgery (SSRF) via Watch URLs, set env var ALLOW_IANA_RESTRICTED_ADDRESSES to true to access IANA reserved URLs such as http://169.254.169.254, http://10.0.0.1/, http://127.0.0.1/, etc.
1 parent a385c89 commit fe7aa38

6 files changed

Lines changed: 204 additions & 5 deletions

File tree

changedetectionio/content_fetchers/requests.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from loguru import logger
2+
from urllib.parse import urljoin, urlparse
23
import hashlib
34
import os
45
import re
@@ -7,6 +8,7 @@
78
from changedetectionio import strtobool
89
from changedetectionio.content_fetchers.exceptions import BrowserStepsInUnsupportedFetcher, EmptyReply, Non200ErrorCodeReceived
910
from changedetectionio.content_fetchers.base import Fetcher
11+
from changedetectionio.validate_url import is_private_hostname
1012

1113

1214
# "html_requests" is listed as the default fetcher in store.py!
@@ -79,14 +81,48 @@ def _run_sync(self,
7981
if strtobool(os.getenv('ALLOW_FILE_URI', 'false')) and url.startswith('file://'):
8082
from requests_file import FileAdapter
8183
session.mount('file://', FileAdapter())
84+
85+
allow_iana_restricted = strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false'))
86+
8287
try:
88+
# Fresh DNS check at fetch time — catches DNS rebinding regardless of add-time cache.
89+
if not allow_iana_restricted:
90+
parsed_initial = urlparse(url)
91+
if parsed_initial.hostname and is_private_hostname(parsed_initial.hostname):
92+
raise Exception(f"Fetch blocked: '{url}' resolves to a private/reserved IP address. "
93+
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow.")
94+
8395
r = session.request(method=request_method,
8496
data=request_body.encode('utf-8') if type(request_body) is str else request_body,
8597
url=url,
8698
headers=request_headers,
8799
timeout=timeout,
88100
proxies=proxies,
89-
verify=False)
101+
verify=False,
102+
allow_redirects=False)
103+
104+
# Manually follow redirects so each hop's resolved IP can be validated,
105+
# preventing SSRF via an open redirect on a public host.
106+
current_url = url
107+
for _ in range(10):
108+
if not r.is_redirect:
109+
break
110+
location = r.headers.get('Location', '')
111+
redirect_url = urljoin(current_url, location)
112+
if not allow_iana_restricted:
113+
parsed_redirect = urlparse(redirect_url)
114+
if parsed_redirect.hostname and is_private_hostname(parsed_redirect.hostname):
115+
raise Exception(f"Redirect blocked: '{redirect_url}' resolves to a private/reserved IP address.")
116+
current_url = redirect_url
117+
r = session.request('GET', redirect_url,
118+
headers=request_headers,
119+
timeout=timeout,
120+
proxies=proxies,
121+
verify=False,
122+
allow_redirects=False)
123+
else:
124+
raise Exception("Too many redirects")
125+
90126
except Exception as e:
91127
msg = str(e)
92128
if proxies and 'SOCKSHTTPSConnectionPool' in msg:

changedetectionio/run_basic_tests.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,12 @@ data_sanity_test () {
4444
cd ..
4545
TMPDIR=$(mktemp -d)
4646
PORT_N=$((5000 + RANDOM % (6501 - 5000)))
47-
./changedetection.py -p $PORT_N -d $TMPDIR -u "https://localhost?test-url-is-sanity=1" &
47+
ALLOW_IANA_RESTRICTED_ADDRESSES=true ./changedetection.py -p $PORT_N -d $TMPDIR -u "https://localhost?test-url-is-sanity=1" &
4848
PID=$!
4949
sleep 5
5050
kill $PID
5151
sleep 2
52-
./changedetection.py -p $PORT_N -d $TMPDIR &
52+
ALLOW_IANA_RESTRICTED_ADDRESSES=true ./changedetection.py -p $PORT_N -d $TMPDIR &
5353
PID=$!
5454
sleep 5
5555
# On a restart the URL should still be there

changedetectionio/store/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -728,8 +728,11 @@ def add_watch(self, url, tag='', extras=None, tag_uuids=None, save_immediately=T
728728
return False
729729

730730
if not is_safe_valid_url(url):
731-
flash(gettext('Watch protocol is not permitted or invalid URL format'), 'error')
732-
731+
from flask import has_request_context
732+
if has_request_context():
733+
flash(gettext('Watch protocol is not permitted or invalid URL format'), 'error')
734+
else:
735+
logger.error(f"add_watch: URL '{url}' is not permitted or invalid, skipping.")
733736
return None
734737

735738
# Check PAGE_WATCH_LIMIT if set

changedetectionio/tests/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
# When test server is slow/unresponsive, workers fail fast instead of holding UUIDs for 45s
1414
# This prevents exponential priority growth from repeated deferrals (priority × 10 each defer)
1515
os.environ['DEFAULT_SETTINGS_REQUESTS_TIMEOUT'] = '5'
16+
# Test server runs on localhost (127.0.0.1) which is a private IP.
17+
# Allow it globally so all existing tests keep working; test_ssrf_protection
18+
# uses monkeypatch to temporarily override this for its own assertions.
19+
os.environ['ALLOW_IANA_RESTRICTED_ADDRESSES'] = 'true'
1620

1721
from changedetectionio.flask_app import init_app_secret, changedetection_app
1822
from changedetectionio.tests.util import live_server_setup, new_live_server_setup

changedetectionio/tests/test_security.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import pytest
23

34
from flask import url_for
45

@@ -579,3 +580,131 @@ def test_static_directory_traversal(client, live_server, measure_memory_usage, d
579580
# Should get 403 (not authenticated) or 404 (file not found), not a path traversal
580581
assert res.status_code in [403, 404]
581582

583+
584+
def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memory_usage, datastore_path):
585+
"""
586+
SSRF protection: IANA-reserved/private IP addresses must be blocked by default.
587+
588+
Covers:
589+
1. is_private_hostname() correctly classifies all reserved ranges
590+
2. is_safe_valid_url() rejects private-IP URLs at add-time (env var off)
591+
3. is_safe_valid_url() allows private-IP URLs when ALLOW_IANA_RESTRICTED_ADDRESSES=true
592+
4. UI form rejects private-IP URLs and shows the standard error message
593+
5. Requests fetcher blocks fetch-time DNS rebinding (fresh check on every fetch)
594+
6. Requests fetcher blocks redirects that lead to a private IP (open-redirect bypass)
595+
596+
conftest.py sets ALLOW_IANA_RESTRICTED_ADDRESSES=true globally so the test
597+
server (localhost) keeps working for all other tests. monkeypatch temporarily
598+
overrides it to 'false' here, and is automatically restored after the test.
599+
"""
600+
from unittest.mock import patch, MagicMock
601+
from changedetectionio.validate_url import is_safe_valid_url, is_private_hostname
602+
603+
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
604+
# Clear any URL results cached while the env var was 'true'
605+
is_safe_valid_url.cache_clear()
606+
607+
# ------------------------------------------------------------------
608+
# 1. is_private_hostname() — unit tests across all reserved ranges
609+
# ------------------------------------------------------------------
610+
private_hosts = [
611+
'127.0.0.1', # loopback
612+
'10.0.0.1', # RFC 1918
613+
'172.16.0.1', # RFC 1918
614+
'192.168.1.1', # RFC 1918
615+
'169.254.169.254', # link-local / AWS metadata endpoint
616+
'::1', # IPv6 loopback
617+
'fc00::1', # IPv6 unique local
618+
'fe80::1', # IPv6 link-local
619+
]
620+
for host in private_hosts:
621+
assert is_private_hostname(host), f"{host} should be identified as private/reserved"
622+
623+
for host in ['8.8.8.8', '1.1.1.1']:
624+
assert not is_private_hostname(host), f"{host} should be identified as public"
625+
626+
# ------------------------------------------------------------------
627+
# 2. is_safe_valid_url() blocks private-IP URLs (env var off)
628+
# ------------------------------------------------------------------
629+
blocked_urls = [
630+
'http://127.0.0.1/',
631+
'http://10.0.0.1/',
632+
'http://172.16.0.1/',
633+
'http://192.168.1.1/',
634+
'http://169.254.169.254/',
635+
'http://169.254.169.254/latest/meta-data/iam/security-credentials/',
636+
'http://[::1]/',
637+
'http://[fc00::1]/',
638+
'http://[fe80::1]/',
639+
]
640+
for url in blocked_urls:
641+
assert not is_safe_valid_url(url), f"{url} should be blocked by is_safe_valid_url"
642+
643+
# ------------------------------------------------------------------
644+
# 3. ALLOW_IANA_RESTRICTED_ADDRESSES=true bypasses the block
645+
# ------------------------------------------------------------------
646+
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'true')
647+
is_safe_valid_url.cache_clear()
648+
assert is_safe_valid_url('http://127.0.0.1/'), \
649+
"Private IP should be allowed when ALLOW_IANA_RESTRICTED_ADDRESSES=true"
650+
651+
# Restore the block for the remaining assertions
652+
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
653+
is_safe_valid_url.cache_clear()
654+
655+
# ------------------------------------------------------------------
656+
# 4. UI form rejects private-IP URLs
657+
# ------------------------------------------------------------------
658+
for url in ['http://127.0.0.1/', 'http://169.254.169.254/latest/meta-data/']:
659+
res = client.post(
660+
url_for('ui.ui_views.form_quick_watch_add'),
661+
data={'url': url, 'tags': ''},
662+
follow_redirects=True
663+
)
664+
assert b'Watch protocol is not permitted or invalid URL format' in res.data, \
665+
f"UI should reject {url}"
666+
667+
# ------------------------------------------------------------------
668+
# 5. Fetch-time DNS-rebinding check in the requests fetcher
669+
# Simulates: URL passed add-time validation with a public IP, but
670+
# by fetch time DNS has been rebound to a private IP.
671+
# ------------------------------------------------------------------
672+
from changedetectionio.content_fetchers.requests import fetcher as RequestsFetcher
673+
674+
f = RequestsFetcher()
675+
676+
with patch('changedetectionio.content_fetchers.requests.is_private_hostname', return_value=True):
677+
with pytest.raises(Exception, match='private/reserved'):
678+
f._run_sync(
679+
url='http://example.com/',
680+
timeout=5,
681+
request_headers={},
682+
request_body=None,
683+
request_method='GET',
684+
)
685+
686+
# ------------------------------------------------------------------
687+
# 6. Redirect-to-private-IP blocked (open-redirect SSRF bypass)
688+
# Public host returns a 302 pointing at an IANA-reserved address.
689+
# ------------------------------------------------------------------
690+
mock_redirect = MagicMock()
691+
mock_redirect.is_redirect = True
692+
mock_redirect.status_code = 302
693+
mock_redirect.headers = {'Location': 'http://169.254.169.254/latest/meta-data/'}
694+
695+
def _private_only_for_redirect(hostname):
696+
# Initial host is "public"; the redirect target is private
697+
return hostname in {'169.254.169.254', '10.0.0.1', '172.16.0.1',
698+
'192.168.0.1', '127.0.0.1', '::1'}
699+
700+
with patch('changedetectionio.content_fetchers.requests.is_private_hostname',
701+
side_effect=_private_only_for_redirect):
702+
with patch('requests.Session.request', return_value=mock_redirect):
703+
with pytest.raises(Exception, match='Redirect blocked'):
704+
f._run_sync(
705+
url='http://example.com/',
706+
timeout=5,
707+
request_headers={},
708+
request_body=None,
709+
request_method='GET',
710+
)

changedetectionio/validate_url.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import ipaddress
2+
import socket
13
from functools import lru_cache
24
from loguru import logger
35
from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
@@ -56,6 +58,23 @@ def normalize_url_encoding(url):
5658
return url
5759

5860

61+
def is_private_hostname(hostname):
62+
"""Return True if hostname resolves to an IANA-restricted (private/reserved) IP address.
63+
64+
Fails closed: unresolvable hostnames return True (block them).
65+
Never cached — callers that need fresh DNS resolution (e.g. at fetch time) can call
66+
this directly without going through the lru_cached is_safe_valid_url().
67+
"""
68+
try:
69+
for info in socket.getaddrinfo(hostname, None):
70+
ip = ipaddress.ip_address(info[4][0])
71+
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
72+
return True
73+
except socket.gaierror:
74+
return True
75+
return False
76+
77+
5978
@lru_cache(maxsize=10000)
6079
def is_safe_valid_url(test_url):
6180
from changedetectionio import strtobool
@@ -119,4 +138,12 @@ def is_safe_valid_url(test_url):
119138
logger.warning(f'URL f"{test_url}" failed validation, aborting.')
120139
return False
121140

141+
# Block IANA-restricted (private/reserved) IP addresses unless explicitly allowed.
142+
# This is an add-time check; fetch-time re-validation in requests.py handles DNS rebinding.
143+
if not strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')):
144+
parsed = urlparse(test_url)
145+
if parsed.hostname and is_private_hostname(parsed.hostname):
146+
logger.warning(f'URL "{test_url}" resolves to a private/reserved IP address, aborting.')
147+
return False
148+
122149
return True

0 commit comments

Comments
 (0)