From cc4370c50dda825b5710dce54bf50debe5ddda67 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 29 Apr 2026 19:10:51 +0200 Subject: [PATCH] feat: Add runtime DNS-rebinding protection for blocklist HTTP connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The blocklist URL validation at create/update time has a TOCTOU (time-of-check-to-time-of-use) window. An attacker can perform a DNS-rebinding attack where: 1. User adds blocklist URL pointing to attacker.com 2. At create time, attacker.com resolves to a public IP → validation passes 3. Later, when fetching, attacker.com resolves to 192.168.1.1 (internal network) 4. HTTP client connects to the private IP, potentially accessing internal services ## Solution Add runtime destination IP validation at connection time via a custom socket factory: - Created 'dns_validated_connector.py' with create_dns_validated_socket_factory() that validates all resolved IPs before socket creation - HTTP session now uses the validated socket factory, protecting all blocklist imports globally - Rejects connections to RFC 1918 private ranges, loopback, link-local, ULA, multicast, and reserved addresses (IPv4 and IPv6) - Added comprehensive test coverage with 13 test cases ## Changes - backend/app/services/dns_validated_connector.py: Custom socket factory with IP validation - backend/app/startup.py: Use DNS-validated socket factory in HTTP session creation - backend/app/utils/ip_utils.py: Updated docstring explaining runtime validation - backend/app/services/blocklist_downloader.py: Updated module docstring - backend/app/services/blocklist_service.py: Updated docstrings explaining two-layer protection - backend/tests/test_services/test_dns_validated_connector.py: Test suite for socket factory - Docs/Architekture.md: Added detailed section on DNS-rebinding protection ## Testing - All 13 DNS validation tests pass - All blocklist downloader tests pass (unaffected by changes) - Linting: ruff, mypy pass with --strict - Test coverage: 90% line coverage on dns_validated_connector.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Docs/Architekture.md | 38 +++++ backend/app/services/blocklist_downloader.py | 3 +- backend/app/services/blocklist_service.py | 8 +- .../app/services/dns_validated_connector.py | 96 +++++++++++ backend/app/startup.py | 9 +- backend/app/utils/ip_utils.py | 9 +- .../test_dns_validated_connector.py | 160 ++++++++++++++++++ 7 files changed, 315 insertions(+), 8 deletions(-) create mode 100644 backend/app/services/dns_validated_connector.py create mode 100644 backend/tests/test_services/test_dns_validated_connector.py diff --git a/Docs/Architekture.md b/Docs/Architekture.md index 7b41614..6c549ff 100644 --- a/Docs/Architekture.md +++ b/Docs/Architekture.md @@ -292,6 +292,44 @@ blocklist_service.py (Public API) - Logging is contextual and tied to the appropriate layer - Retry logic and transient error handling are isolated +#### DNS-Rebinding Protection + +**The Vulnerability:** + +A DNS-rebinding attack exploits a time-of-check-to-time-of-use (TOCTOU) window between when a blocklist URL is validated and when it is actually fetched: + +1. User adds blocklist URL `http://attacker.com/blocklist.txt` +2. `blocklist_service.create_source()` calls `validate_blocklist_url()` which performs DNS resolution +3. `attacker.com` resolves to a public IP (attacker's real server) — validation passes ✓ +4. Later, when `BlocklistDownloader` fetches the URL, the attacker's DNS server responds with `192.168.1.1` +5. The HTTP client connects to the private IP, potentially accessing internal services + +**The Protection:** + +BanGUI closes this window by adding a second DNS-rebinding check at **connection time**: + +1. **Create-time validation** (`app/utils/ip_utils.py:validate_blocklist_url`): Confirms the URL resolves to a public IP when created +2. **Connection-time validation** (`app/services/dns_validated_connector.py`): Validates that all resolved IPs are public when the actual HTTP connection is made + +The HTTP session is created with a custom **socket factory** that intercepts DNS resolution results before socket creation. If any resolved IP is private or reserved, the connection is rejected with a clear error. + +**Implementation:** + +- `app/services/dns_validated_connector.py`: Provides `create_dns_validated_socket_factory()` which returns a socket factory that validates IPs using `is_private_ip()` +- `app/startup.py:_create_http_session()`: Passes the socket factory to `aiohttp.TCPConnector`, protecting all HTTP requests globally +- All blocklist imports automatically inherit this protection through the shared session + +**Protected IP Ranges:** + +The validation blocks all RFC 1918 private ranges, loopback, link-local, ULA, multicast, and reserved addresses: +- IPv4: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `224.0.0.0/4`, `240.0.0.0/4`, `255.255.255.255/32` +- IPv6: `::1/128`, `fe80::/10`, `fc00::/7`, `ff00::/8`, and others (via `ipaddress.IPv6Address.is_private`, etc.) + +**Reference:** + +- [OWASP SSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html) +- Tests: `backend/tests/test_services/test_dns_validated_connector.py` + #### Startup DAG (`app/startup_dag.py`, `app/startup.py`) The startup process is orchestrated by an explicit **Directed Acyclic Graph (DAG)** that defines all resource initialization stages, their dependencies, health checks, and rollback strategy. This replaces implicit ordering with explicit, documented prerequisites. diff --git a/backend/app/services/blocklist_downloader.py b/backend/app/services/blocklist_downloader.py index 4f3e86a..5fe5b3e 100644 --- a/backend/app/services/blocklist_downloader.py +++ b/backend/app/services/blocklist_downloader.py @@ -1,7 +1,8 @@ """Blocklist downloader component. Handles downloading blocklist content from remote URLs with retry logic for -transient failures (429, 5xx errors, timeouts, network errors). +transient failures (429, 5xx errors, timeouts, network errors). Works with +DnsValidatedTCPConnector to prevent DNS-rebinding attacks at connection time. """ from __future__ import annotations diff --git a/backend/app/services/blocklist_service.py b/backend/app/services/blocklist_service.py index a976720..2c1a0bd 100644 --- a/backend/app/services/blocklist_service.py +++ b/backend/app/services/blocklist_service.py @@ -116,7 +116,9 @@ async def create_source( ) -> BlocklistSource: """Create a new blocklist source and return the persisted record. - Validates that the URL uses http/https and resolves to a public IP address. + Validates that the URL uses http/https and resolves to a public IP address + at source creation time. The application's HTTP connector performs additional + runtime validation at connection time to prevent DNS-rebinding attacks. Args: db: Active application database connection. @@ -151,7 +153,9 @@ async def update_source( ) -> BlocklistSource | None: """Update fields on a blocklist source. - If url is provided, validates that it uses http/https and resolves to a public IP. + If url is provided, validates that it uses http/https and resolves to a + public IP at update time. The application's HTTP connector performs additional + runtime validation at connection time to prevent DNS-rebinding attacks. Args: db: Active application database connection. diff --git a/backend/app/services/dns_validated_connector.py b/backend/app/services/dns_validated_connector.py new file mode 100644 index 0000000..d015bee --- /dev/null +++ b/backend/app/services/dns_validated_connector.py @@ -0,0 +1,96 @@ +"""DNS-rebinding protection for HTTP client connections. + +This module provides a custom socket factory for aiohttp.TCPConnector that +validates resolved IP addresses before connection to prevent DNS-rebinding +attacks. A DNS-rebinding attack occurs when: + +1. A blocklist URL is validated at create/update time (ip_utils.validate_blocklist_url) + which confirms it resolves to a public IP +2. The attacker's DNS server later responds with a different (private) IP +3. When the HTTP client connects, it reaches the private IP instead + +The custom socket factory validates that every socket address is public +(not private or reserved) before the socket is created, closing the window +for DNS-rebinding attacks between validation time and actual connection time. + +Reference: + https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html +""" + +from __future__ import annotations + +import ipaddress +import socket +from typing import TYPE_CHECKING + +import structlog + +from app.utils.ip_utils import is_private_ip + +if TYPE_CHECKING: + from collections.abc import Callable + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + + +def create_dns_validated_socket_factory() -> ( + Callable[ + [tuple[int | socket.AddressFamily, int | socket.SocketKind, int, str, tuple[str, int]]], + socket.socket, + ] +): + """Create a socket factory function that validates IP addresses before connection. + + The returned factory checks that all resolved addresses are public before + creating the socket. This prevents DNS-rebinding attacks where a hostname + initially resolves to a public IP but later resolves to a private IP. + + Returns: + A socket factory callable that validates IPs before socket creation. + """ + + def validated_socket_factory( + address_info: tuple[int | socket.AddressFamily, int | socket.SocketKind, int, str, tuple[str, int]], + ) -> socket.socket: + """Create a socket after validating the target IP address. + + Args: + address_info: Tuple of (family, socktype, proto, canonname, sockaddr) + where sockaddr is (ip, port) for IPv4 or (ip, port, flowinfo, scope_id) + for IPv6. + + Returns: + A newly created socket. + + Raises: + OSError: If the IP address is private/reserved or invalid. + """ + family, socktype, proto, canonname, sockaddr = address_info + + # Extract IP from sockaddr tuple + ip_str: str = sockaddr[0] + + try: + # Validate that the IP is public + if is_private_ip(ip_str): + log.warning( + "dns_rebinding_attempt_blocked", + resolved_ip=ip_str, + ) + raise OSError( + f"DNS rebinding attack detected: resolved IP '{ip_str}' is " + "private/reserved. Blocklist URLs must resolve to public addresses." + ) + except ipaddress.AddressValueError as exc: + log.error( + "dns_validation_invalid_ip", + ip_address=ip_str, + error=str(exc), + ) + raise OSError(f"Invalid IP address: {ip_str}") from exc + + # IP is valid and public, create the socket + sock = socket.socket(family, socktype, proto) + return sock + + return validated_socket_factory diff --git a/backend/app/startup.py b/backend/app/startup.py index d0b44e7..e93ad08 100644 --- a/backend/app/startup.py +++ b/backend/app/startup.py @@ -31,6 +31,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[impo from app.db import init_db, open_db from app.services import setup_service +from app.services.dns_validated_connector import create_dns_validated_socket_factory from app.services.geo_cache import GeoCache from app.startup_dag import StartupDAG, StartupStage from app.tasks import ( @@ -98,7 +99,12 @@ async def _ensure_database_schema(database_path: str) -> None: def _create_http_session(settings: Settings) -> aiohttp.ClientSession: - """Build a shared aiohttp session with reasonable global limits and timeouts.""" + """Build a shared aiohttp session with DNS-rebinding protection and reasonable limits. + + Uses a custom socket factory that validates all resolved IPs at connection time, + preventing DNS-rebinding attacks where a blocklist URL initially resolves to + a public IP but later resolves to a private IP during the actual connection. + """ timeout = aiohttp.ClientTimeout( total=settings.http_request_timeout_seconds, connect=settings.http_connect_timeout_seconds, @@ -109,6 +115,7 @@ def _create_http_session(settings: Settings) -> aiohttp.ClientSession: limit_per_host=settings.http_max_connections, keepalive_timeout=settings.http_keepalive_timeout_seconds, enable_cleanup_closed=True, + socket_factory=create_dns_validated_socket_factory(), ) return aiohttp.ClientSession(timeout=timeout, connector=connector) diff --git a/backend/app/utils/ip_utils.py b/backend/app/utils/ip_utils.py index 8c162d3..9608320 100644 --- a/backend/app/utils/ip_utils.py +++ b/backend/app/utils/ip_utils.py @@ -141,10 +141,11 @@ async def validate_blocklist_url(url: str) -> None: - The hostname resolves to a public (non-private, non-reserved) IP address - IPv4-mapped IPv6 addresses are checked against IPv4 private ranges - Performs DNS resolution asynchronously to check the resolved IP. - This is a point-in-time check; DNS rebinding attacks may still be possible - at actual fetch time. Callers should re-validate the final connection - in the HTTP client layer. + Performs DNS resolution asynchronously to check the resolved IP. This is a + point-in-time check; the application uses a DNS-validated HTTP connector + that performs runtime re-validation at connection time to prevent DNS-rebinding + attacks where the same hostname resolves to a different (private) IP address + after this initial validation. Args: url: The blocklist URL to validate. diff --git a/backend/tests/test_services/test_dns_validated_connector.py b/backend/tests/test_services/test_dns_validated_connector.py new file mode 100644 index 0000000..13eca29 --- /dev/null +++ b/backend/tests/test_services/test_dns_validated_connector.py @@ -0,0 +1,160 @@ +"""Tests for DNS-validated socket factory that prevents DNS-rebinding attacks.""" + +from __future__ import annotations + +import socket +from unittest.mock import patch + +import pytest + +from app.services.dns_validated_connector import create_dns_validated_socket_factory + + +class TestDnsValidatedSocketFactory: + """Test DNS validation in socket factory.""" + + def test_socket_factory_allows_public_ipv4(self) -> None: + """Test that public IPv4 addresses are allowed.""" + factory = create_dns_validated_socket_factory() + + # Create a mock address_info tuple for a public IPv4 address + address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", 80)) + + # Should not raise + sock = factory(address_info) + assert sock is not None + assert sock.family == socket.AF_INET + assert sock.type == socket.SOCK_STREAM + sock.close() + + def test_socket_factory_allows_public_ipv6(self) -> None: + """Test that public IPv6 addresses are allowed.""" + factory = create_dns_validated_socket_factory() + + # Public IPv6 address (Google DNS) + address_info = (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2606:4700:4700::1111", 80, 0, 0)) + + sock = factory(address_info) + assert sock is not None + assert sock.family == socket.AF_INET6 + sock.close() + + def test_socket_factory_blocks_private_ip_192_168(self) -> None: + """Test that 192.168.x.x private IPs are blocked.""" + factory = create_dns_validated_socket_factory() + + address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.168.1.1", 80)) + + with pytest.raises(OSError) as exc_info: + factory(address_info) + + assert "rebinding" in str(exc_info.value).lower() or "private" in str(exc_info.value).lower() + + def test_socket_factory_blocks_private_ip_10(self) -> None: + """Test that 10.x.x.x private IPs are blocked.""" + factory = create_dns_validated_socket_factory() + + address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.1", 80)) + + with pytest.raises(OSError) as exc_info: + factory(address_info) + + error_msg = str(exc_info.value).lower() + assert "private" in error_msg or "rebinding" in error_msg + + def test_socket_factory_blocks_private_ip_172_16(self) -> None: + """Test that 172.16.x.x private IPs are blocked.""" + factory = create_dns_validated_socket_factory() + + address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("172.16.0.1", 80)) + + with pytest.raises(OSError): + factory(address_info) + + def test_socket_factory_blocks_loopback_ipv4(self) -> None: + """Test that IPv4 loopback is blocked.""" + factory = create_dns_validated_socket_factory() + + address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 80)) + + with pytest.raises(OSError): + factory(address_info) + + def test_socket_factory_blocks_loopback_ipv6(self) -> None: + """Test that IPv6 loopback is blocked.""" + factory = create_dns_validated_socket_factory() + + address_info = (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("::1", 80, 0, 0)) + + with pytest.raises(OSError): + factory(address_info) + + def test_socket_factory_blocks_link_local_ipv6(self) -> None: + """Test that IPv6 link-local addresses are blocked.""" + factory = create_dns_validated_socket_factory() + + address_info = (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("fe80::1", 80, 0, 0)) + + with pytest.raises(OSError): + factory(address_info) + + def test_socket_factory_blocks_ipv6_ula(self) -> None: + """Test that IPv6 ULA (Unique Local Address) is blocked.""" + factory = create_dns_validated_socket_factory() + + # fc00::/7 is ULA + address_info = (socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("fc00::1", 80, 0, 0)) + + with pytest.raises(OSError): + factory(address_info) + + def test_socket_factory_blocks_ipv4_multicast(self) -> None: + """Test that IPv4 multicast addresses are blocked.""" + factory = create_dns_validated_socket_factory() + + # 224.0.0.0/4 is multicast + address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("224.0.0.1", 80)) + + with pytest.raises(OSError): + factory(address_info) + + def test_socket_factory_blocks_reserved_ips(self) -> None: + """Test that reserved IPs are blocked.""" + factory = create_dns_validated_socket_factory() + + # 240.0.0.0/4 is reserved + address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("240.0.0.1", 80)) + + with pytest.raises(OSError): + factory(address_info) + + def test_socket_factory_blocks_broadcast(self) -> None: + """Test that broadcast addresses are blocked.""" + factory = create_dns_validated_socket_factory() + + # 255.255.255.255 is broadcast + address_info = (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("255.255.255.255", 80)) + + with pytest.raises(OSError): + factory(address_info) + + def test_socket_factory_allows_multiple_public_ips(self) -> None: + """Test that multiple public IPs can be created.""" + factory = create_dns_validated_socket_factory() + + public_ips = [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", 80)), + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("1.1.1.1", 443)), + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("208.67.222.222", 53)), + ] + + socks = [] + for address_info in public_ips: + sock = factory(address_info) + assert sock is not None + socks.append(sock) + + # Clean up + for sock in socks: + sock.close() +