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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
96
backend/app/services/dns_validated_connector.py
Normal file
96
backend/app/services/dns_validated_connector.py
Normal 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
|
||||
Reference in New Issue
Block a user