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:
2026-04-29 19:10:51 +02:00
parent 9072117db3
commit cc4370c50d
7 changed files with 315 additions and 8 deletions

View File

@@ -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.