Compare commits

...

1 Commits

2 changed files with 24 additions and 22 deletions
+21 -14
View File
@@ -584,13 +584,16 @@ def test_static_directory_traversal(client, live_server, measure_memory_usage, d
def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memory_usage, datastore_path): def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memory_usage, datastore_path):
""" """
SSRF protection: IANA-reserved/private IP addresses must be blocked by default. SSRF protection: IANA-reserved/private IP addresses are blocked at fetch-time, not add-time.
Watches targeting private/reserved IPs can be *added* freely; the block happens when the
fetcher actually tries to reach the URL (via validate_iana_url() in call_browser()).
Covers: Covers:
1. is_private_hostname() correctly classifies all reserved ranges 1. is_private_hostname() correctly classifies all reserved ranges
2. is_safe_valid_url() rejects private-IP URLs at add-time (env var off) 2. is_safe_valid_url() ALLOWS private-IP URLs at add-time (IANA check moved to fetch-time)
3. is_safe_valid_url() allows private-IP URLs when ALLOW_IANA_RESTRICTED_ADDRESSES=true 3. ALLOW_IANA_RESTRICTED_ADDRESSES has no effect on add-time; it only controls fetch-time
4. UI form rejects private-IP URLs and shows the standard error message 4. UI form accepts private-IP URLs at add-time without error
5. Requests fetcher blocks fetch-time DNS rebinding (fresh check on every fetch) 5. Requests fetcher blocks fetch-time DNS rebinding (fresh check on every fetch)
6. Requests fetcher blocks redirects that lead to a private IP (open-redirect bypass) 6. Requests fetcher blocks redirects that lead to a private IP (open-redirect bypass)
@@ -623,9 +626,10 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
assert not is_private_hostname(host), f"{host} should be identified as public" assert not is_private_hostname(host), f"{host} should be identified as public"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 2. is_safe_valid_url() blocks private-IP URLs (env var off) # 2. is_safe_valid_url() ALLOWS private-IP URLs at add-time
# IANA check is no longer done here — it moved to fetch-time validate_iana_url()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
blocked_urls = [ private_ip_urls = [
'http://127.0.0.1/', 'http://127.0.0.1/',
'http://10.0.0.1/', 'http://10.0.0.1/',
'http://172.16.0.1/', 'http://172.16.0.1/',
@@ -636,21 +640,24 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
'http://[fc00::1]/', 'http://[fc00::1]/',
'http://[fe80::1]/', 'http://[fe80::1]/',
] ]
for url in blocked_urls: for url in private_ip_urls:
assert not is_safe_valid_url(url), f"{url} should be blocked by is_safe_valid_url" assert is_safe_valid_url(url), f"{url} should be allowed by is_safe_valid_url (IANA check is at fetch-time)"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 3. ALLOW_IANA_RESTRICTED_ADDRESSES=true bypasses the block # 3. ALLOW_IANA_RESTRICTED_ADDRESSES does not affect add-time validation
# It only controls fetch-time blocking inside validate_iana_url()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'true') monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'true')
assert is_safe_valid_url('http://127.0.0.1/'), \ assert is_safe_valid_url('http://127.0.0.1/'), \
"Private IP should be allowed when ALLOW_IANA_RESTRICTED_ADDRESSES=true" "Private IP should be allowed at add-time regardless of ALLOW_IANA_RESTRICTED_ADDRESSES"
# Restore the block for the remaining assertions
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false') monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
assert is_safe_valid_url('http://127.0.0.1/'), \
"Private IP should be allowed at add-time regardless of ALLOW_IANA_RESTRICTED_ADDRESSES"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 4. UI form rejects private-IP URLs # 4. UI form accepts private-IP URLs at add-time
# The watch is created; the SSRF block fires later at fetch-time
# ------------------------------------------------------------------ # ------------------------------------------------------------------
for url in ['http://127.0.0.1/', 'http://169.254.169.254/latest/meta-data/']: for url in ['http://127.0.0.1/', 'http://169.254.169.254/latest/meta-data/']:
res = client.post( res = client.post(
@@ -658,8 +665,8 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
data={'url': url, 'tags': ''}, data={'url': url, 'tags': ''},
follow_redirects=True follow_redirects=True
) )
assert b'Watch protocol is not permitted or invalid URL format' in res.data, \ assert b'Watch protocol is not permitted or invalid URL format' not in res.data, \
f"UI should reject {url}" f"UI should accept {url} at add-time (SSRF is blocked at fetch-time)"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 5. Fetch-time DNS-rebinding check in the requests fetcher # 5. Fetch-time DNS-rebinding check in the requests fetcher
+3 -8
View File
@@ -71,12 +71,15 @@ def is_private_hostname(hostname):
for info in socket.getaddrinfo(hostname, None): for info in socket.getaddrinfo(hostname, None):
ip = ipaddress.ip_address(info[4][0]) ip = ipaddress.ip_address(info[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
logger.warning(f"Hostname '{hostname} - {ip} - ip.is_private = {ip.is_private}, ip.is_loopback = {ip.is_loopback}, ip.is_link_local = {ip.is_link_local}, ip.is_reserved = {ip.is_reserved}")
return True return True
except socket.gaierror as e: except socket.gaierror as e:
logger.warning(f"{hostname} error checking {str(e)}") logger.warning(f"{hostname} error checking {str(e)}")
return False return False
logger.info(f"Hostname '{hostname}' is NOT private/IANA restricted.")
return False return False
def is_safe_valid_url(test_url): def is_safe_valid_url(test_url):
from changedetectionio import strtobool from changedetectionio import strtobool
from changedetectionio.jinja2_custom import render as jinja_render from changedetectionio.jinja2_custom import render as jinja_render
@@ -139,12 +142,4 @@ def is_safe_valid_url(test_url):
logger.warning(f'URL f"{test_url}" failed validation, aborting.') logger.warning(f'URL f"{test_url}" failed validation, aborting.')
return False return False
# Block IANA-restricted (private/reserved) IP addresses unless explicitly allowed.
# This is an add-time check; fetch-time re-validation in requests.py handles DNS rebinding.
if not strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')):
parsed = urlparse(test_url)
if parsed.hostname and is_private_hostname(parsed.hostname):
logger.warning(f'URL "{test_url}" resolves to a private/reserved IP address, aborting.')
return False
return True return True