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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()