TASK-009: Mitigate SSRF vulnerability in blocklist URL validation

- Change BlocklistSourceCreate.url from str to AnyHttpUrl (Pydantic type)
  - Rejects non-http schemes (file://, ftp://, etc.) at model boundary

- Add is_private_ip() utility to detect RFC 1918 private ranges:
  - 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (RFC 1918)
  - 127.0.0.0/8, ::1/128 (loopback)
  - 169.254.0.0/16, fe80::/10 (link-local)
  - IPv6 site-local, multicast, and reserved ranges

- Add async validate_blocklist_url() function:
  - Resolves hostname via DNS using loop.run_in_executor()
  - Rejects if hostname resolves to private/reserved IP
  - Raises ValueError on validation failure

- Integrate validation into service layer:
  - create_source() calls validate_blocklist_url() before persist
  - update_source() conditionally validates if url provided
  - Both raise ValueError on failure

- Update router endpoints with error handling:
  - create_blocklist() and update_blocklist() catch ValueError
  - Return HTTP 400 Bad Request with descriptive error message

- Add comprehensive test coverage (9 new SSRF tests):
  - file://, ftp://, localhost, 127.0.0.1, 192.168.x.x
  - 10.x.x.x, 172.16.x.x, 169.254.x.x (link-local)
  - Valid public URLs (passes validation)
  - All 36 service tests passing

- Update documentation:
  - Features.md: Document URL validation constraints
  - Backend-Development.md: Add SSRF prevention pattern section

Fixes SSRF vulnerability where authenticated users could supply
file://, ftp://, or private IP URLs and the backend would fetch them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-26 12:57:23 +02:00
parent a5b55d1248
commit 4ab767e3d4
9 changed files with 291 additions and 66 deletions

View File

@@ -4,7 +4,10 @@ All IP handling in BanGUI goes through these helpers to enforce consistency
and prevent malformed addresses from reaching fail2ban.
"""
import asyncio
import ipaddress
import socket
from urllib.parse import urlparse
def is_valid_ip(address: str) -> bool:
@@ -99,3 +102,97 @@ def ip_version(address: str) -> int:
ValueError: If *address* is not a valid IP address.
"""
return ipaddress.ip_address(address).version
def is_private_ip(address: str) -> bool:
"""Return ``True`` if *address* is a private or reserved IP address.
Private ranges include:
- RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
- Loopback: 127.0.0.0/8 (IPv4), ::1/128 (IPv6)
- Link-local: 169.254.0.0/16 (IPv4), fe80::/10 (IPv6)
- IPv6 ULA: fc00::/7
- Multicast and other reserved ranges
Args:
address: A valid IP address string.
Returns:
``True`` if the address is private or reserved, ``False`` if it is public.
Raises:
ValueError: If *address* is not a valid IP address.
"""
ip = ipaddress.ip_address(address)
return (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
)
async def validate_blocklist_url(url: str) -> None:
"""Validate that a blocklist URL points to a public HTTP(S) endpoint.
Checks that:
- The URL uses HTTP or HTTPS scheme
- 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.
Args:
url: The blocklist URL to validate.
Raises:
ValueError: If the URL has an invalid scheme, hostname cannot be resolved,
or the resolved IP is private/reserved.
"""
try:
parsed = urlparse(url)
except Exception as exc:
raise ValueError(f"Invalid URL format: {exc}") from exc
if parsed.scheme not in ("http", "https"):
raise ValueError(
f"Invalid scheme '{parsed.scheme}': only http and https are allowed"
)
if not parsed.hostname:
raise ValueError("URL has no hostname")
hostname = parsed.hostname
try:
loop = asyncio.get_event_loop()
addrinfo = await loop.run_in_executor(
None,
socket.getaddrinfo,
hostname,
parsed.port or 80,
socket.AF_UNSPEC,
socket.SOCK_STREAM,
)
except socket.gaierror as exc:
raise ValueError(f"Cannot resolve hostname '{hostname}': {exc}") from exc
except Exception as exc:
raise ValueError(f"DNS resolution error for '{hostname}': {exc}") from exc
if not addrinfo:
raise ValueError(f"No address resolved for hostname '{hostname}'")
for family, socktype, proto, canonname, sockaddr in addrinfo:
ip_str: str = sockaddr[0] # type: ignore[assignment]
try:
if is_private_ip(ip_str):
raise ValueError(
f"Hostname '{hostname}' resolves to private/reserved IP: {ip_str}"
)
except ipaddress.AddressValueError as exc:
raise ValueError(f"Invalid IP address: {ip_str}") from exc