|
1 | 1 | import os |
| 2 | +import pytest |
2 | 3 |
|
3 | 4 | from flask import url_for |
4 | 5 |
|
@@ -579,3 +580,131 @@ def test_static_directory_traversal(client, live_server, measure_memory_usage, d |
579 | 580 | # Should get 403 (not authenticated) or 404 (file not found), not a path traversal |
580 | 581 | assert res.status_code in [403, 404] |
581 | 582 |
|
| 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 | + ) |
0 commit comments