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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user