Files
BanGUI/backend/app/services/dns_validated_connector.py
Lukas cc4370c50d 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>
2026-04-29 19:10:51 +02:00

97 lines
3.3 KiB
Python

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