fix: disable auto-redirects and validate URLs to prevent SSRF (#3)

- Add _is_safe_url() checking for private/loopback/link-local/reserved IPs
- Block internal hostnames (.local, .internal, localhost, metadata.google.internal)
- Disable allow_redirects in requests.get()
- Validate redirect target before following single hop
- Raise RuntimeError on unsafe URL or redirect
This commit is contained in:
Jarian Cottingham 2026-07-05 07:44:27 +00:00
parent 8e7f5bc920
commit fdc66efe1b

View File

@ -42,12 +42,14 @@ Dependencies
import argparse import argparse
import datetime import datetime
import ipaddress
import json import json
import os import os
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Dict, List
from urllib.parse import urlparse
import requests import requests
@ -165,16 +167,46 @@ def fetch_items(start: datetime.datetime, end: datetime.datetime) -> List[Dict]:
return filtered return filtered
def _is_safe_url(url: str) -> bool:
"""Check that a URL points to a safe (non-internal) destination."""
parsed = urlparse(url)
host = parsed.hostname or ""
# Block private and reserved IP ranges
try:
addr = ipaddress.ip_address(host)
if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved:
return False
except ValueError:
pass
# Block internal hostnames
if host.endswith((".local", ".internal", ".home.arpa")) or host in ("localhost", "metadata.google.internal"):
return False
return True
def download_file(url: str, dest: Path) -> bool: def download_file(url: str, dest: Path) -> bool:
"""Streamdownload the file at *url* to *dest* if it does not already exist.""" """Streamdownload the file at *url* to *dest* if it does not already exist."""
if dest.exists(): if dest.exists():
return False return False
if not _is_safe_url(url):
raise RuntimeError(f"Unsafe download URL blocked (SSRF protection): {url}")
try: try:
with requests.get(url, stream=True, timeout=30) as r: with requests.get(url, stream=True, timeout=30, allow_redirects=False) as r:
r.raise_for_status() if r.status_code in (301, 302, 303, 307, 308):
with dest.open("wb") as f: location = r.headers.get("Location", "")
for chunk in r.iter_content(chunk_size=8192): if not _is_safe_url(location):
f.write(chunk) raise RuntimeError(f"Redirect to unsafe URL blocked (SSRF protection): {location}")
# Follow single validated redirect
with requests.get(location, stream=True, timeout=30, allow_redirects=False) as r2:
r2.raise_for_status()
with dest.open("wb") as f:
for chunk in r2.iter_content(chunk_size=8192):
f.write(chunk)
else:
r.raise_for_status()
with dest.open("wb") as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
return True return True
except Exception as e: except Exception as e:
raise RuntimeError(f"Download error for {url}: {e}") from e raise RuntimeError(f"Download error for {url}: {e}") from e