| """SSRF protection utilities. |
| |
| Validates that user-supplied URLs point to allowed public hosts and |
| blocks requests to private/reserved IP ranges. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import ipaddress |
| import logging |
| from urllib.parse import urlparse |
|
|
| from fastapi import HTTPException |
|
|
| logger = logging.getLogger(__name__) |
|
|
| ALLOWED_HOSTS: set[str] = { |
| "files.rcsb.org", |
| "data.rcsb.org", |
| "search.rcsb.org", |
| "www.rcsb.org", |
| "alphafold.ebi.ac.uk", |
| "rest.uniprot.org", |
| "www.uniprot.org", |
| } |
|
|
| PRIVATE_NETWORKS = [ |
| ipaddress.ip_network("127.0.0.0/8"), |
| ipaddress.ip_network("10.0.0.0/8"), |
| ipaddress.ip_network("172.16.0.0/12"), |
| ipaddress.ip_network("192.168.0.0/16"), |
| ipaddress.ip_network("169.254.0.0/16"), |
| ipaddress.ip_network("::1/128"), |
| ipaddress.ip_network("fc00::/7"), |
| ipaddress.ip_network("fe80::/10"), |
| ] |
|
|
|
|
| def validate_url(url: str, param_name: str = "url") -> None: |
| """Validate a user-supplied URL against SSRF protections. |
| |
| Checks: |
| 1. URL is well-formed |
| 2. Host is in the allowlist |
| 3. Resolved IP is not in a private/reserved range |
| |
| Raises HTTPException(400) on violation. |
| """ |
| if not url or not url.strip(): |
| return |
|
|
| parsed = urlparse(url) |
| if parsed.scheme not in ("http", "https"): |
| raise HTTPException(status_code=400, detail=f"{param_name}: only http/https URLs are allowed") |
|
|
| hostname = parsed.hostname |
| if not hostname: |
| raise HTTPException(status_code=400, detail=f"{param_name}: invalid URL — no hostname") |
|
|
| |
| host_allowed = any( |
| hostname == allowed or hostname.endswith("." + allowed) |
| for allowed in ALLOWED_HOSTS |
| ) |
| if not host_allowed: |
| raise HTTPException( |
| status_code=400, |
| detail=f"{param_name}: host '{hostname}' is not in the allowed list. " |
| f"Allowed: {', '.join(sorted(ALLOWED_HOSTS))}", |
| ) |
|
|
| |
| try: |
| import socket |
| addrinfos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) |
| for family, _, _, _, sockaddr in addrinfos: |
| ip = ipaddress.ip_address(sockaddr[0]) |
| for net in PRIVATE_NETWORKS: |
| if ip in net: |
| raise HTTPException( |
| status_code=400, |
| detail=f"{param_name}: resolved to private IP {ip} — request blocked", |
| ) |
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.warning(f"SSRF DNS check failed for {hostname}: {e}") |
| |
| raise HTTPException( |
| status_code=400, |
| detail=f"{param_name}: could not resolve hostname — request blocked", |
| ) |
|
|