#!/usr/bin/env python3 """Tests for the shared "may the server fetch this URL?" gate. # run from dir above changedetectionio/ dir # python3 -m unittest changedetectionio.tests.unit.test_fetch_url_gate Every server-side fetch entry point routes through validate_url.is_fetch_url_allowed(). Before it existed, the file:// and private-IP rules were enforced inline in call_browser() only, so any fetch path that did not go through call_browser() was unprotected: * a "Goto URL" browser step could read file:///etc/passwd (GHSA-hm22-wg2m-35v4) * /add-watch-ui/snapshot url= could fetch internal hosts (GHSA-56fq-63vj-9992) These tests pin the gate's rules AND the browser-step choke point, so a future fetch path that forgets to call the gate is the only way to regress it. """ import asyncio import unittest from unittest.mock import patch from changedetectionio.browser_steps.browser_steps import steppable_browser_interface from changedetectionio.validate_url import ( is_fetch_url_allowed, is_special_purpose_ip, validate_fetch_url, validate_fetch_url_async, ) # tests/conftest.py sets ALLOW_IANA_RESTRICTED_ADDRESSES=true for the functional suite, so the # locked-down default has to be re-asserted explicitly rather than assumed. LOCKED_DOWN = {'ALLOW_IANA_RESTRICTED_ADDRESSES': 'false', 'ALLOW_FILE_URI': 'false'} OPTED_IN = {'ALLOW_IANA_RESTRICTED_ADDRESSES': 'true', 'ALLOW_FILE_URI': 'true'} class TestFetchUrlGate(unittest.TestCase): def assertBlocked(self, url): ok, reason = is_fetch_url_allowed(url) self.assertFalse(ok, f"URL '{url}' should have been blocked") self.assertTrue(reason, f"URL '{url}' was blocked without a reason to show the user") def assertAllowed(self, url): ok, reason = is_fetch_url_allowed(url) self.assertTrue(ok, f"URL '{url}' should have been allowed, got: {reason}") def test_file_uri_blocked_by_default(self): with patch.dict('os.environ', LOCKED_DOWN): # All the spellings that reach the same local file for url in ('file:///etc/passwd', 'FILE:///etc/passwd', 'file:/etc/passwd', 'file://etc/passwd'): with self.subTest(url=url): self.assertBlocked(url) def test_file_uri_allowed_when_operator_opts_in(self): with patch.dict('os.environ', OPTED_IN): self.assertAllowed('file:///etc/passwd') def test_file_uri_blocked_even_if_safe_protocol_regex_was_loosened(self): """An operator who widens SAFE_PROTOCOL_REGEX for some other scheme must not get local file reads thrown in for free - hence the explicit file: check ahead of is_safe_valid_url().""" env = dict(LOCKED_DOWN, SAFE_PROTOCOL_REGEX='^(http|https|ftp|file):') with patch.dict('os.environ', env): self.assertBlocked('file:///etc/passwd') def test_private_and_reserved_addresses_blocked_by_default(self): with patch.dict('os.environ', LOCKED_DOWN): for url in ('http://127.0.0.1:5000/', 'http://localhost/', 'http://169.254.169.254/latest/meta-data/', # cloud metadata 'http://192.168.1.1/', 'http://10.0.0.1/', 'http://[::1]/'): with self.subTest(url=url): self.assertBlocked(url) def test_cgnat_and_other_non_global_addresses_blocked_by_default(self): """GHSA-gwph-fp79-379w - the 0.54.1 predicate only tested is_private/is_loopback/ is_link_local/is_reserved, none of which are True for RFC 6598 CGNAT space, so 100.64.0.0/10 (an ISP's other subscribers, CPE admin panels, CGNAT gateways) stayed fetchable. These are IP literals, so no DNS is involved and CI cannot flake.""" with patch.dict('os.environ', LOCKED_DOWN): for url in ('http://100.64.0.1/', # RFC 6598 CGNAT, first usable 'http://100.127.255.254/', # RFC 6598 CGNAT, last usable 'http://100.100.100.100/', # inside CGNAT (Alibaba Cloud metadata) 'http://192.88.99.1/', # RFC 7526 deprecated 6to4 relay anycast 'http://224.0.0.1/', # IPv4 multicast all-hosts 'http://[ff02::1]/'): # IPv6 multicast all-nodes with self.subTest(url=url): self.assertBlocked(url) def test_cgnat_allowed_when_operator_opts_in(self): """CGNAT is legitimate for operators monitoring their own carrier network, so the opt-in has to release it the same way it releases 127.0.0.1.""" with patch.dict('os.environ', OPTED_IN): self.assertAllowed('http://100.64.0.1/') def test_special_purpose_ip_classification(self): """The predicate itself, without DNS - one place to pin what is and is not fetchable.""" for ip in ('100.64.0.1', '100.127.255.254', '192.88.99.1', '224.0.0.1', 'ff02::1', '127.0.0.1', '10.0.0.1', '169.254.169.254', '192.168.1.1', '::1', '0.0.0.0', '255.255.255.255', '198.18.0.1', 'fc00::1', 'fe80::1', '::ffff:100.64.0.1', # CGNAT wrapped as an IPv4-mapped IPv6 address '2002:6440:1::'): # CGNAT wrapped as a 6to4 address with self.subTest(ip=ip): blocked, why = is_special_purpose_ip(ip) self.assertTrue(blocked, f"{ip} should be refused") self.assertTrue(why, f"{ip} was refused without a stated reason") for ip in ('1.1.1.1', '8.8.8.8', '93.184.216.34', '2606:4700:4700::1111'): with self.subTest(ip=ip): blocked, why = is_special_purpose_ip(ip) self.assertFalse(blocked, f"public address {ip} was refused as '{why}'") def test_cgnat_boundaries_are_exact(self): """100.64.0.0/10 ends at 100.127.255.255 - 100.63.x and 100.128.x are ordinary public space and must not be collateral damage from a /8-sized over-block.""" for ip in ('100.63.255.255', '100.128.0.0'): with self.subTest(ip=ip): blocked, why = is_special_purpose_ip(ip) self.assertFalse(blocked, f"public address {ip} was refused as '{why}'") def test_private_addresses_allowed_when_operator_opts_in(self): with patch.dict('os.environ', OPTED_IN): self.assertAllowed('http://127.0.0.1:5000/') def test_source_prefix_is_stripped_before_the_hostname_check(self): """Load-bearing, not cosmetic: urlparse('source:http://127.0.0.1/') reports NO hostname, so leaving the prefix on would hand the private-IP check nothing to look at and let it pass.""" with patch.dict('os.environ', LOCKED_DOWN): self.assertBlocked('source:http://127.0.0.1/') self.assertBlocked('SOURCE:http://169.254.169.254/') self.assertBlocked('source:file:///etc/passwd') def test_jinja2_is_rendered_before_the_hostname_check(self): """The fetch uses the rendered URL, so the rendered URL is what must be judged - otherwise a template expression hides the real target from the check.""" with patch.dict('os.environ', LOCKED_DOWN): self.assertBlocked("http://{{ '127.0.0.1' }}/") self.assertBlocked("http://{% if 1 %}127.0.0.1{% endif %}/") def test_parser_differential_payload_always_rejected(self): """GHSA-rph4-96w6-q594: urlparse sees PUBLIC, urllib3 connects to INTERNAL. A backslash has no legitimate use in a URL, so this is refused even with both opt-ins enabled.""" for env in (LOCKED_DOWN, OPTED_IN): with self.subTest(env=env), patch.dict('os.environ', env): self.assertBlocked('http://127.0.0.1:8888\\@example.com/') def test_unsupported_schemes_rejected(self): with patch.dict('os.environ', LOCKED_DOWN): for url in ('javascript:alert(1)', 'data:text/html,