feat: Add runtime DNS-rebinding protection for blocklist HTTP connections
## 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>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user