First Push
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"""URL safety helpers — prevent SSRF from user-supplied fetch URLs
|
||||
(calendar subscriptions, weather API hosts)."""
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
ALLOWED_SCHEMES = ("http", "https")
|
||||
|
||||
# Hosts that are always fine to reach (QWeather SaaS / common cloud APIs).
|
||||
# Everything else is resolved and must be a public address.
|
||||
_ALWAYS_ALLOWED = {
|
||||
"devapi.qweather.com",
|
||||
"geoapi.qweather.com",
|
||||
"qweather.com",
|
||||
}
|
||||
|
||||
|
||||
def _host_is_private(hostname: str) -> bool:
|
||||
"""Resolve a hostname and return True if ANY address is non-public."""
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None)
|
||||
except Exception:
|
||||
# Unresolvable — treat as unsafe (fail closed for fetches).
|
||||
return True
|
||||
for info in infos:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
||||
or ip.is_multicast or ip.is_reserved or ip.is_unspecified):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_safe_public_url(url: str) -> bool:
|
||||
"""True if the URL is http(s) and resolves to public addresses only."""
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
try:
|
||||
p = urlparse(url.strip())
|
||||
except Exception:
|
||||
return False
|
||||
if p.scheme not in ALLOWED_SCHEMES or not p.hostname:
|
||||
return False
|
||||
host = p.hostname.lower().rstrip(".")
|
||||
if host in _ALWAYS_ALLOWED or host.endswith(".qweather.com"):
|
||||
return True
|
||||
if p.port and p.port not in (80, 443):
|
||||
return False
|
||||
return not _host_is_private(host)
|
||||
|
||||
|
||||
def is_safe_https_host(host: str) -> bool:
|
||||
"""True if the value is a bare https hostname (no scheme/path) and public."""
|
||||
if not host or not isinstance(host, str):
|
||||
return False
|
||||
host = host.strip().lower().rstrip(".")
|
||||
if not host or "://" in host or "/" in host or " " in host:
|
||||
return False
|
||||
if host in _ALWAYS_ALLOWED or host.endswith(".qweather.com"):
|
||||
return True
|
||||
return not _host_is_private(host)
|
||||
|
||||
|
||||
def safe_http_get(url: str, timeout: float = 15, max_redirects: int = 3):
|
||||
"""GET a URL, re-validating safety on every redirect hop.
|
||||
|
||||
Returns the final `requests.Response` (status 200) or None if any hop is
|
||||
unsafe/unresolvable or the redirect chain exceeds max_redirects.
|
||||
Raises requests.HTTPError for non-2xx final responses.
|
||||
"""
|
||||
import requests
|
||||
from urllib.parse import urljoin
|
||||
|
||||
current = (url or "").strip()
|
||||
for _ in range(max_redirects + 1):
|
||||
if not is_safe_public_url(current):
|
||||
return None
|
||||
resp = requests.get(current, timeout=timeout, allow_redirects=False)
|
||||
if resp.status_code in (301, 302, 303, 307, 308):
|
||||
location = resp.headers.get("Location", "")
|
||||
if not location:
|
||||
return None
|
||||
current = urljoin(current, location)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
return None
|
||||
Reference in New Issue
Block a user